feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.

This commit is contained in:
Michael committed 2025-12-17 23:45:56 +01:00
1 parent 2ca92d8425
commit 55acbbc135
28 files changed
+2075 -1448

No files matched your search

+34 -26
View File
@@ -7,14 +7,15 @@ import 'package:dio/io.dart';
/// Useful for emulators or restricted networks
class DnsFallbackInterceptor extends Interceptor {
final Dio _dio;
// Cache for resolved IPs
final Map<String, String> _dnsCache = {};
DnsFallbackInterceptor(this._dio);
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
// Basic check for connection/host lookup errors
if (_isDnsError(err)) {
final uri = err.requestOptions.uri;
@@ -30,12 +31,12 @@ class DnsFallbackInterceptor extends Interceptor {
// Attempt resolution attempts
print('DnsFallback: Attempting DoH resolution...');
ip = await _resolveWithDoH(host);
if (ip != null) {
print('DnsFallback: Resolved $host to $ip');
_dnsCache[host] = ip;
} else {
print('DnsFallback: Failed to resolve $host');
print('DnsFallback: Failed to resolve $host');
}
} catch (e) {
print('DnsFallback: Resolution error: $e');
@@ -48,30 +49,32 @@ class DnsFallbackInterceptor extends Interceptor {
// Clone options but replace host with IP
final newUri = uri.replace(host: ip);
final options = err.requestOptions.copyWith(path: newUri.toString());
// IMPORTANT: Set Host header so the server handles it correctly
options.headers['Host'] = host;
// Retry request
print('DnsFallback: Retrying request to $ip');
final response = await _dio.fetch(options);
return handler.resolve(response);
} catch (e) {
print('DnsFallback: Retry failed: $e');
return handler.next(err);
print('DnsFallback: Retry failed: $e');
return handler.next(err);
}
}
}
handler.next(err);
}
bool _isDnsError(DioException err) {
if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.unknown) {
if (err.error is SocketException) {
final msg = (err.error as SocketException).message;
return msg.contains('Failed host lookup') || msg.contains('No address associated with hostname');
}
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.unknown) {
if (err.error is SocketException) {
final msg = (err.error as SocketException).message;
return msg.contains('Failed host lookup') ||
msg.contains('No address associated with hostname');
}
}
return false;
}
@@ -79,14 +82,17 @@ class DnsFallbackInterceptor extends Interceptor {
/// Resolve hostname using multiple DoH providers
Future<String?> _resolveWithDoH(String hostname) async {
// 1. Google DNS (8.8.8.8)
String? ip = await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A');
String? ip =
await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A');
if (ip != null) return ip;
// 2. Cloudflare DNS (1.1.1.1)
ip = await _queryDoH('https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'});
ip = await _queryDoH(
'https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'},
);
if (ip != null) return ip;
return null;
}
@@ -94,26 +100,28 @@ class DnsFallbackInterceptor extends Interceptor {
try {
// Create a raw HttpClient to avoid loop and control SSL
final client = HttpClient()
..badCertificateCallback = (cert, host, port) => true; // IGNORE SSL ERRORS just for DoH IP connectivity
..badCertificateCallback = (cert, host, port) =>
true; // IGNORE SSL ERRORS just for DoH IP connectivity
final request = await client.getUrl(Uri.parse(url));
if (headers != null) {
headers.forEach((k, v) => request.headers.add(k, v));
}
final response = await request.close();
if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body);
// Google/Cloudflare JSON format
if (json['Status'] == 0 && json['Answer'] != null) {
final answers = json['Answer'] as List;
for (final ans in answers) {
if (ans['type'] == 1) { // Type A record
return ans['data'];
}
if (ans['type'] == 1) {
// Type A record
return ans['data'];
}
}
}
}
+6 -7
View File
@@ -23,12 +23,10 @@ class DnsResolver {
);
// Fallback to Cloudflare via IP (1.1.1.1)
if (ip == null) {
ip = await _queryDoH(
'https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'},
);
}
ip ??= await _queryDoH(
'https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'},
);
if (ip != null) {
print('DnsResolver: Resolved $hostname -> $ip');
@@ -40,7 +38,8 @@ class DnsResolver {
return ip;
}
static Future<String?> _queryDoH(String url, {Map<String, String>? headers}) async {
static Future<String?> _queryDoH(String url,
{Map<String, String>? headers}) async {
try {
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 10)
+9 -10
View File
@@ -8,7 +8,7 @@ import '../models/playlist_config.dart';
class HiveService {
static const String _usersBoxName = 'users';
static const String _playlistsBoxName = 'playlists';
static bool _initialized = false;
static List<int>? _encryptionKey;
@@ -45,27 +45,27 @@ class HiveService {
// Generate new 256-bit key
_encryptionKey = Hive.generateSecureKey();
return _encryptionKey!;
}
/// Seed default admin user (admin/admin)
static Future<void> _seedDefaultAdmin() async {
final usersBox = Hive.box<AppUser>(_usersBoxName);
if (usersBox.isEmpty) {
final adminId = const Uuid().v4();
final passwordHash = _hashPassword('admin');
final admin = AppUser(
id: adminId,
username: 'admin',
passwordHash: passwordHash,
isAdmin: true,
assignedPlaylistIds: [],
assignedPlaylistIds: const [],
createdAt: DateTime.now(),
);
await usersBox.put(adminId, admin);
}
}
@@ -88,12 +88,12 @@ class HiveService {
final legacyHash = sha256.convert(utf8.encode(password)).toString();
return legacyHash == storedHash;
}
final salt = parts[0];
final expectedHash = parts[1];
final bytes = utf8.encode(password + salt);
final actualHash = sha256.convert(bytes).toString();
return actualHash == expectedHash;
} catch (e) {
return false;
@@ -103,12 +103,11 @@ class HiveService {
/// Public method to hash passwords (used by auth)
static String hashPassword(String password) => _hashPassword(password);
/// Get users box
static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName);
/// Get playlists box
static Box<PlaylistConfig> get playlistsBox =>
static Box<PlaylistConfig> get playlistsBox =>
Hive.box<PlaylistConfig>(_playlistsBoxName);
/// Close all boxes (cleanup)
+41 -68
View File
@@ -1,30 +1,18 @@
import '../api/api_client.dart';
import 'package:uuid/uuid.dart';
import '../database/hive_service.dart';
import '../models/playlist_config.dart';
/// Service for managing playlists via API
/// Service for managing playlists via local storage (Hive)
class PlaylistApiService {
final ApiClient _api = ApiClient();
/// Get all playlists for current user
Future<List<PlaylistConfig>> getPlaylists() async {
try {
final response = await _api.get('/api/playlists');
final data = response.data as Map<String, dynamic>;
final playlistsData = data['playlists'] as List<dynamic>;
return playlistsData.map((p) {
final playlist = p as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}).toList();
if (!HiveService.playlistsBox.isOpen) {
await HiveService.init();
}
return HiveService.playlistsBox.values.toList();
} catch (e) {
print('Error fetching playlists: $e');
print('Error fetching playlists from Hive: $e');
return [];
}
}
@@ -37,30 +25,20 @@ class PlaylistApiService {
required String password,
}) async {
try {
final response = await _api.post('/api/playlists', data: {
'name': name,
'serverUrl': dns,
'username': username,
'password': password,
'dns': dns,
},);
final id = const Uuid().v4();
final playlist = PlaylistConfig(
id: id,
name: name,
dns: dns,
username: username,
password: password,
createdAt: DateTime.now(),
);
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}
return null;
await HiveService.playlistsBox.put(id, playlist);
return playlist;
} catch (e) {
print('Error creating playlist: $e');
print('Error creating playlist in Hive: $e');
return null;
}
}
@@ -74,30 +52,26 @@ class PlaylistApiService {
required String password,
}) async {
try {
final response = await _api.put('/api/playlists/$id', data: {
'name': name,
'serverUrl': dns,
'username': username,
'password': password,
'dns': dns,
},);
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
if (!HiveService.playlistsBox.containsKey(id)) {
return null;
}
return null;
final existing = HiveService.playlistsBox.get(id);
if (existing == null) return null;
final updated = PlaylistConfig(
id: id,
name: name,
dns: dns,
username: username,
password: password,
createdAt: existing.createdAt,
);
await HiveService.playlistsBox.put(id, updated);
return updated;
} catch (e) {
print('Error updating playlist: $e');
print('Error updating playlist in Hive: $e');
return null;
}
}
@@ -105,11 +79,10 @@ class PlaylistApiService {
/// Delete a playlist
Future<bool> deletePlaylist(String id) async {
try {
final response = await _api.delete('/api/playlists/$id');
final data = response.data as Map<String, dynamic>;
return data['success'] == true;
await HiveService.playlistsBox.delete(id);
return true;
} catch (e) {
print('Error deleting playlist: $e');
print('Error deleting playlist from Hive: $e');
return false;
}
}
+27 -28
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
/// XtremFlow Apple TV Style Palette
///
///
/// Ultra minimalist, focus-driven palette.
/// - Background: Pure Black (#000000)
/// - Focus/Active: Pure White (#FFFFFF)
@@ -13,20 +13,20 @@ class AppColors {
// ============ BACKGROUNDS ============
/// Pure black for that infinite depth look (OLED friendly)
static const Color background = Color(0xFF000000);
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
static const Color surface = Color(0xFF1C1C1E);
/// Slightly lighter grey for secondary surfaces or hover states
static const Color surfaceVariant = Color(0xFF3A3A3C);
/// Focused element background (often white in tvOS for text, or bright accent)
static const Color focusColor = Color(0xFFFFFFFF);
// ============ ACCENTS ============
/// Minimal white accent. In Apple TV, color is used sparingly.
/// We keep a subtle blue only for specific indicators if needed.
static const Color primary = Color(0xFFFFFFFF);
static const Color primary = Color(0xFFFFFFFF);
static const Color accent = Color(0xFFE5E5E5);
/// Primary Gradient (Subtle silver/white glow)
@@ -60,7 +60,7 @@ class AppColors {
// ============ BORDERS ============
// Borders are usually hidden until focused
static const Color border = Color(0xFF1F1F1F);
static const Color border = Color(0xFF1F1F1F);
static const Color focusBorder = Color(0xFFFFFFFF);
// ============ GLASSMORPHISM ============
@@ -70,28 +70,27 @@ class AppColors {
// ============ THEME SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: primary,
secondary: accent,
surface: surface,
background: background,
error: error,
onPrimary: Colors.black, // White text on buttons -> Black text on White buttons
onSecondary: Colors.black,
onSurface: textPrimary,
onError: Colors.white,
brightness: Brightness.dark,
);
primary: primary,
secondary: accent,
surface: surface,
error: error,
onPrimary: Colors
.black, // White text on buttons -> Black text on White buttons
onSecondary: Colors.black,
onSurface: textPrimary,
onError: Colors.white,
brightness: Brightness.dark,
);
static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Colors.black,
secondary: Colors.grey,
surface: Color(0xFFF2F2F7),
background: Colors.white,
error: error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: Colors.black,
onError: Colors.white,
brightness: Brightness.light,
);
primary: Colors.black,
secondary: Colors.grey,
surface: Color(0xFFF2F2F7),
error: error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: Colors.black,
onError: Colors.white,
brightness: Brightness.light,
);
}
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key});