feat: add mobile-optimized IPTV player with HLS and MpegTS support

This commit is contained in:
Michael committed 2026-03-29 10:19:36 +02:00
1 parent 3c9a9c7be7
commit fa6ae90d8d
38 files changed
+1280 -895

No files matched your search

+8 -5
View File
@@ -29,8 +29,9 @@ class EpgApi {
final playlist = await _getPlaylist(request); final playlist = await _getPlaylist(request);
if (playlist == null) { if (playlist == null) {
return Response.forbidden( return Response.forbidden(
json.encode({'error': 'Playlist non trouvée'}), json.encode({'error': 'Playlist non trouvée'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
final dns = playlist.dns; final dns = playlist.dns;
@@ -43,7 +44,7 @@ class EpgApi {
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
Map<String, dynamic> epgData = { Map<String, dynamic> epgData = {
'channel_id': channelId, 'channel_id': channelId,
'programmes': [] 'programmes': [],
}; };
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -72,8 +73,10 @@ class EpgApi {
expiresAt: DateTime.now().add(const Duration(minutes: 30)), expiresAt: DateTime.now().add(const Duration(minutes: 30)),
); );
return Response.ok(jsonStr, return Response.ok(
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}); jsonStr,
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
);
} catch (e) { } catch (e) {
return Response.internalServerError( return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}), body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
+13 -9
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/playlist.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
import '../database/database.dart'; import '../database/database.dart';
@@ -34,8 +33,9 @@ class ProxyHandler {
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async { Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
// Basic caching to avoid DB overhead on every video segment // Basic caching to avoid DB overhead on every video segment
final now = DateTime.now(); final now = DateTime.now();
final cacheKey = 'global_playlist'; // Currently app has one primary playlist per user/global const cacheKey =
'global_playlist'; // Currently app has one primary playlist per user/global
if (_playlistCache.containsKey(cacheKey)) { if (_playlistCache.containsKey(cacheKey)) {
final (cached, expiry) = _playlistCache[cacheKey]!; final (cached, expiry) = _playlistCache[cacheKey]!;
if (now.isBefore(expiry)) return cached; if (now.isBefore(expiry)) return cached;
@@ -134,7 +134,8 @@ class ProxyHandler {
final playlist = await _getCachedPlaylist(request); final playlist = await _getCachedPlaylist(request);
if (playlist == null) { if (playlist == null) {
return Response.forbidden( return Response.forbidden(
'No active playlist configuration found to validate request'); 'No active playlist configuration found to validate request',
);
} }
final targetHost = targetUrl.host.toLowerCase(); final targetHost = targetUrl.host.toLowerCase();
@@ -142,9 +143,11 @@ class ProxyHandler {
if (targetHost != allowedHost) { if (targetHost != allowedHost) {
print( print(
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)'); '[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
);
return Response.forbidden( return Response.forbidden(
'Access to this domain is forbidden by policy'); 'Access to this domain is forbidden by policy',
);
} }
} }
@@ -163,14 +166,14 @@ class ProxyHandler {
try { try {
print('[Proxy] Forwarding to: $targetUrl'); print('[Proxy] Forwarding to: $targetUrl');
final proxyRequest = http.Request(request.method, targetUrl); final proxyRequest = http.Request(request.method, targetUrl);
// Forward safe request headers // Forward safe request headers
for (final header in _allowedRequestHeaders) { for (final header in _allowedRequestHeaders) {
if (request.headers.containsKey(header)) { if (request.headers.containsKey(header)) {
proxyHeaders[header] = request.headers[header]!; proxyHeaders[header] = request.headers[header]!;
} }
} }
proxyRequest.headers.addAll(proxyHeaders); proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = true; proxyRequest.followRedirects = true;
@@ -215,7 +218,8 @@ class ProxyHandler {
targetUrl?.path.endsWith('.jpg') == true) { targetUrl?.path.endsWith('.jpg') == true) {
return Response.ok( return Response.ok(
base64Decode( base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
),
headers: {'content-type': 'image/png'}, headers: {'content-type': 'image/png'},
); );
} }
+31 -18
View File
@@ -36,19 +36,25 @@ class SeasonPassesApi {
final streamUrl = data['stream_url'] as String?; final streamUrl = data['stream_url'] as String?;
if (showTitle == null || showTitle.isEmpty) { if (showTitle == null || showTitle.isEmpty) {
return Response(400, return Response(
body: json.encode({'error': 'show_title est requis'}), 400,
headers: {'Content-Type': 'application/json'}); body: json.encode({'error': 'show_title est requis'}),
headers: {'Content-Type': 'application/json'},
);
} }
if (channelId == null || channelId.isEmpty) { if (channelId == null || channelId.isEmpty) {
return Response(400, return Response(
body: json.encode({'error': 'channel_id est requis'}), 400,
headers: {'Content-Type': 'application/json'}); body: json.encode({'error': 'channel_id est requis'}),
headers: {'Content-Type': 'application/json'},
);
} }
if (streamUrl == null || streamUrl.isEmpty) { if (streamUrl == null || streamUrl.isEmpty) {
return Response(400, return Response(
body: json.encode({'error': 'stream_url est requis'}), 400,
headers: {'Content-Type': 'application/json'}); body: json.encode({'error': 'stream_url est requis'}),
headers: {'Content-Type': 'application/json'},
);
} }
// Récupérer l'utilisateur depuis le contexte // Récupérer l'utilisateur depuis le contexte
@@ -57,13 +63,18 @@ class SeasonPassesApi {
// Vérifier si un season pass identique existe déjà // Vérifier si un season pass identique existe déjà
final existing = _db.getAllSeasonPasses(); final existing = _db.getAllSeasonPasses();
final duplicate = existing.any((p) => final duplicate = existing.any(
(p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() && (p) =>
p['channel_id'] == channelId); (p['show_title'] as String).toLowerCase() ==
showTitle.toLowerCase() &&
p['channel_id'] == channelId,
);
if (duplicate) { if (duplicate) {
return Response(409, return Response(
body: json.encode({'error': 'Un Season Pass identique existe déjà'}), 409,
headers: {'Content-Type': 'application/json'}); body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
headers: {'Content-Type': 'application/json'},
);
} }
final pass = _db.createSeasonPass( final pass = _db.createSeasonPass(
@@ -74,9 +85,11 @@ class SeasonPassesApi {
); );
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId'); print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
return Response(201, return Response(
body: json.encode(pass), 201,
headers: {'Content-Type': 'application/json'}); body: json.encode(pass),
headers: {'Content-Type': 'application/json'},
);
} catch (e) { } catch (e) {
return Response.internalServerError( return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}), body: json.encode({'error': 'Erreur: $e'}),
+28 -18
View File
@@ -153,17 +153,20 @@ Handler createLiveStreamHandler(
'-i', targetUrl, '-i', targetUrl,
// Video // Video
if (useNvidiaGpu) ...[ if (useNvidiaGpu) ...[
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune',
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate 'hq', // Better quality preset for NVENC
'-g', '50', '-b:v', '8000k', '-maxrate', '12000k', '-bufsize',
'16000k', // Quality native / high bitrate
'-g', '50',
] else ...[ ] else ...[
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency', // Better detail than ultrafast '-c:v', 'libx264', '-preset', 'medium', '-tune',
'zerolatency', // Better detail than ultrafast
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k', '-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-g', '50', '-g', '50',
], ],
// Audio: High quality + Sync filter // Audio: High quality + Sync filter
'-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000', '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
'-af', 'aresample=async=1', '-af', 'aresample=async=1',
// HLS Sliding Window // HLS Sliding Window
'-f', 'hls', '-f', 'hls',
'-hls_time', '2', '-hls_time', '2',
@@ -201,15 +204,19 @@ Handler createLiveStreamHandler(
retries++; retries++;
} }
if (!file.existsSync()) if (!file.existsSync()) {
return Response.internalServerError(body: 'Timeout starting live HLS'); return Response.internalServerError(body: 'Timeout starting live HLS');
}
return Response.ok(file.openRead(), headers: { return Response.ok(
'Content-Type': 'application/vnd.apple.mpegurl', file.openRead(),
'Access-Control-Allow-Origin': '*', headers: {
'Cache-Control': 'no-cache', 'Content-Type': 'application/vnd.apple.mpegurl',
'X-Content-Type-Options': 'nosniff', 'Access-Control-Allow-Origin': '*',
}); 'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
},
);
}); });
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback) // Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
@@ -245,11 +252,14 @@ Handler createLiveStreamHandler(
final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found'); if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: { return Response.ok(
'Content-Type': 'video/mp2t', file.openRead(),
'Access-Control-Allow-Origin': '*', headers: {
'Cache-Control': 'max-age=60', 'Content-Type': 'video/mp2t',
}); 'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=60',
},
);
}); });
return router; return router;
+16 -5
View File
@@ -469,7 +469,7 @@ class AppDatabase {
startTime.toIso8601String(), startTime.toIso8601String(),
endTime.toIso8601String(), endTime.toIso8601String(),
now, now,
now now,
], ],
); );
@@ -489,7 +489,8 @@ class AppDatabase {
/// Lister tous les enregistrements (pour le Scheduler et l'admin) /// Lister tous les enregistrements (pour le Scheduler et l'admin)
List<Recording> getAllRecordings() { List<Recording> getAllRecordings() {
final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC'); final result =
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
return result.map((row) => Recording.fromMap(row)).toList(); return result.map((row) => Recording.fromMap(row)).toList();
} }
@@ -510,7 +511,8 @@ class AppDatabase {
} }
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement /// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) { void updateRecordingStatus(String id, String status,
{String? filePath, String? errorReason}) {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
_db.execute( _db.execute(
''' '''
@@ -542,12 +544,21 @@ class AppDatabase {
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, now], [id, userId, showTitle, channelId, streamUrl, now],
); );
return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now}; return {
'id': id,
'user_id': userId,
'show_title': showTitle,
'channel_id': channelId,
'stream_url': streamUrl,
'enabled': 1,
'created_at': now
};
} }
/// Lister tous les Season Passes /// Lister tous les Season Passes
List<Map<String, dynamic>> getAllSeasonPasses() { List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC'); final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
return result.map((r) => Map<String, dynamic>.from(r)).toList(); return result.map((r) => Map<String, dynamic>.from(r)).toList();
} }
+6 -4
View File
@@ -46,7 +46,7 @@ void main(List<String> args) async {
// Injecter la config playlist dans le scheduler pour les Season Passes // Injecter la config playlist dans le scheduler pour les Season Passes
// (on prend la playlist du premier utilisateur disponible) // (on prend la playlist du premier utilisateur disponible)
Future<void> _injectPlaylistToScheduler() async { Future<void> injectPlaylistToScheduler() async {
final users = db.getAllUsers(); final users = db.getAllUsers();
if (users.isNotEmpty) { if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id); final playlists = db.getPlaylists(users[0].id);
@@ -61,7 +61,7 @@ void main(List<String> args) async {
} }
// Injecter après 5s pour attendre l'initialisation complète // Injecter après 5s pour attendre l'initialisation complète
Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler); Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
// Initialize Streaming Subsystem // Initialize Streaming Subsystem
await initStreaming(); await initStreaming();
@@ -84,14 +84,16 @@ void main(List<String> args) async {
if (users.isNotEmpty) { if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id); final playlists = db.getPlaylists(users[0].id);
print( print(
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists'); '[getPlaylist] User ${users[0].username} has ${playlists.length} playlists',
);
if (playlists.isNotEmpty) playlist = playlists.first; if (playlists.isNotEmpty) playlist = playlists.first;
} }
} }
if (playlist != null) { if (playlist != null) {
print( print(
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})'); '[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
);
return PlaylistConfig( return PlaylistConfig(
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
+91 -48
View File
@@ -25,11 +25,14 @@ class RecordingScheduler {
RecordingScheduler(this._db); RecordingScheduler(this._db);
void start() { void start() {
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV'); print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat) // Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
_timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings()); _timer = Timer.periodic(
const Duration(seconds: 10), (_) => _checkAndRunRecordings());
// Season Passes : vérifier toutes les 4 heures // Season Passes : vérifier toutes les 4 heures
_seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses()); _seasonPassTimer =
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
// Lancer une première vérification immédiatement // Lancer une première vérification immédiatement
_checkAndRunRecordings(); _checkAndRunRecordings();
// Vérification initiale des season passes après 30s (laisser le serveur démarrer) // Vérification initiale des season passes après 30s (laisser le serveur démarrer)
@@ -50,47 +53,58 @@ class RecordingScheduler {
try { try {
// Toujours comparer en UTC pour éviter les problèmes de fuseau horaire // Toujours comparer en UTC pour éviter les problèmes de fuseau horaire
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final recordings = _db.getAllRecordings(); final recordings = _db.getAllRecordings();
print('[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}'); print(
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter // Si un enregistrement est en cours, vérifier s'il doit s'arrêter
if (_currentRecording != null) { if (_currentRecording != null) {
if (now.isAfter(_currentRecording!.endTime.toUtc())) { if (now.isAfter(_currentRecording!.endTime.toUtc())) {
print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); print(
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
await _stopCurrentRecording(); await _stopCurrentRecording();
} }
} }
// Rechercher les enregistrements planifiés // Rechercher les enregistrements planifiés
for (final recording in recordings) { for (final recording in recordings) {
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
if (recording.status == 'recording' && _currentRecording?.id != recording.id) { if (recording.status == 'recording' &&
print('[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}'); _currentRecording?.id != recording.id) {
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur'); print(
continue; '[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Interruption inattendue du serveur');
continue;
} }
if (recording.status == 'scheduled') { if (recording.status == 'scheduled') {
final startUtc = recording.startTime.toUtc(); final startUtc = recording.startTime.toUtc();
final endUtc = recording.endTime.toUtc(); final endUtc = recording.endTime.toUtc();
print('[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}'); print(
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini // Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
if (now.isAfter(startUtc) && now.isBefore(endUtc)) { if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
if (_currentRecording != null) { if (_currentRecording != null) {
print('[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours.'); '[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Un autre enregistrement était déjà en cours.');
continue; continue;
} }
print('[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***'); print(
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
await _startRecording(recording); await _startRecording(recording);
} }
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
if (now.isAfter(endUtc)) { if (now.isAfter(endUtc)) {
print('[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Heure de fin dépassée avant le démarrage'); '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Heure de fin dépassée avant le démarrage');
} }
} }
} }
@@ -111,7 +125,8 @@ class RecordingScheduler {
final password = playlistPassword; final password = playlistPassword;
if (dns == null || username == null || password == null) { if (dns == null || username == null || password == null) {
print('[SeasonPass] Config playlist non disponible, vérification annulée'); print(
'[SeasonPass] Config playlist non disponible, vérification annulée');
return; return;
} }
@@ -128,29 +143,36 @@ class RecordingScheduler {
final url = '$dns/player_api.php?username=$username&password=$password' final url = '$dns/player_api.php?username=$username&password=$password'
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48'; '&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode != 200) continue; if (response.statusCode != 200) continue;
final raw = json.decode(response.body); final raw = json.decode(response.body);
final listings = (raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? []; final listings =
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
for (final item in listings) { for (final item in listings) {
String title = item['title'] as String? ?? ''; String title = item['title'] as String? ?? '';
try { title = utf8.decode(base64Decode(title)); } catch (_) {} try {
title = utf8.decode(base64Decode(title));
} catch (_) {}
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle) // Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue; if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
// Parser les heures de début/fin // Parser les heures de début/fin
final startStr = item['start'] as String? ?? ''; final startStr = item['start'] as String? ?? '';
final endStr = item['stop'] as String? ?? item['end'] as String? ?? ''; final endStr =
item['stop'] as String? ?? item['end'] as String? ?? '';
if (startStr.isEmpty || endStr.isEmpty) continue; if (startStr.isEmpty || endStr.isEmpty) continue;
DateTime startTime, endTime; DateTime startTime, endTime;
try { try {
startTime = DateTime.parse(startStr).toUtc(); startTime = DateTime.parse(startStr).toUtc();
endTime = DateTime.parse(endStr).toUtc(); endTime = DateTime.parse(endStr).toUtc();
} catch (_) { continue; } } catch (_) {
continue;
}
// Ne pas créer pour les programmes déjà terminés // Ne pas créer pour les programmes déjà terminés
if (endTime.isBefore(DateTime.now().toUtc())) continue; if (endTime.isBefore(DateTime.now().toUtc())) continue;
@@ -170,7 +192,8 @@ class RecordingScheduler {
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
); );
print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
} }
} catch (e) { } catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e'); print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
@@ -195,8 +218,13 @@ class RecordingScheduler {
await _checkDiskSpaceAndRotate(recordingsDir); await _checkDiskSpaceAndRotate(recordingsDir);
// Génération d'un nom de fichier unique et sûr // Génération d'un nom de fichier unique et sûr
final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); final safeTitle =
final dateStr = recording.startTime.toUtc().toIso8601String().replaceAll(':', '').split('.')[0]; recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
final dateStr = recording.startTime
.toUtc()
.toIso8601String()
.replaceAll(':', '')
.split('.')[0];
final fileName = '${safeTitle}_$dateStr.mkv'; final fileName = '${safeTitle}_$dateStr.mkv';
final filePath = p.join(recordingsDir.path, fileName); final filePath = p.join(recordingsDir.path, fileName);
final logFilePath = filePath.replaceAll('.mkv', '.log'); final logFilePath = filePath.replaceAll('.mkv', '.log');
@@ -210,10 +238,13 @@ class RecordingScheduler {
final args = [ final args = [
'-y', '-y',
'-i', streamUrl, '-i',
'-c', 'copy', streamUrl,
'-t', '${recording.endTime.difference(DateTime.now()).inSeconds}', '-c',
filePath 'copy',
'-t',
'${recording.endTime.difference(DateTime.now()).inSeconds}',
filePath,
]; ];
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception) // Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
@@ -225,9 +256,11 @@ class RecordingScheduler {
logSink.writeln('Destination: $filePath'); logSink.writeln('Destination: $filePath');
} catch (logError) { } catch (logError) {
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire) // Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
print('[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError'); print(
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
// On note la filePath dans la DB quand même pour pouvoir retourner le statut // On note la filePath dans la DB quand même pour pouvoir retourner le statut
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); _db.updateRecordingStatus(recording.id, 'recording',
filePath: filePath);
} }
// Trouver ffmpeg // Trouver ffmpeg
@@ -251,15 +284,19 @@ class RecordingScheduler {
// Écouter la fin du processus FFmpeg de manière asynchrone // Écouter la fin du processus FFmpeg de manière asynchrone
_ffmpegProcess!.exitCode.then((exitCode) async { _ffmpegProcess!.exitCode.then((exitCode) async {
if (_currentRecording?.id == recording.id) { if (_currentRecording?.id == recording.id) {
logSink?.writeln('\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode'); logSink?.writeln(
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
await logSink?.close(); await logSink?.close();
if (exitCode == 0 || exitCode == 255) { if (exitCode == 0 || exitCode == 255) {
print('[RecordingScheduler] Enregistrement terminé: ${recording.title}'); print(
'[RecordingScheduler] Enregistrement terminé: ${recording.title}');
_db.updateRecordingStatus(recording.id, 'completed'); _db.updateRecordingStatus(recording.id, 'completed');
} else { } else {
print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); '[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
} }
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -267,11 +304,11 @@ class RecordingScheduler {
await logSink?.close(); await logSink?.close();
} }
}); });
} catch (e, st) { } catch (e, st) {
// Attraper TOUTES les exceptions pour éviter de crasher le serveur // Attraper TOUTES les exceptions pour éviter de crasher le serveur
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st'); print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur au lancement: $e'); _db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Erreur au lancement: $e');
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
} }
@@ -280,7 +317,8 @@ class RecordingScheduler {
/// Arrêter un enregistrement en cours (appelé depuis l'API) /// Arrêter un enregistrement en cours (appelé depuis l'API)
Future<bool> stopRecording(String id) async { Future<bool> stopRecording(String id) async {
if (_ffmpegProcess != null && _currentRecording?.id == id) { if (_ffmpegProcess != null && _currentRecording?.id == id) {
print('[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}'); print(
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
_ffmpegProcess!.kill(ProcessSignal.sigterm); _ffmpegProcess!.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed'); _db.updateRecordingStatus(id, 'completed');
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -301,25 +339,30 @@ class RecordingScheduler {
} }
Future<void> _checkDiskSpaceAndRotate(Directory dir) async { Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers // Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint. // et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers // Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
try { try {
final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
final files = dir.listSync().whereType<File>().toList(); final files = dir.listSync().whereType<File>().toList();
if (files.length > maxFiles) { if (files.length > maxFiles) {
print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements'); print(
files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent '[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
files.sort((a, b) => a
.statSync()
.modified
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
// Supprimer les plus anciens pour revenir sous la limite // Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles); final filesToDelete = files.take(files.length - maxFiles);
for (var file in filesToDelete) { for (var file in filesToDelete) {
file.deleteSync(); file.deleteSync();
} }
} }
} catch (e) { } catch (e) {
print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e'); print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
} }
} }
} }
+25 -20
View File
@@ -2,16 +2,15 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../database/database.dart'; import '../database/database.dart';
import '../models/recording.dart';
/// 🎯 SIMPLIFIED Recording System - One job: Record streams /// 🎯 SIMPLIFIED Recording System - One job: Record streams
class SimpleRecorder { class SimpleRecorder {
final AppDatabase db; final AppDatabase db;
final String recordingsDir; final String recordingsDir;
// Active recordings (streamId -> process) // Active recordings (streamId -> process)
final Map<String, _ActiveRecording> _active = {}; final Map<String, _ActiveRecording> _active = {};
SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'}); SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'});
/// Initialize recordings directory /// Initialize recordings directory
@@ -56,9 +55,12 @@ class SimpleRecorder {
try { try {
final ffmpeg = await Process.start('ffmpeg', [ final ffmpeg = await Process.start('ffmpeg', [
'-y', '-y',
'-i', streamUrl, '-i',
'-c', 'copy', streamUrl,
'-t', '${duration.inSeconds}', '-c',
'copy',
'-t',
'${duration.inSeconds}',
filepath, filepath,
]); ]);
@@ -78,7 +80,8 @@ class SimpleRecorder {
_active.remove(channelId); _active.remove(channelId);
print('✅ Recording done: $title'); print('✅ Recording done: $title');
}).catchError((e) { }).catchError((e) {
db.updateRecordingStatus(recordingId, 'failed', errorReason: 'FFmpeg error'); db.updateRecordingStatus(recordingId, 'failed',
errorReason: 'FFmpeg error');
_active.remove(channelId); _active.remove(channelId);
}); });
@@ -99,7 +102,7 @@ class SimpleRecorder {
required DateTime endTime, required DateTime endTime,
}) async { }) async {
final recordingId = const Uuid().v4(); final recordingId = const Uuid().v4();
// Create in DB with 'scheduled' status // Create in DB with 'scheduled' status
db.createRecording( db.createRecording(
userId: 'system', userId: 'system',
@@ -167,22 +170,23 @@ class SimpleRecorder {
/// Get active recordings /// Get active recordings
List<Map<String, dynamic>> getActive() { List<Map<String, dynamic>> getActive() {
return _active.entries.map((e) => { return _active.entries
'id': e.value.id, .map(
'channel': e.key, (e) => {
'filepath': e.value.filepath, 'id': e.value.id,
'endsAt': e.value.endTime.toIso8601String(), 'channel': e.key,
}).toList(); 'filepath': e.value.filepath,
'endsAt': e.value.endTime.toIso8601String(),
},
)
.toList();
} }
/// Cleanup old recordings (keep last 20) /// Cleanup old recordings (keep last 20)
Future<void> cleanupOld({int keepCount = 20}) async { Future<void> cleanupOld({int keepCount = 20}) async {
final dir = Directory(recordingsDir); final dir = Directory(recordingsDir);
final files = dir final files = dir.listSync().whereType<File>().toList()
.listSync() ..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
.whereType<File>()
.toList()
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
if (files.length > keepCount) { if (files.length > keepCount) {
for (final file in files.skip(keepCount)) { for (final file in files.skip(keepCount)) {
@@ -194,7 +198,8 @@ class SimpleRecorder {
String _safeName(String title, String id) { String _safeName(String title, String id) {
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_'); final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0]; final timestamp =
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
return '${safe}_$timestamp.mkv'; return '${safe}_$timestamp.mkv';
} }
+51 -47
View File
@@ -1,6 +1,7 @@
/// Application optimization configuration /// Application optimization configuration
/// ///
/// This file contains performance tuning settings for XtremFlow /// This file contains performance tuning settings for XtremFlow
library;
class OptimizationConfig { class OptimizationConfig {
// ============================================ // ============================================
@@ -26,7 +27,8 @@ class OptimizationConfig {
static const bool enableHlsCompression = true; static const bool enableHlsCompression = true;
/// FFmpeg preset for live transcoding /// FFmpeg preset for live transcoding
static const String ffmpegLivePreset = 'medium'; // ultrafast, fast, medium, slow static const String ffmpegLivePreset =
'medium'; // ultrafast, fast, medium, slow
/// FFmpeg preset for VOD transcoding /// FFmpeg preset for VOD transcoding
static const String ffmpegVodPreset = 'medium'; static const String ffmpegVodPreset = 'medium';
@@ -175,64 +177,64 @@ class OptimizationConfig {
/// Get optimized image cache settings /// Get optimized image cache settings
static Map<String, dynamic> getImageCacheSettings() => { static Map<String, dynamic> getImageCacheSettings() => {
'maxSize': maxImageCacheMb * 1024 * 1024, 'maxSize': maxImageCacheMb * 1024 * 1024,
'maxItems': maxCachedImages, 'maxItems': maxCachedImages,
'enableAnimation': enableImageFadeAnimation, 'enableAnimation': enableImageFadeAnimation,
'fadeAnimationMs': imageFadeAnimationMs, 'fadeAnimationMs': imageFadeAnimationMs,
}; };
/// Get streaming configuration /// Get streaming configuration
static Map<String, dynamic> getStreamingConfig() => { static Map<String, dynamic> getStreamingConfig() => {
'enableAdaptiveBitrate': enableAdaptiveBitrate, 'enableAdaptiveBitrate': enableAdaptiveBitrate,
'defaultQuality': defaultQualityResolution, 'defaultQuality': defaultQualityResolution,
'maxBuffer': maxBufferSeconds, 'maxBuffer': maxBufferSeconds,
'minBuffer': minBufferSeconds, 'minBuffer': minBufferSeconds,
'targetBuffer': targetBufferSeconds, 'targetBuffer': targetBufferSeconds,
'enableHlsCompression': enableHlsCompression, 'enableHlsCompression': enableHlsCompression,
'ffmpegPreset': ffmpegLivePreset, 'ffmpegPreset': ffmpegLivePreset,
'enableGpu': enableNvidiaGpu, 'enableGpu': enableNvidiaGpu,
}; };
/// Get network configuration /// Get network configuration
static Map<String, dynamic> getNetworkConfig() => { static Map<String, dynamic> getNetworkConfig() => {
'connectTimeout': connectionTimeoutSeconds, 'connectTimeout': connectionTimeoutSeconds,
'receiveTimeout': receiveTimeoutSeconds, 'receiveTimeout': receiveTimeoutSeconds,
'enableAutoRetry': enableAutoRetry, 'enableAutoRetry': enableAutoRetry,
'maxRetries': maxRetryAttempts, 'maxRetries': maxRetryAttempts,
'enableGzip': enableGzipCompression, 'enableGzip': enableGzipCompression,
'enableCache': enableNetworkCache, 'enableCache': enableNetworkCache,
'cacheRetentionDays': networkCacheRetentionDays, 'cacheRetentionDays': networkCacheRetentionDays,
}; };
/// Get UI performance settings /// Get UI performance settings
static Map<String, dynamic> getUiPerformanceSettings() => { static Map<String, dynamic> getUiPerformanceSettings() => {
'itemsPerPage': itemsPerPage, 'itemsPerPage': itemsPerPage,
'liveItemsPerPage': liveItemsPerPage, 'liveItemsPerPage': liveItemsPerPage,
'enableLazyLoading': enableLazyLoading, 'enableLazyLoading': enableLazyLoading,
'enableSmoothScroll': enableSmoothScroll, 'enableSmoothScroll': enableSmoothScroll,
'maxTexturePoolSize': videoTexturePoolSize, 'maxTexturePoolSize': videoTexturePoolSize,
}; };
/// Get memory optimization settings /// Get memory optimization settings
static Map<String, dynamic> getMemoryOptimizationSettings() => { static Map<String, dynamic> getMemoryOptimizationSettings() => {
'enableProfiling': enableMemoryProfiling, 'enableProfiling': enableMemoryProfiling,
'profilingInterval': memoryProfilingIntervalSeconds, 'profilingInterval': memoryProfilingIntervalSeconds,
'aggressiveCleanup': enableMemoryCleanupOnBackground, 'aggressiveCleanup': enableMemoryCleanupOnBackground,
'maxImageCacheMb': maxImageCacheMb, 'maxImageCacheMb': maxImageCacheMb,
'maxMemoryCacheMb': maxMemoryCacheMb, 'maxMemoryCacheMb': maxMemoryCacheMb,
}; };
/// Get all enabled features /// Get all enabled features
static List<String> getEnabledFeatures() => [ static List<String> getEnabledFeatures() => [
if (enableEpg) 'EPG', if (enableEpg) 'EPG',
if (enableOfflineDownloads) 'OfflineDownloads', if (enableOfflineDownloads) 'OfflineDownloads',
if (enableContinueWatching) 'ContinueWatching', if (enableContinueWatching) 'ContinueWatching',
if (enableTrending) 'Trending', if (enableTrending) 'Trending',
if (enableQualitySelector) 'QualitySelector', if (enableQualitySelector) 'QualitySelector',
if (enableSubtitles) 'Subtitles', if (enableSubtitles) 'Subtitles',
if (enableWatchHistory) 'WatchHistory', if (enableWatchHistory) 'WatchHistory',
if (enableFavorites) 'Favorites', if (enableFavorites) 'Favorites',
]; ];
/// Print configuration summary /// Print configuration summary
static void printSummary() { static void printSummary() {
@@ -285,13 +287,15 @@ class RuntimeOptimizations {
// Detect low storage // Detect low storage
if (storageFreeMb < 1000) { if (storageFreeMb < 1000) {
print( print(
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.'); '[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.',
);
} }
// High performance device detection // High performance device detection
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000; hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
print('[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB'); print(
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
} }
/// Get dynamic cache size based on available memory /// Get dynamic cache size based on available memory
+26 -25
View File
@@ -1,10 +1,8 @@
/// Adaptive Bitrate (ABR) Streaming Handler for HLS /// Adaptive Bitrate (ABR) Streaming Handler for HLS
/// ///
/// Supports multi-bitrate HLS playlists for adaptive streaming /// Supports multi-bitrate HLS playlists for adaptive streaming
/// with fallback options for poor network conditions /// with fallback options for poor network conditions
library;
import 'dart:async';
import 'dart:io';
/// Represents a video quality level for adaptive bitrate streaming /// Represents a video quality level for adaptive bitrate streaming
class QualityLevel { class QualityLevel {
@@ -108,23 +106,23 @@ class QualityProfiles {
/// Get default profiles for balanced streaming /// Get default profiles for balanced streaming
static List<QualityLevel> getBalancedProfiles() => [ static List<QualityLevel> getBalancedProfiles() => [
mobile240p, mobile240p,
mobile360p, mobile360p,
hd480p, hd480p,
hd720p, hd720p,
fhd1080p, fhd1080p,
]; ];
/// Get all available profiles /// Get all available profiles
static List<QualityLevel> getAllProfiles() => [ static List<QualityLevel> getAllProfiles() => [
mobile240p, mobile240p,
mobile360p, mobile360p,
hd480p, hd480p,
hd720p, hd720p,
fhd1080p, fhd1080p,
uhd2k, uhd2k,
uhd4k, uhd4k,
]; ];
/// Get profiles suitable for streaming type /// Get profiles suitable for streaming type
static List<QualityLevel> getProfiles({ static List<QualityLevel> getProfiles({
@@ -159,8 +157,8 @@ class QualityProfiles {
/// Bandwidth detector for adaptive bitrate selection /// Bandwidth detector for adaptive bitrate selection
class BandwidthDetector { class BandwidthDetector {
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
int _totalBytesLastPeriod = 0; final int _totalBytesLastPeriod = 0;
Duration _sampleWindow = const Duration(seconds: 10); final Duration _sampleWindow = const Duration(seconds: 10);
/// Record bytes downloaded at current time /// Record bytes downloaded at current time
void recordBytes(int bytes) { void recordBytes(int bytes) {
@@ -177,7 +175,9 @@ class BandwidthDetector {
if (_samples.isEmpty) return 0; if (_samples.isEmpty) return 0;
int totalBytes = 0; int totalBytes = 0;
_samples.values.forEach((bytes) => totalBytes += bytes); for (var bytes in _samples.values) {
totalBytes += bytes;
}
final timeSpan = DateTime.now() final timeSpan = DateTime.now()
.difference( .difference(
@@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator {
// Variant streams (quality levels) // Variant streams (quality levels)
for (final quality in qualityLevels) { for (final quality in qualityLevels) {
buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer buffer
.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},' .writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
'RESOLUTION=${quality.width}x${quality.height}');
buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
} }
return buffer.toString(); return buffer.toString();
@@ -317,7 +317,8 @@ class QualitySelector {
// Only switch if significantly different from current // Only switch if significantly different from current
final overhead = _currentQuality.bitrateBps * 1.2; final overhead = _currentQuality.bitrateBps * 1.2;
if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) { if (bandwidthBps > overhead ||
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
final bestQuality = final bestQuality =
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles); BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
setQuality(bestQuality); setQuality(bestQuality);
+17 -18
View File
@@ -12,8 +12,8 @@ class CacheEntry<T> {
required this.data, required this.data,
required this.sizeEstimate, required this.sizeEstimate,
}) : timestamp = DateTime.now(), }) : timestamp = DateTime.now(),
lastAccess = DateTime.now(), lastAccess = DateTime.now(),
accessCount = 0; accessCount = 0;
bool isExpired({Duration ttl = const Duration(hours: 24)}) { bool isExpired({Duration ttl = const Duration(hours: 24)}) {
return DateTime.now().difference(timestamp) > ttl; return DateTime.now().difference(timestamp) > ttl;
@@ -52,7 +52,7 @@ class CacheService {
/// Get cached data /// Get cached data
T? get<T>(String key) { T? get<T>(String key) {
final entry = _cache[key] as CacheEntry?; final entry = _cache[key];
if (entry != null && !entry.isExpired()) { if (entry != null && !entry.isExpired()) {
entry.markAccess(); entry.markAccess();
return entry.data as T; return entry.data as T;
@@ -68,11 +68,12 @@ class CacheService {
/// Get cache stats /// Get cache stats
Map<String, dynamic> getStats() => { Map<String, dynamic> getStats() => {
'totalSize': _totalSizeBytes, 'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024), 'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length, 'itemCount': _cache.length,
'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length, 'averageSizePerItem':
}; _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
};
/// Clear all cache /// Clear all cache
void clear() { void clear() {
@@ -96,7 +97,7 @@ class CacheService {
/// Trim cache by removing least recently used entries /// Trim cache by removing least recently used entries
void _trimCacheIfNeeded() { void _trimCacheIfNeeded() {
final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024; const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
if (_totalSizeBytes > maxSize) { if (_totalSizeBytes > maxSize) {
// Remove least recently accessed entries // Remove least recently accessed entries
final sorted = _cache.entries.toList() final sorted = _cache.entries.toList()
@@ -173,10 +174,10 @@ class ImageCacheManager {
/// Get cache stats /// Get cache stats
Map<String, dynamic> getStats() => { Map<String, dynamic> getStats() => {
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024), 'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
'cachedImages': _imageCache.length, 'cachedImages': _imageCache.length,
'maxImages': _maxImages, 'maxImages': _maxImages,
}; };
/// Clear all image cache /// Clear all image cache
void clear() { void clear() {
@@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider<ImageCacheManager>((ref) {
}); });
/// Provider for cache statistics /// Provider for cache statistics
final cacheStatsProvider = StateNotifierProvider< final cacheStatsProvider =
CacheStatsNotifier, StateNotifierProvider<CacheStatsNotifier, Map<String, dynamic>>((ref) {
Map<String, dynamic>>((ref) {
final cacheService = ref.watch(cacheServiceProvider); final cacheService = ref.watch(cacheServiceProvider);
return CacheStatsNotifier(cacheService); return CacheStatsNotifier(cacheService);
}); });
@@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider<
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> { class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
final CacheService _cacheService; final CacheService _cacheService;
CacheStatsNotifier(this._cacheService) CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats());
: super(_cacheService.getStats());
void refreshStats() { void refreshStats() {
state = _cacheService.getStats(); state = _cacheService.getStats();
+8 -4
View File
@@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor {
@override @override
Future<void> onError( Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async { DioException err,
ErrorInterceptorHandler handler,
) async {
// Only retry on timeout or connection errors // Only retry on timeout or connection errors
if (err.type == DioExceptionType.connectionTimeout || if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout) { err.type == DioExceptionType.receiveTimeout) {
@@ -248,9 +250,11 @@ final networkConfigProvider =
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> { class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
NetworkConfigNotifier() NetworkConfigNotifier()
: super(const NetworkConfig( : super(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18', const NetworkConfig(
)); userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
),
);
void setProxy(String? proxyUrl) { void setProxy(String? proxyUrl) {
state = state.copyWith(proxyUrl: proxyUrl); state = state.copyWith(proxyUrl: proxyUrl);
+13 -15
View File
@@ -23,10 +23,7 @@ class StreamingMetrics {
}); });
double get totalPlaybackSeconds => double get totalPlaybackSeconds =>
(endTime ?? DateTime.now()) (endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
.difference(startTime)
.inSeconds
.toDouble();
double get qualityScore { double get qualityScore {
// Score based on metrics // Score based on metrics
@@ -67,7 +64,7 @@ class StreamingOptimizer {
StreamController.broadcast(); StreamController.broadcast();
late Timer _metricsTimer; late Timer _metricsTimer;
int _segmentStartTime = 0; final int _segmentStartTime = 0;
int _totalBytesDownloaded = 0; int _totalBytesDownloaded = 0;
int _rebufferCount = 0; int _rebufferCount = 0;
int _lastBufferNotificationTime = 0; int _lastBufferNotificationTime = 0;
@@ -98,8 +95,7 @@ class StreamingOptimizer {
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000; final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
_currentMetrics = StreamingMetrics( _currentMetrics = StreamingMetrics(
averageNetworkBitrate: _totalBytesDownloaded > 0 averageNetworkBitrate: _totalBytesDownloaded > 0
? (_totalBytesDownloaded * 8 / ? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
_currentMetrics.totalPlaybackSeconds)
: 0, : 0,
currentNetworkBitrate: bandwidthBps, currentNetworkBitrate: bandwidthBps,
bufferDurationMs: _currentMetrics.bufferDurationMs, bufferDurationMs: _currentMetrics.bufferDurationMs,
@@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
late StreamingOptimizer _optimizer; late StreamingOptimizer _optimizer;
StreamingOptimizerNotifier(this._qualitySelector) StreamingOptimizerNotifier(this._qualitySelector)
: super(StreamingMetrics( : super(
averageNetworkBitrate: 0, StreamingMetrics(
currentNetworkBitrate: 0, averageNetworkBitrate: 0,
bufferDurationMs: 0, currentNetworkBitrate: 0,
segmentDownloadTimeMs: 0, bufferDurationMs: 0,
rebufferCount: 0, segmentDownloadTimeMs: 0,
startTime: DateTime.now(), rebufferCount: 0,
)) { startTime: DateTime.now(),
),
) {
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector); _optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
// Listen to metrics updates // Listen to metrics updates
+31 -32
View File
@@ -23,9 +23,9 @@ class AppTheme {
static const double spacing24 = 24.0; static const double spacing24 = 24.0;
static const double spacing32 = 32.0; static const double spacing32 = 32.0;
static const double spacing40 = 40.0; static const double spacing40 = 40.0;
static const double spacing48 = 48.0; // TV minimum safe area static const double spacing48 = 48.0; // TV minimum safe area
static const double spacing56 = 56.0; static const double spacing56 = 56.0;
static const double spacing64 = 64.0; // Hero section padding static const double spacing64 = 64.0; // Hero section padding
// ============ RADIUS (modern, organic curves) ============ // ============ RADIUS (modern, organic curves) ============
static const double radiusXs = 4.0; static const double radiusXs = 4.0;
@@ -114,7 +114,6 @@ class AppTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( return baseTheme.copyWith(
useMaterial3: true,
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background, canvasColor: AppColors.background,
@@ -387,7 +386,7 @@ class AppTheme {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg), borderRadius: BorderRadius.circular(radiusLg),
side: BorderSide( side: const BorderSide(
color: AppColors.border, color: AppColors.border,
width: 1, width: 1,
), ),
@@ -400,7 +399,7 @@ class AppTheme {
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl), borderRadius: BorderRadius.circular(radiusXl),
side: BorderSide( side: const BorderSide(
color: AppColors.border, color: AppColors.border,
width: 1, width: 1,
), ),
@@ -498,14 +497,14 @@ class AppTheme {
), ),
// ============ NAVIGATION RAIL (Side menu) ============ // ============ NAVIGATION RAIL (Side menu) ============
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: const NavigationRailThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
elevation: 0, elevation: 0,
selectedIconTheme: const IconThemeData( selectedIconTheme: IconThemeData(
color: AppColors.primary, color: AppColors.primary,
size: 28, size: 28,
), ),
unselectedIconTheme: const IconThemeData( unselectedIconTheme: IconThemeData(
color: AppColors.textSecondary, color: AppColors.textSecondary,
size: 24, size: 24,
), ),
@@ -538,7 +537,7 @@ class AppTheme {
static const double buttonHeightSm = 36.0; static const double buttonHeightSm = 36.0;
static const double buttonHeightMd = 44.0; static const double buttonHeightMd = 44.0;
static const double buttonHeightLg = 56.0; // TV-comfortable tap target static const double buttonHeightLg = 56.0; // TV-comfortable tap target
// ============ ASPECT RATIOS ============ // ============ ASPECT RATIOS ============
static const double posterAspectRatio = 2 / 3; static const double posterAspectRatio = 2 / 3;
@@ -564,29 +563,29 @@ class _AppThemeExtension extends ThemeExtension<_AppThemeExtension> {
final List<BoxShadow>? elevatedShadow; final List<BoxShadow>? elevatedShadow;
static _AppThemeExtension get dark => _AppThemeExtension( static _AppThemeExtension get dark => _AppThemeExtension(
glassShadow: [ glassShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.15), color: Colors.black.withOpacity(0.15),
blurRadius: 16, blurRadius: 16,
spreadRadius: -4, spreadRadius: -4,
), ),
], ],
focusShadow: [ focusShadow: [
BoxShadow( BoxShadow(
color: AppColors.primary.withOpacity(0.5), color: AppColors.primary.withOpacity(0.5),
blurRadius: 20, blurRadius: 20,
spreadRadius: 4, spreadRadius: 4,
), ),
], ],
elevatedShadow: [ elevatedShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.25), color: Colors.black.withOpacity(0.25),
blurRadius: 12, blurRadius: 12,
spreadRadius: 0, spreadRadius: 0,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
); );
@override @override
ThemeExtension<_AppThemeExtension> copyWith({ ThemeExtension<_AppThemeExtension> copyWith({
+17 -13
View File
@@ -4,10 +4,10 @@ import '../theme/app_colors.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Modern Apple TV Glass Container /// Modern Apple TV Glass Container
/// ///
/// Sophisticated glassmorphism with premium blur effects, /// Sophisticated glassmorphism with premium blur effects,
/// configurable transparency, and elegant borders. /// configurable transparency, and elegant borders.
/// ///
/// Features: /// Features:
/// - Backdrop blur (iOS-style glass effect) /// - Backdrop blur (iOS-style glass effect)
/// - Subtle gradient overlay /// - Subtle gradient overlay
@@ -151,11 +151,11 @@ class _GlassCardState extends State<GlassCard>
duration: AppTheme.durationMd, duration: AppTheme.durationMd,
); );
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04) _scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8) _opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
} }
@override @override
@@ -189,12 +189,16 @@ class _GlassCardState extends State<GlassCard>
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
onTapDown: (_) => _onPointerDown(PointerDownEvent( onTapDown: (_) => _onPointerDown(
position: Offset.zero, const PointerDownEvent(
)), position: Offset.zero,
onTapUp: (_) => _onPointerUp(PointerUpEvent( ),
position: Offset.zero, ),
)), onTapUp: (_) => _onPointerUp(
const PointerUpEvent(
position: Offset.zero,
),
),
onTapCancel: () => _controller.reverse(), onTapCancel: () => _controller.reverse(),
child: ScaleTransition( child: ScaleTransition(
scale: _scaleAnimation, scale: _scaleAnimation,
@@ -211,7 +215,7 @@ class _GlassCardState extends State<GlassCard>
content = Stack( content = Stack(
children: [ children: [
Opacity(opacity: 0.5, child: content), Opacity(opacity: 0.5, child: content),
Center( const Center(
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
+8 -6
View File
@@ -285,7 +285,8 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.play_arrow, color: AppColors.textPrimary), const Icon(Icons.play_arrow,
color: AppColors.textPrimary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Play', 'Play',
@@ -320,8 +321,10 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.info_outline, const Icon(
color: AppColors.textPrimary), Icons.info_outline,
color: AppColors.textPrimary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'More Info', 'More Info',
@@ -361,9 +364,8 @@ class _HeroCarouselState extends State<HeroCarousel>
width: _currentIndex == index ? 32 : 8, width: _currentIndex == index ? 32 : 8,
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _currentIndex == index color:
? AppColors.primary _currentIndex == index ? AppColors.primary : AppColors.border,
: AppColors.border,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
+3 -3
View File
@@ -17,15 +17,15 @@ class ThemedLoading extends StatelessWidget {
], ],
), ),
), ),
child: Center( child: const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator( CircularProgressIndicator(
color: AppColors.primary, color: AppColors.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
const SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'Loading...', 'Loading...',
style: TextStyle( style: TextStyle(
+3 -3
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Apple TV Modern Channel Grid /// Apple TV Modern Channel Grid
/// ///
/// Responsive grid layout for channels with: /// Responsive grid layout for channels with:
/// - Flexible column count based on screen size /// - Flexible column count based on screen size
/// - Smooth animations /// - Smooth animations
@@ -57,7 +56,8 @@ class TvChannelGrid extends StatelessWidget {
// Compute item width, clamped to avoid negative/NaN values. // Compute item width, clamped to avoid negative/NaN values.
final totalSpacing = horizontalSpacing * (columnCount - 1); final totalSpacing = horizontalSpacing * (columnCount - 1);
final availableWidth = screenWidth - horizontalPadding - totalSpacing; final availableWidth = screenWidth - horizontalPadding - totalSpacing;
final itemWidth = (availableWidth / columnCount).clamp(1.0, double.infinity); final itemWidth =
(availableWidth / columnCount).clamp(1.0, double.infinity);
return SingleChildScrollView( return SingleChildScrollView(
controller: scrollController, controller: scrollController,
+12 -10
View File
@@ -6,7 +6,7 @@ import '../theme/app_theme.dart';
import 'glass_container.dart'; import 'glass_container.dart';
/// Apple TV Modern Content Card /// Apple TV Modern Content Card
/// ///
/// Premium card for displaying movies, shows, or other content with: /// Premium card for displaying movies, shows, or other content with:
/// - Poster image with skeleton loading /// - Poster image with skeleton loading
/// - Title and metadata /// - Title and metadata
@@ -149,19 +149,19 @@ class _TvModernCardState extends State<TvModernCard>
right: 0, right: 0,
child: Container( child: Container(
height: 3, height: 3,
decoration: BoxDecoration( decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg), bottom: Radius.circular(AppTheme.radiusLg),
), ),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg), bottom: Radius.circular(AppTheme.radiusLg),
), ),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: widget.progress, value: widget.progress,
backgroundColor: AppColors.surfaceVariant, backgroundColor: AppColors.surfaceVariant,
valueColor: AlwaysStoppedAnimation( valueColor: const AlwaysStoppedAnimation(
AppColors.primary, AppColors.primary,
), ),
minHeight: 3, minHeight: 3,
@@ -171,7 +171,8 @@ class _TvModernCardState extends State<TvModernCard>
), ),
// Action buttons (on hover) // Action buttons (on hover)
if (_isHovered && (widget.onPlayTap != null || widget.onMoreTap != null)) if (_isHovered &&
(widget.onPlayTap != null || widget.onMoreTap != null))
Center( Center(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -182,7 +183,8 @@ class _TvModernCardState extends State<TvModernCard>
label: 'Play', label: 'Play',
onTap: widget.onPlayTap!, onTap: widget.onPlayTap!,
), ),
if (widget.onPlayTap != null && widget.onMoreTap != null) if (widget.onPlayTap != null &&
widget.onMoreTap != null)
const SizedBox(width: 16), const SizedBox(width: 16),
if (widget.onMoreTap != null) if (widget.onMoreTap != null)
_buildActionButton( _buildActionButton(
@@ -219,7 +221,7 @@ class _TvModernCardState extends State<TvModernCard>
Row( Row(
children: [ children: [
if (widget.rating != null) ...[ if (widget.rating != null) ...[
Icon( const Icon(
Icons.star, Icons.star,
size: 14, size: 14,
color: AppColors.ratingGold, color: AppColors.ratingGold,
@@ -258,7 +260,7 @@ class _TvModernCardState extends State<TvModernCard>
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) { if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
return Container( return Container(
color: AppColors.surface, color: AppColors.surface,
child: Center( child: const Center(
child: Icon( child: Icon(
Icons.image_outlined, Icons.image_outlined,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -273,7 +275,7 @@ class _TvModernCardState extends State<TvModernCard>
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: AppColors.shimmer, color: AppColors.shimmer,
child: Center( child: const Center(
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
@@ -286,7 +288,7 @@ class _TvModernCardState extends State<TvModernCard>
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
color: AppColors.surface, color: AppColors.surface,
child: Center( child: const Center(
child: Icon( child: Icon(
Icons.broken_image_outlined, Icons.broken_image_outlined,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -33,20 +33,23 @@ class RecommendationService {
List<Playlist> allContent, List<Playlist> allContent,
) { ) {
final recommendations = <Recommendation>[]; final recommendations = <Recommendation>[];
for (final item in allContent) { for (final item in allContent) {
final historyKey = item.streamId.toString(); final historyKey = item.streamId.toString();
if (watchHistory.containsKey(historyKey)) { if (watchHistory.containsKey(historyKey)) {
final lastPosition = watchHistory[historyKey] as double? ?? 0.0; final lastPosition = watchHistory[historyKey] as double? ?? 0.0;
// Only show if watched more than 5 seconds ago and less than 95% complete // Only show if watched more than 5 seconds ago and less than 95% complete
if (lastPosition > 5 && lastPosition < 95) { if (lastPosition > 5 && lastPosition < 95) {
recommendations.add(Recommendation( recommendations.add(
item: item, Recommendation(
type: RecommendationType.continueWatching, item: item,
score: 100.0 - lastPosition, // Score higher if recently started type: RecommendationType.continueWatching,
reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%', score: 100.0 - lastPosition, // Score higher if recently started
)); reason:
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
),
);
} }
} }
} }
@@ -62,16 +65,18 @@ class RecommendationService {
List<Playlist> allContent, List<Playlist> allContent,
) { ) {
final recommendations = <Recommendation>[]; final recommendations = <Recommendation>[];
for (final item in allContent) { for (final item in allContent) {
final views = viewCounts[item.streamId.toString()] ?? 0; final views = viewCounts[item.streamId.toString()] ?? 0;
if (views > 0) { if (views > 0) {
recommendations.add(Recommendation( recommendations.add(
item: item, Recommendation(
type: RecommendationType.trending, item: item,
score: views.toDouble(), type: RecommendationType.trending,
reason: '$views views - Trending now', score: views.toDouble(),
)); reason: '$views views - Trending now',
),
);
} }
} }
@@ -82,7 +87,7 @@ class RecommendationService {
/// Get recently added content /// Get recently added content
static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) { static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) {
final recommendations = <Recommendation>[]; final recommendations = <Recommendation>[];
// Sort by added date (most recent first) // Sort by added date (most recent first)
final sorted = [...allContent]; final sorted = [...allContent];
sorted.sort((a, b) { sorted.sort((a, b) {
@@ -95,13 +100,15 @@ class RecommendationService {
final daysAgo = DateTime.now() final daysAgo = DateTime.now()
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000)) .difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
.inDays; .inDays;
recommendations.add(Recommendation( recommendations.add(
item: item, Recommendation(
type: RecommendationType.recentlyAdded, item: item,
score: (20 - daysAgo).toDouble().clamp(0, 100), type: RecommendationType.recentlyAdded,
reason: 'Added $daysAgo days ago', score: (20 - daysAgo).toDouble().clamp(0, 100),
)); reason: 'Added $daysAgo days ago',
),
);
} }
return recommendations.take(10).toList(); return recommendations.take(10).toList();
@@ -113,7 +120,7 @@ class RecommendationService {
List<Playlist> allContent, List<Playlist> allContent,
) { ) {
final recommendations = <Recommendation>[]; final recommendations = <Recommendation>[];
// Collect watched categories // Collect watched categories
final watchedCategories = <String>{}; final watchedCategories = <String>{};
watchHistory.forEach((key, value) { watchHistory.forEach((key, value) {
@@ -125,12 +132,14 @@ class RecommendationService {
// Recommend similar content // Recommend similar content
for (final item in allContent) { for (final item in allContent) {
if (watchedCategories.contains(item.categoryId)) { if (watchedCategories.contains(item.categoryId)) {
recommendations.add(Recommendation( recommendations.add(
item: item, Recommendation(
type: RecommendationType.forYou, item: item,
score: 75.0, type: RecommendationType.forYou,
reason: 'Based on your interests', score: 75.0,
)); reason: 'Based on your interests',
),
);
} }
} }
@@ -146,12 +155,14 @@ class RecommendationService {
final rating = double.tryParse(i.rating ?? '0') ?? 0.0; final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
return rating >= 7.5; return rating >= 7.5;
}).take(5)) { }).take(5)) {
recommendations.add(Recommendation( recommendations.add(
item: item, Recommendation(
type: RecommendationType.topRated, item: item,
score: double.tryParse(item.rating ?? '0') ?? 0.0, type: RecommendationType.topRated,
reason: 'Highly rated - ${item.rating}/10', score: double.tryParse(item.rating ?? '0') ?? 0.0,
)); reason: 'Highly rated - ${item.rating}/10',
),
);
} }
// Remove duplicates and limit // Remove duplicates and limit
@@ -169,9 +180,8 @@ class RecommendationService {
} }
/// Provider for watch history /// Provider for watch history
final watchHistoryProvider = StateNotifierProvider< final watchHistoryProvider =
WatchHistoryNotifier, StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
Map<String, dynamic>>((ref) {
return WatchHistoryNotifier(); return WatchHistoryNotifier();
}); });
@@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
} }
/// Provider for view counts/trending /// Provider for view counts/trending
final trendingProvider = StateNotifierProvider< final trendingProvider =
TrendingNotifier, StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
Map<String, int>>((ref) {
return TrendingNotifier(); return TrendingNotifier();
}); });
@@ -213,11 +222,11 @@ class TrendingNotifier extends StateNotifier<Map<String, int>> {
} }
/// Provider for recommendations /// Provider for recommendations
final recommendationsProvider = final recommendationsProvider = FutureProvider.family<List<Recommendation>,
FutureProvider.family<List<Recommendation>, (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>( (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
(ref, params) async { (ref, params) async {
final (playlist, watchHistory, trending, allContent) = params; final (playlist, watchHistory, trending, allContent) = params;
// Combine all recommendation types // Combine all recommendation types
final allRecommendations = <Recommendation>[ final allRecommendations = <Recommendation>[
...RecommendationService.getContinueWatching(watchHistory, allContent), ...RecommendationService.getContinueWatching(watchHistory, allContent),
@@ -229,7 +238,7 @@ final recommendationsProvider =
// Deduplicate and score // Deduplicate and score
final seen = <int>{}; final seen = <int>{};
final scored = <Recommendation>[]; final scored = <Recommendation>[];
for (final rec in allRecommendations) { for (final rec in allRecommendations) {
if (!seen.contains(rec.item.streamId)) { if (!seen.contains(rec.item.streamId)) {
scored.add(rec); scored.add(rec);
@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
class _DashboardScreenState extends ConsumerState<DashboardScreen> { class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
final List<String> _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings']; final List<String> _tabs = [
'Live TV',
'Movies',
'Series',
'Recordings',
'Settings'
];
final List<IconData> _icons = [ final List<IconData> _icons = [
Icons.live_tv_rounded, Icons.live_tv_rounded,
Icons.movie_rounded, Icons.movie_rounded,
+30 -21
View File
@@ -30,7 +30,7 @@ class EpgProgram {
}); });
Duration get duration => endTime.difference(startTime); Duration get duration => endTime.difference(startTime);
double get progress { double get progress {
if (!isNow) return 0; if (!isNow) return 0;
final now = DateTime.now(); final now = DateTime.now();
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
final Playlist playlist; final Playlist playlist;
const EpgGridScreen({ const EpgGridScreen({
Key? key, super.key,
required this.playlist, required this.playlist,
}) : super(key: key); });
@override @override
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState(); ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
} }
List<EpgProgram> _parseEpgPrograms( List<EpgProgram> _parseEpgPrograms(
Map<String, dynamic> epgData, String channelId) { Map<String, dynamic> epgData,
String channelId,
) {
final programs = <EpgProgram>[]; final programs = <EpgProgram>[];
final now = DateTime.now(); final now = DateTime.now();
@@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
final start = _parseEpgTime(startStr); final start = _parseEpgTime(startStr);
final end = _parseEpgTime(endStr); final end = _parseEpgTime(endStr);
programs.add(EpgProgram( programs.add(
id: '${channelId}_${start.millisecondsSinceEpoch}', EpgProgram(
channelId: channelId, id: '${channelId}_${start.millisecondsSinceEpoch}',
title: title, channelId: channelId,
description: desc, title: title,
startTime: start, description: desc,
endTime: end, startTime: start,
isNow: now.isAfter(start) && now.isBefore(end), endTime: end,
isNext: start.isAfter(now) && isNow: now.isAfter(start) && now.isBefore(end),
start.difference(now).inMinutes < 60, isNext:
)); start.isAfter(now) && start.difference(now).inMinutes < 60,
),
);
} }
} catch (e) { } catch (e) {
continue; continue;
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(day, style: const TextStyle(fontSize: 11)), Text(day, style: const TextStyle(fontSize: 11)),
Text(dateNum, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), Text(dateNum,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.bold)),
], ],
), ),
); );
@@ -250,7 +256,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
child: Center( child: Center(
child: Text( child: Text(
'$hour:00', '$hour:00',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.bold),
), ),
), ),
); );
@@ -335,7 +342,8 @@ class _EpgChannelRow extends StatelessWidget {
// Programs // Programs
...programs.map((program) { ...programs.map((program) {
final duration = program.duration.inMinutes; final duration = program.duration.inMinutes;
final width = (duration / 60 * 100).clamp(40, double.infinity).toDouble(); final width =
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
return GestureDetector( return GestureDetector(
onTap: () => onProgramTap(program), onTap: () => onProgramTap(program),
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
value: program.progress / 100, value: program.progress / 100,
minHeight: 3, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
valueColor: valueColor: const AlwaysStoppedAnimation(Colors.red),
const AlwaysStoppedAnimation(Colors.red),
), ),
), ),
), ),
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
program.description.isEmpty ? 'No description available' : program.description, program.description.isEmpty
? 'No description available'
: program.description,
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
+37 -41
View File
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
// ... (existing imports) // ... (existing imports)
// ... (existing imports) // ... (existing imports)
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/glass_container.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../widgets/epg_overlay.dart';
enum StreamType { live, vod, series } enum StreamType { live, vod, series }
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
StreamSubscription? _messageSubscription; StreamSubscription? _messageSubscription;
final String _aspectRatio = 'contain'; final String _aspectRatio = 'contain';
bool _isSeeking = false; bool _isSeeking = false;
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString(); late final String _viewIdPrefix =
DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player'; String _viewId = 'iptv-player';
String? _currentStreamUrl; String? _currentStreamUrl;
String _statusMessage = 'Loading...'; String _statusMessage = 'Loading...';
@@ -134,7 +132,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Store URL for Lite Player // Store URL for Lite Player
final isMobile = MediaQuery.of(context).size.width < 600; final isMobile = MediaQuery.of(context).size.width < 600;
final isLiveTV = widget.streamType == StreamType.live; final isLiveTV = widget.streamType == StreamType.live;
// TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping // TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping
if (isLiveTV && !isMobile) { if (isLiveTV && !isMobile) {
streamUrl = service.getLiveStreamUrlTs(currentStreamId); streamUrl = service.getLiveStreamUrlTs(currentStreamId);
@@ -152,15 +150,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Force player choice based on stream type: // Force player choice based on stream type:
// - Live TV: Player Lite (simple TS playback with mpegts.js) // - Live TV: Player Lite (simple TS playback with mpegts.js)
// - VOD/Series: Player Standard (fuller controls, HLS support) // - VOD/Series: Player Standard (fuller controls, HLS support)
// Cache buster to force reload of updated player.html // Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch; final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; final streamTypeParam =
final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html'); widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag // Inject Turbo-Start flag
var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster'; var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
if (startTimeOverride != null) { if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride'; playerSrc += '&t=$startTimeOverride';
@@ -300,31 +302,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _toggleFullscreen() { void _toggleFullscreen() {
try { try {
final doc = html.document; final doc = html.document;
final isFullscreen = doc.fullscreenElement != null || final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null || (doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null; (doc as dynamic).mozFullScreenElement != null;
if (isFullscreen) { if (isFullscreen) {
if (doc.exitFullscreen != null) { doc.exitFullscreen();
doc.exitFullscreen();
} else if ((doc as dynamic).webkitExitFullscreen != null) {
(doc as dynamic).webkitExitFullscreen();
} else if ((doc as dynamic).mozCancelFullScreen != null) {
(doc as dynamic).mozCancelFullScreen();
}
} else { } else {
final element = doc.documentElement; final element = doc.documentElement;
if (element != null) { if (element != null) {
if (element.requestFullscreen != null) { element.requestFullscreen();
element.requestFullscreen();
} else if ((element as dynamic).webkitRequestFullscreen != null) {
(element as dynamic).webkitRequestFullscreen();
} else if ((element as dynamic).mozRequestFullScreen != null) {
(element as dynamic).mozRequestFullScreen();
} else {
// Fallback: Message the player if browser API fails
_sendMessage({'type': 'request_fullscreen'});
}
} }
} }
} catch (e) { } catch (e) {
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [ children: [
Text( Text(
widget.channels != null widget.channels != null
? (widget.channels![_currentIndex] ? (widget
.channels![
_currentIndex]
.name ?? .name ??
widget.channels![ widget
.channels![
_currentIndex] _currentIndex]
.title ?? .title ??
'Unknown') 'Unknown')
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Unified Bottom Bar - EPG + Controls in one bar // Unified Bottom Bar - EPG + Controls in one bar
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
bottom: _showControls ? 24 : -150, // More offset for stacked layout bottom: _showControls
? 24
: -150, // More offset for stacked layout
left: 16, left: 16,
right: 16, right: 16,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isSmallScreen = constraints.maxWidth < 600; final isSmallScreen =
constraints.maxWidth < 600;
return Container( return Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isSmallScreen ? 16 : 20, horizontal: isSmallScreen ? 16 : 20,
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.background.withOpacity(0.3), color: AppColors.background
.withOpacity(0.3),
blurRadius: 10, blurRadius: 10,
spreadRadius: 2, spreadRadius: 2,
), ),
], ],
border: Border.all( border: Border.all(
color: AppColors.textPrimary.withOpacity(0.15), color: AppColors.textPrimary
.withOpacity(0.15),
), ),
), ),
child: isSmallScreen child: isSmallScreen
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// EPG Info (top in mobile) // EPG Info (top in mobile)
_buildInlineEpg(), _buildInlineEpg(),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(color: Colors.white10), const Divider(
color: Colors.white10),
const SizedBox(height: 8), const SizedBox(height: 8),
// Control Buttons // Control Buttons
Row( Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.spaceEvenly, MainAxisAlignment.spaceEvenly,
children: _buildControlButtons( children: _buildControlButtons(
isSmallScreen), isSmallScreen,
),
), ),
], ],
) )
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: _buildControlButtons( children: _buildControlButtons(
isSmallScreen), isSmallScreen,
),
), ),
], ],
), ),
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onTap: _toggleMute, onTap: _toggleMute,
size: buttonSize, size: buttonSize,
), ),
]; ];
} }
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}, },
); );
} }
} }
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
actions: [ actions: [
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
IconButton( IconButton(
icon: const Icon(Icons.admin_panel_settings, icon: const Icon(
color: AppColors.textSecondary), Icons.admin_panel_settings,
color: AppColors.textSecondary,
),
onPressed: () => context.go('/admin'), onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel', tooltip: 'Admin Panel',
), ),
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
!constraints.maxHeight.isFinite || !constraints.maxHeight.isFinite ||
constraints.maxHeight <= 0) { constraints.maxHeight <= 0) {
return const Center( return const Center(
child: child: CircularProgressIndicator(color: AppColors.primary),
CircularProgressIndicator(color: AppColors.primary)); );
} }
return playlistsAsync.when( return playlistsAsync.when(
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, const Icon(
size: 64, color: AppColors.error), Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Error loading playlists', 'Error loading playlists',
@@ -120,12 +120,15 @@ class DownloadService {
/// Get download progress /// Get download progress
double getDownloadProgress(String id) { double getDownloadProgress(String id) {
final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask( final task = _downloads.firstWhere(
id: id, (t) => t.id == id,
title: '', orElse: () => DownloadTask(
url: '', id: id,
filePath: '', title: '',
)); url: '',
filePath: '',
),
);
return task.progress; return task.progress;
} }
@@ -151,13 +154,12 @@ class DownloadService {
if (available < 1024 * 1024 * 1024) { if (available < 1024 * 1024 * 1024) {
// Less than 1GB free // Less than 1GB free
final dir = Directory(_downloadDir); final dir = Directory(_downloadDir);
final files = dir.listSync() final files = dir.listSync().whereType<File>().toList();
.whereType<File>()
.toList();
// Sort by modification time, delete oldest first // Sort by modification time, delete oldest first
files.sort((a, b) => files.sort(
a.statSync().modified.compareTo(b.statSync().modified)); (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
);
for (final file in files.take(files.length ~/ 3)) { for (final file in files.take(files.length ~/ 3)) {
try { try {
@@ -216,7 +218,8 @@ class DownloadService {
} }
// Check if paused or cancelled // Check if paused or cancelled
final currentTask = _downloads.firstWhere((t) => t.id == task.id, orElse: () => task); final currentTask =
_downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
if (currentTask.status == DownloadStatus.paused || if (currentTask.status == DownloadStatus.paused ||
!_activeDownloads.contains(task.id)) { !_activeDownloads.contains(task.id)) {
break; break;
@@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier<List<DownloadTask>> {
} }
void clearCompleted() { void clearCompleted() {
state = state state = state.where((d) => d.status != DownloadStatus.completed).toList();
.where((d) => d.status != DownloadStatus.completed)
.toList();
} }
} }
@@ -17,12 +17,12 @@ class SubtitleTrack {
}); });
SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack( SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack(
id: id, id: id,
name: name, name: name,
language: language, language: language,
url: url, url: url,
isEnabled: isEnabled ?? this.isEnabled, isEnabled: isEnabled ?? this.isEnabled,
); );
} }
/// Subtitle content with timing /// Subtitle content with timing
@@ -42,16 +42,17 @@ class SubtitleEntry {
/// Service for handling subtitles /// Service for handling subtitles
class SubtitleService { class SubtitleService {
static const _srtPattern = r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)'; static const _srtPattern =
r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
/// Parse SRT subtitle content /// Parse SRT subtitle content
static List<SubtitleEntry> parseSrt(String content) { static List<SubtitleEntry> parseSrt(String content) {
final entries = <SubtitleEntry>[]; final entries = <SubtitleEntry>[];
final blocks = content.split('\n\n'); final blocks = content.split('\n\n');
for (final block in blocks) { for (final block in blocks) {
if (block.trim().isEmpty) continue; if (block.trim().isEmpty) continue;
final lines = block.trim().split('\n'); final lines = block.trim().split('\n');
if (lines.length < 3) continue; if (lines.length < 3) continue;
@@ -67,12 +68,14 @@ class SubtitleService {
final startTime = _parseTime(times[0].trim()); final startTime = _parseTime(times[0].trim());
final endTime = _parseTime(times[1].trim()); final endTime = _parseTime(times[1].trim());
entries.add(SubtitleEntry( entries.add(
index: index, SubtitleEntry(
startTime: startTime, index: index,
endTime: endTime, startTime: startTime,
text: text, endTime: endTime,
)); text: text,
),
);
} catch (e) { } catch (e) {
continue; continue;
} }
@@ -85,10 +88,10 @@ class SubtitleService {
static List<SubtitleEntry> parseWebVtt(String content) { static List<SubtitleEntry> parseWebVtt(String content) {
final entries = <SubtitleEntry>[]; final entries = <SubtitleEntry>[];
final blocks = content.split('\n\n'); final blocks = content.split('\n\n');
for (final block in blocks) { for (final block in blocks) {
if (block.trim().isEmpty || block.startsWith('WEBVTT')) continue; if (block.trim().isEmpty || block.startsWith('WEBVTT')) continue;
final lines = block.trim().split('\n'); final lines = block.trim().split('\n');
if (lines.length < 2) continue; if (lines.length < 2) continue;
@@ -103,12 +106,14 @@ class SubtitleService {
final startTime = _parseWebVttTime(times[0].trim()); final startTime = _parseWebVttTime(times[0].trim());
final endTime = _parseWebVttTime(times[1].trim()); final endTime = _parseWebVttTime(times[1].trim());
entries.add(SubtitleEntry( entries.add(
index: entries.length, SubtitleEntry(
startTime: startTime, index: entries.length,
endTime: endTime, startTime: startTime,
text: text, endTime: endTime,
)); text: text,
),
);
} catch (e) { } catch (e) {
continue; continue;
} }
@@ -120,7 +125,8 @@ class SubtitleService {
/// Get subtitle at specific time /// Get subtitle at specific time
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) { static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
for (final entry in entries) { for (final entry in entries) {
if (time.compareTo(entry.startTime) >= 0 && time.compareTo(entry.endTime) <= 0) { if (time.compareTo(entry.startTime) >= 0 &&
time.compareTo(entry.endTime) <= 0) {
return entry.text; return entry.text;
} }
} }
+39 -22
View File
@@ -183,11 +183,14 @@ class XtreamService {
/// Get all live TV channels grouped by category /// Get all live TV channels grouped by category
/// ///
/// Returns a Map where key is category name and value is list of channels /// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels({bool refresh = false}) async { Future<Map<String, List<Channel>>> getLiveChannels(
{bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -226,7 +229,8 @@ class XtreamService {
return groupedChannels; return groupedChannels;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<Channel>('live_channels', Channel.fromJson); final cached =
_loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch live channels: $e'); throw Exception('Failed to fetch live channels: $e');
} }
@@ -236,27 +240,30 @@ class XtreamService {
if (!kIsWeb) return; if (!kIsWeb) return;
try { try {
final jsonStr = jsonEncode(data); final jsonStr = jsonEncode(data);
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr; html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
jsonStr;
} catch (e) { } catch (e) {
debugPrint('Error saving to local cache ($key): $e'); debugPrint('Error saving to local cache ($key): $e');
} }
} }
Map<String, List<T>> _loadFromLocalCache<T>( Map<String, List<T>> _loadFromLocalCache<T>(
String key, T Function(Map<String, dynamic>) fromJson) { String key,
T Function(Map<String, dynamic>) fromJson,
) {
if (!kIsWeb) return {}; if (!kIsWeb) return {};
try { try {
final jsonStr = html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key']; final jsonStr =
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
if (jsonStr == null) return {}; if (jsonStr == null) return {};
final data = jsonDecode(jsonStr) as Map<String, dynamic>; final data = jsonDecode(jsonStr) as Map<String, dynamic>;
final result = <String, List<T>>{}; final result = <String, List<T>>{};
data.forEach((cat, items) { data.forEach((cat, items) {
if (items is List) { if (items is List) {
result[cat] = items result[cat] =
.map((i) => fromJson(i as Map<String, dynamic>)) items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
.toList();
} }
}); });
return result; return result;
@@ -301,8 +308,10 @@ class XtreamService {
Future<Map<String, List<VodItem>>> getVodItems({bool refresh = false}) async { Future<Map<String, List<VodItem>>> getVodItems({bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -342,7 +351,8 @@ class XtreamService {
return groupedVods; return groupedVods;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson); final cached =
_loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch VOD items: $e'); throw Exception('Failed to fetch VOD items: $e');
} }
@@ -383,8 +393,10 @@ class XtreamService {
Future<Map<String, List<Series>>> getSeries({bool refresh = false}) async { Future<Map<String, List<Series>>> getSeries({bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -424,15 +436,18 @@ class XtreamService {
return groupedSeries; return groupedSeries;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<Series>('series_items', Series.fromJson); final cached =
_loadFromLocalCache<Series>('series_items', Series.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch series: $e'); throw Exception('Failed to fetch series: $e');
} }
} }
/// Get movies with pagination support /// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated( Future<List<xm.Movie>> getMoviesPaginated({
{int offset = 0, int limit = 100}) async { int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -511,8 +526,10 @@ class XtreamService {
} }
/// Get series with pagination support (returns flat list) /// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated( Future<List<xm.Series>> getSeriesPaginated({
{int offset = 0, int limit = 100}) async { int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
+6 -2
View File
@@ -79,7 +79,9 @@ class _ChannelCardState extends State<ChannelCard>
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: ScaleTransition( child: ScaleTransition(
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0), scale: widget.isActive
? _pulseAnimation
: const AlwaysStoppedAnimation(1.0),
child: AnimatedContainer( child: AnimatedContainer(
duration: AppTheme.durationMd, duration: AppTheme.durationMd,
curve: AppTheme.curveDefault, curve: AppTheme.curveDefault,
@@ -90,7 +92,9 @@ class _ChannelCardState extends State<ChannelCard>
border: Border.all( border: Border.all(
color: widget.isActive color: widget.isActive
? AppColors.primary ? AppColors.primary
: (_isHovered ? AppColors.border : AppColors.border.withOpacity(0.5)), : (_isHovered
? AppColors.border
: AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5, width: widget.isActive ? 2.5 : 1.5,
), ),
borderRadius: BorderRadius.circular(AppTheme.radiusMd), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
@@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const ContinueWatchingWidget({ const ContinueWatchingWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
ConsumerState<ContinueWatchingWidget> createState() => ConsumerState<ContinueWatchingWidget> createState() =>
@@ -68,12 +68,12 @@ class _ContinueWatchingWidgetState
const SizedBox(width: 16), const SizedBox(width: 16),
...continueWatching.take(5).map((rec) { ...continueWatching.take(5).map((rec) {
final item = rec.item; final item = rec.item;
final percentage = watchHistory[item.streamId.toString()] final percentage =
is Map watchHistory[item.streamId.toString()] is Map
? (watchHistory[item.streamId.toString()] ? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ?? as Map)['percentage'] as double? ??
0.0 0.0
: 0.0; : 0.0;
return Container( return Container(
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
@@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget {
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: Colors.grey[800], color: Colors.grey[800],
), ),
errorWidget: (context, url, error) => errorWidget: (context, url, error) => Icon(
Icon(Icons.image_not_supported, Icons.image_not_supported,
color: Colors.grey[600]), color: Colors.grey[600],
),
), ),
), ),
@@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const TrendingWidget({ const TrendingWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( const Row(
children: [ children: [
const Icon(Icons.local_fire_department, Icon(
color: Colors.red, size: 24), Icons.local_fire_department,
const SizedBox(width: 8), color: Colors.red,
const Text( size: 24,
),
SizedBox(width: 8),
Text(
'Trending Now', 'Trending Now',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget {
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: Colors.grey[800], color: Colors.grey[800],
), ),
errorWidget: (context, url, error) => errorWidget: (context, url, error) => Icon(
Icon(Icons.image_not_supported, Icons.image_not_supported,
color: Colors.grey[600]), color: Colors.grey[600],
),
), ),
), ),
), ),
@@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget {
if (item.rating != null) if (item.rating != null)
Row( Row(
children: [ children: [
const Icon(Icons.star, const Icon(
color: Colors.amber, size: 12), Icons.star,
color: Colors.amber,
size: 12,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
item.rating ?? '0', item.rating ?? '0',
@@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const RecentlyAddedWidget({ const RecentlyAddedWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( const Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text( child: Text(
'Recently Added', 'Recently Added',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
+9 -4
View File
@@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity( color: Colors.white.withOpacity(
0.05), // Subtle transparent background 0.05,
), // Subtle transparent background
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(12)), top: Radius.circular(12),
),
), ),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Footer // Footer
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8), horizontal: 12,
vertical: 8,
),
color: Colors.black54, color: Colors.black54,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
EpgEntry.parseDateTime(entry.start); EpgEntry.parseDateTime(entry.start);
final end = final end =
EpgEntry.parseDateTime(entry.end); EpgEntry.parseDateTime(entry.end);
if (start == null || end == null) if (start == null || end == null) {
return false; return false;
}
return now.isAfter(start) && return now.isAfter(start) &&
now.isBefore(end); now.isBefore(end);
} catch (e) { } catch (e) {
@@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget {
final VoidCallback onClose; final VoidCallback onClose;
const QualitySelectorWidget({ const QualitySelectorWidget({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
required this.onClose, required this.onClose,
}) : super(key: key); });
@override @override
ConsumerState<QualitySelectorWidget> createState() => ConsumerState<QualitySelectorWidget> createState() =>
@@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
// Header // Header
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: Colors.white24), bottom: BorderSide(color: Colors.white24),
), ),
@@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
const EdgeInsets.symmetric(horizontal: 24, vertical: 8), const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue) ? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(Icons.radio_button_unchecked, : const Icon(
color: Colors.white30), Icons.radio_button_unchecked,
color: Colors.white30,
),
title: Text( title: Text(
quality.label, quality.label,
style: TextStyle( style: TextStyle(
color: isSelected ? Colors.blue : Colors.white, color: isSelected ? Colors.blue : Colors.white,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
), ),
), ),
subtitle: Text( subtitle: Text(
@@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
top: BorderSide(color: Colors.white24), top: BorderSide(color: Colors.white24),
), ),
@@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget {
final VoidCallback onTap; final VoidCallback onTap;
const QualityIndicator({ const QualityIndicator({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
required this.onTap, required this.onTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget {
final QualitySelector qualitySelector; final QualitySelector qualitySelector;
const BandwidthMonitor({ const BandwidthMonitor({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
File diff suppressed because it is too large. Load diff
@@ -47,7 +47,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
_isRecording = true; _isRecording = true;
_status = '🔴 Recording for $minutes min'; _status = '🔴 Recording for $minutes min';
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Recording started!')), const SnackBar(content: Text('✅ Recording started!')),
@@ -80,7 +80,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
if (response.statusCode == 200) { if (response.statusCode == 200) {
setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}'); setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Recording scheduled!')), const SnackBar(content: Text('✅ Recording scheduled!')),
@@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
value: _durationMinutes, value: _durationMinutes,
dropdownColor: Colors.grey[800], dropdownColor: Colors.grey[800],
items: [30, 60, 120, 240, 480] items: [30, 60, 120, 240, 480]
.map((m) => DropdownMenuItem( .map(
value: m, (m) => DropdownMenuItem(
child: Text('$m min'), value: m,
)) child: Text('$m min'),
),
)
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
if (v != null) { if (v != null) {
@@ -65,8 +65,11 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, const Icon(
size: 48, color: AppColors.error), Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.inter()), Text('Error loading playlists', style: GoogleFonts.inter()),
TextButton( TextButton(
@@ -82,12 +85,16 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.playlist_remove, const Icon(
size: 48, color: AppColors.textSecondary), Icons.playlist_remove,
size: 48,
color: AppColors.textSecondary,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No playlists available', Text(
style: 'No playlists available',
GoogleFonts.inter(color: AppColors.textSecondary)), style: GoogleFonts.inter(color: AppColors.textSecondary),
),
], ],
), ),
); );
@@ -158,8 +165,11 @@ class _MobilePlaylistCard extends StatelessWidget {
color: AppColors.primary.withOpacity(0.2), color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.playlist_play, child: const Icon(
color: AppColors.primary, size: 28), Icons.playlist_play,
color: AppColors.primary,
size: 28,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -48,14 +48,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Loading channels...', style: TextStyle(color: Colors.white70)), const Text('Loading channels...',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 24), const SizedBox(height: 24),
FutureBuilder( FutureBuilder(
future: Future.delayed(const Duration(seconds: 8)), future: Future.delayed(const Duration(seconds: 8)),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
return TextButton( return TextButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), onPressed: () => ref.refresh(
liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Taking too long? Tap to retry'), child: const Text('Taking too long? Tap to retry'),
); );
} }
@@ -73,12 +75,19 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48), const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Failed to load channels', style: GoogleFonts.inter(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), Text('Failed to load channels',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('$e', style: const TextStyle(color: Colors.white54), textAlign: TextAlign.center), Text('$e',
style: const TextStyle(color: Colors.white54),
textAlign: TextAlign.center),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), onPressed: () => ref
.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Retry'), child: const Text('Retry'),
), ),
], ],
@@ -285,7 +294,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
channel: channel, channel: channel,
playlist: widget.playlist, playlist: widget.playlist,
onTap: () => _playChannel( onTap: () => _playChannel(
context, channel, displayedChannels), context,
channel,
displayedChannels,
),
); );
}, },
), ),
@@ -299,7 +311,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
} }
void _playChannel( void _playChannel(
BuildContext context, Channel channel, List<Channel> channels) { BuildContext context,
Channel channel,
List<Channel> channels,
) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => MobilePlayerScreen(
-1
View File
@@ -31,7 +31,6 @@ class MobileTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( return baseTheme.copyWith(
useMaterial3: true,
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background, canvasColor: AppColors.background,
+1 -1
View File
@@ -48,7 +48,7 @@ class MobileScaffold extends ConsumerWidget {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface.withOpacity(0.9), color: AppColors.surface.withOpacity(0.9),
border: Border( border: const Border(
top: BorderSide( top: BorderSide(
color: AppColors.border, color: AppColors.border,
width: 0.5, width: 0.5,
-1
View File
@@ -228,7 +228,6 @@
// Android / Chrome - HLS.js // Android / Chrome - HLS.js
if (isHLS && Hls.isSupported()) { if (isHLS && Hls.isSupported()) {
log('Using Hls.js optimized for mobile'); log('Using Hls.js optimized for mobile');
const isTurbo = urlParams.get('turbo') === 'true';
const hls = new Hls({ const hls = new Hls({
maxBufferLength: 60, maxBufferLength: 60,