From fa6ae90d8d5b8b59a706cfde66494fdf6c6f4cb4 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 29 Mar 2026 10:19:36 +0200 Subject: [PATCH] feat: add mobile-optimized IPTV player with HLS and MpegTS support --- bin/api/epg_api.dart | 13 +- bin/api/proxy_handler.dart | 22 +- bin/api/season_passes_api.dart | 49 +- bin/api/streaming_handler.dart | 46 +- bin/database/database.dart | 21 +- bin/server.dart | 10 +- bin/services/recording_scheduler.dart | 139 ++- bin/services/simple_recorder.dart | 45 +- lib/core/config/optimization_config.dart | 98 +- .../services/adaptive_bitrate_service.dart | 51 +- lib/core/services/cache_service.dart | 35 +- lib/core/services/network_service.dart | 12 +- lib/core/services/streaming_optimizer.dart | 28 +- lib/core/theme/app_theme.dart | 63 +- lib/core/widgets/glass_container.dart | 30 +- lib/core/widgets/hero_carousel.dart | 14 +- lib/core/widgets/themed_loading_screen.dart | 6 +- lib/core/widgets/tv_channel_grid.dart | 6 +- lib/core/widgets/tv_modern_card.dart | 22 +- .../providers/recommendations_provider.dart | 101 +- .../iptv/screens/dashboard_screen.dart | 9 +- .../iptv/screens/epg_grid_screen.dart | 51 +- lib/features/iptv/screens/player_screen.dart | 78 +- .../screens/playlist_selection_screen.dart | 17 +- .../iptv/services/download_service.dart | 31 +- .../iptv/services/subtitle_service.dart | 54 +- .../iptv/services/xtream_service.dart | 61 +- lib/features/iptv/widgets/channel_card.dart | 8 +- .../widgets/continue_watching_widget.dart | 64 +- lib/features/iptv/widgets/live_tv_tab.dart | 13 +- .../iptv/widgets/quality_selector_widget.dart | 25 +- lib/features/iptv/widgets/recordings_tab.dart | 878 +++++++++++------- .../iptv/widgets/simple_recording_widget.dart | 14 +- .../mobile_playlist_selection_screen.dart | 28 +- .../iptv/widgets/mobile_live_tv_tab.dart | 29 +- lib/mobile/theme/mobile_theme.dart | 1 - lib/mobile/widgets/mobile_scaffold.dart | 2 +- web/player_mobile.html | 1 - 38 files changed, 1280 insertions(+), 895 deletions(-) diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index 658262c..1013b6e 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -29,8 +29,9 @@ class EpgApi { final playlist = await _getPlaylist(request); if (playlist == null) { return Response.forbidden( - json.encode({'error': 'Playlist non trouvée'}), - headers: {'Content-Type': 'application/json'}); + json.encode({'error': 'Playlist non trouvée'}), + headers: {'Content-Type': 'application/json'}, + ); } final dns = playlist.dns; @@ -43,7 +44,7 @@ class EpgApi { await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); Map epgData = { 'channel_id': channelId, - 'programmes': [] + 'programmes': [], }; if (response.statusCode == 200) { @@ -72,8 +73,10 @@ class EpgApi { expiresAt: DateTime.now().add(const Duration(minutes: 30)), ); - return Response.ok(jsonStr, - headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}); + return Response.ok( + jsonStr, + headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}, + ); } catch (e) { return Response.internalServerError( body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}), diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index 0a2fa12..693fccb 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; -import '../models/playlist.dart'; import '../models/playlist_config.dart'; import '../database/database.dart'; @@ -34,8 +33,9 @@ class ProxyHandler { Future _getCachedPlaylist(Request request) async { // Basic caching to avoid DB overhead on every video segment 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)) { final (cached, expiry) = _playlistCache[cacheKey]!; if (now.isBefore(expiry)) return cached; @@ -134,7 +134,8 @@ class ProxyHandler { final playlist = await _getCachedPlaylist(request); if (playlist == null) { return Response.forbidden( - 'No active playlist configuration found to validate request'); + 'No active playlist configuration found to validate request', + ); } final targetHost = targetUrl.host.toLowerCase(); @@ -142,9 +143,11 @@ class ProxyHandler { if (targetHost != allowedHost) { print( - '[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)'); + '[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)', + ); 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 { print('[Proxy] Forwarding to: $targetUrl'); final proxyRequest = http.Request(request.method, targetUrl); - + // Forward safe request headers for (final header in _allowedRequestHeaders) { if (request.headers.containsKey(header)) { proxyHeaders[header] = request.headers[header]!; } } - + proxyRequest.headers.addAll(proxyHeaders); proxyRequest.followRedirects = true; @@ -215,7 +218,8 @@ class ProxyHandler { targetUrl?.path.endsWith('.jpg') == true) { return Response.ok( base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + ), headers: {'content-type': 'image/png'}, ); } diff --git a/bin/api/season_passes_api.dart b/bin/api/season_passes_api.dart index 4e8bd00..686544a 100644 --- a/bin/api/season_passes_api.dart +++ b/bin/api/season_passes_api.dart @@ -36,19 +36,25 @@ class SeasonPassesApi { final streamUrl = data['stream_url'] as String?; if (showTitle == null || showTitle.isEmpty) { - return Response(400, - body: json.encode({'error': 'show_title est requis'}), - headers: {'Content-Type': 'application/json'}); + return Response( + 400, + body: json.encode({'error': 'show_title est requis'}), + headers: {'Content-Type': 'application/json'}, + ); } if (channelId == null || channelId.isEmpty) { - return Response(400, - body: json.encode({'error': 'channel_id est requis'}), - headers: {'Content-Type': 'application/json'}); + return Response( + 400, + body: json.encode({'error': 'channel_id est requis'}), + headers: {'Content-Type': 'application/json'}, + ); } if (streamUrl == null || streamUrl.isEmpty) { - return Response(400, - body: json.encode({'error': 'stream_url est requis'}), - headers: {'Content-Type': 'application/json'}); + return Response( + 400, + body: json.encode({'error': 'stream_url est requis'}), + headers: {'Content-Type': 'application/json'}, + ); } // Récupérer l'utilisateur depuis le contexte @@ -57,13 +63,18 @@ class SeasonPassesApi { // Vérifier si un season pass identique existe déjà final existing = _db.getAllSeasonPasses(); - final duplicate = existing.any((p) => - (p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() && - p['channel_id'] == channelId); + final duplicate = existing.any( + (p) => + (p['show_title'] as String).toLowerCase() == + showTitle.toLowerCase() && + p['channel_id'] == channelId, + ); if (duplicate) { - return Response(409, - body: json.encode({'error': 'Un Season Pass identique existe déjà'}), - headers: {'Content-Type': 'application/json'}); + return Response( + 409, + body: json.encode({'error': 'Un Season Pass identique existe déjà'}), + headers: {'Content-Type': 'application/json'}, + ); } final pass = _db.createSeasonPass( @@ -74,9 +85,11 @@ class SeasonPassesApi { ); print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId'); - return Response(201, - body: json.encode(pass), - headers: {'Content-Type': 'application/json'}); + return Response( + 201, + body: json.encode(pass), + headers: {'Content-Type': 'application/json'}, + ); } catch (e) { return Response.internalServerError( body: json.encode({'error': 'Erreur: $e'}), diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 97e8aa5..eccc030 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -153,17 +153,20 @@ Handler createLiveStreamHandler( '-i', targetUrl, // Video if (useNvidiaGpu) ...[ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC - '-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate - '-g', '50', + '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', + 'hq', // Better quality preset for NVENC + '-b:v', '8000k', '-maxrate', '12000k', '-bufsize', + '16000k', // Quality native / high bitrate + '-g', '50', ] 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', - '-g', '50', + '-g', '50', ], // Audio: High quality + Sync filter '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000', - '-af', 'aresample=async=1', + '-af', 'aresample=async=1', // HLS Sliding Window '-f', 'hls', '-hls_time', '2', @@ -201,15 +204,19 @@ Handler createLiveStreamHandler( retries++; } - if (!file.existsSync()) + if (!file.existsSync()) { return Response.internalServerError(body: 'Timeout starting live HLS'); + } - return Response.ok(file.openRead(), headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - 'X-Content-Type-Options': 'nosniff', - }); + return Response.ok( + file.openRead(), + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + '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) @@ -245,11 +252,14 @@ Handler createLiveStreamHandler( final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); if (!file.existsSync()) return Response.notFound('Segment not found'); - return Response.ok(file.openRead(), headers: { - 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'max-age=60', - }); + return Response.ok( + file.openRead(), + headers: { + 'Content-Type': 'video/mp2t', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=60', + }, + ); }); return router; diff --git a/bin/database/database.dart b/bin/database/database.dart index 7a581f5..54a0458 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -469,7 +469,7 @@ class AppDatabase { startTime.toIso8601String(), endTime.toIso8601String(), now, - now + now, ], ); @@ -489,7 +489,8 @@ class AppDatabase { /// Lister tous les enregistrements (pour le Scheduler et l'admin) List 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(); } @@ -510,7 +511,8 @@ class AppDatabase { } /// 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(); _db.execute( ''' @@ -542,12 +544,21 @@ class AppDatabase { 'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)', [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 List> 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.from(r)).toList(); } diff --git a/bin/server.dart b/bin/server.dart index 6eef50f..49ffb8d 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -46,7 +46,7 @@ void main(List args) async { // Injecter la config playlist dans le scheduler pour les Season Passes // (on prend la playlist du premier utilisateur disponible) - Future _injectPlaylistToScheduler() async { + Future injectPlaylistToScheduler() async { final users = db.getAllUsers(); if (users.isNotEmpty) { final playlists = db.getPlaylists(users[0].id); @@ -61,7 +61,7 @@ void main(List args) async { } // 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 await initStreaming(); @@ -84,14 +84,16 @@ void main(List args) async { if (users.isNotEmpty) { final playlists = db.getPlaylists(users[0].id); 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 (playlist != null) { print( - '[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})'); + '[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})', + ); return PlaylistConfig( id: playlist.id, name: playlist.name, diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart index 8fdc39b..7de7282 100644 --- a/bin/services/recording_scheduler.dart +++ b/bin/services/recording_scheduler.dart @@ -25,11 +25,14 @@ class RecordingScheduler { RecordingScheduler(this._db); 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) - _timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings()); + _timer = Timer.periodic( + const Duration(seconds: 10), (_) => _checkAndRunRecordings()); // 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 _checkAndRunRecordings(); // Vérification initiale des season passes après 30s (laisser le serveur démarrer) @@ -50,47 +53,58 @@ class RecordingScheduler { try { // Toujours comparer en UTC pour éviter les problèmes de fuseau horaire final now = DateTime.now().toUtc(); - + 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 if (_currentRecording != null) { if (now.isAfter(_currentRecording!.endTime.toUtc())) { - print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); + print( + '[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); await _stopCurrentRecording(); } } // Rechercher les enregistrements planifiés for (final recording in recordings) { - // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur - if (recording.status == 'recording' && _currentRecording?.id != recording.id) { - print('[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}'); - _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur'); - continue; + // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur + if (recording.status == 'recording' && + _currentRecording?.id != recording.id) { + print( + '[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}'); + _db.updateRecordingStatus(recording.id, 'failed', + errorReason: 'Interruption inattendue du serveur'); + continue; } if (recording.status == 'scheduled') { final startUtc = recording.startTime.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 if (now.isAfter(startUtc) && now.isBefore(endUtc)) { if (_currentRecording != null) { - print('[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.'); + print( + '[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; } - print('[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***'); + print( + '[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***'); await _startRecording(recording); } - + // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) if (now.isAfter(endUtc)) { - print('[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'); + print( + '[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; 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; } @@ -128,29 +143,36 @@ class RecordingScheduler { final url = '$dns/player_api.php?username=$username&password=$password' '&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; final raw = json.decode(response.body); - final listings = (raw is Map ? raw['epg_listings'] : raw) as List? ?? []; + final listings = + (raw is Map ? raw['epg_listings'] : raw) as List? ?? []; for (final item in listings) { 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) if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue; // Parser les heures de début/fin 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; DateTime startTime, endTime; try { startTime = DateTime.parse(startStr).toUtc(); endTime = DateTime.parse(endStr).toUtc(); - } catch (_) { continue; } + } catch (_) { + continue; + } // Ne pas créer pour les programmes déjà terminés if (endTime.isBefore(DateTime.now().toUtc())) continue; @@ -170,7 +192,8 @@ class RecordingScheduler { startTime: startTime, endTime: endTime, ); - print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); + print( + '[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); } } catch (e) { print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e'); @@ -195,8 +218,13 @@ class RecordingScheduler { await _checkDiskSpaceAndRotate(recordingsDir); // Génération d'un nom de fichier unique et sûr - final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); - final dateStr = recording.startTime.toUtc().toIso8601String().replaceAll(':', '').split('.')[0]; + final safeTitle = + recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); + final dateStr = recording.startTime + .toUtc() + .toIso8601String() + .replaceAll(':', '') + .split('.')[0]; final fileName = '${safeTitle}_$dateStr.mkv'; final filePath = p.join(recordingsDir.path, fileName); final logFilePath = filePath.replaceAll('.mkv', '.log'); @@ -210,10 +238,13 @@ class RecordingScheduler { final args = [ '-y', - '-i', streamUrl, - '-c', 'copy', - '-t', '${recording.endTime.difference(DateTime.now()).inSeconds}', - filePath + '-i', + streamUrl, + '-c', + '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) @@ -225,9 +256,11 @@ class RecordingScheduler { logSink.writeln('Destination: $filePath'); } catch (logError) { // 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 - _db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); + _db.updateRecordingStatus(recording.id, 'recording', + filePath: filePath); } // Trouver ffmpeg @@ -251,15 +284,19 @@ class RecordingScheduler { // Écouter la fin du processus FFmpeg de manière asynchrone _ffmpegProcess!.exitCode.then((exitCode) async { 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(); if (exitCode == 0 || exitCode == 255) { - print('[RecordingScheduler] Enregistrement terminé: ${recording.title}'); + print( + '[RecordingScheduler] Enregistrement terminé: ${recording.title}'); _db.updateRecordingStatus(recording.id, 'completed'); } else { - print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}'); - _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); + print( + '[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}'); + _db.updateRecordingStatus(recording.id, 'failed', + errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); } _currentRecording = null; _ffmpegProcess = null; @@ -267,11 +304,11 @@ class RecordingScheduler { await logSink?.close(); } }); - } catch (e, st) { // Attraper TOUTES les exceptions pour éviter de crasher le serveur 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; _ffmpegProcess = null; } @@ -280,7 +317,8 @@ class RecordingScheduler { /// Arrêter un enregistrement en cours (appelé depuis l'API) Future stopRecording(String id) async { 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); _db.updateRecordingStatus(id, 'completed'); _ffmpegProcess = null; @@ -301,25 +339,30 @@ class RecordingScheduler { } Future _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. // Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers try { - final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) + const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) final files = dir.listSync().whereType().toList(); - + if (files.length > maxFiles) { - print('[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 - + print( + '[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 final filesToDelete = files.take(files.length - maxFiles); for (var file in filesToDelete) { - file.deleteSync(); + file.deleteSync(); } } } 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'); } } } diff --git a/bin/services/simple_recorder.dart b/bin/services/simple_recorder.dart index 8432cd6..58b4a29 100644 --- a/bin/services/simple_recorder.dart +++ b/bin/services/simple_recorder.dart @@ -2,16 +2,15 @@ import 'dart:async'; import 'dart:io'; import 'package:uuid/uuid.dart'; import '../database/database.dart'; -import '../models/recording.dart'; /// 🎯 SIMPLIFIED Recording System - One job: Record streams class SimpleRecorder { final AppDatabase db; final String recordingsDir; - + // Active recordings (streamId -> process) final Map _active = {}; - + SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'}); /// Initialize recordings directory @@ -56,9 +55,12 @@ class SimpleRecorder { try { final ffmpeg = await Process.start('ffmpeg', [ '-y', - '-i', streamUrl, - '-c', 'copy', - '-t', '${duration.inSeconds}', + '-i', + streamUrl, + '-c', + 'copy', + '-t', + '${duration.inSeconds}', filepath, ]); @@ -78,7 +80,8 @@ class SimpleRecorder { _active.remove(channelId); print('✅ Recording done: $title'); }).catchError((e) { - db.updateRecordingStatus(recordingId, 'failed', errorReason: 'FFmpeg error'); + db.updateRecordingStatus(recordingId, 'failed', + errorReason: 'FFmpeg error'); _active.remove(channelId); }); @@ -99,7 +102,7 @@ class SimpleRecorder { required DateTime endTime, }) async { final recordingId = const Uuid().v4(); - + // Create in DB with 'scheduled' status db.createRecording( userId: 'system', @@ -167,22 +170,23 @@ class SimpleRecorder { /// Get active recordings List> getActive() { - return _active.entries.map((e) => { - 'id': e.value.id, - 'channel': e.key, - 'filepath': e.value.filepath, - 'endsAt': e.value.endTime.toIso8601String(), - }).toList(); + return _active.entries + .map( + (e) => { + 'id': e.value.id, + 'channel': e.key, + 'filepath': e.value.filepath, + 'endsAt': e.value.endTime.toIso8601String(), + }, + ) + .toList(); } /// Cleanup old recordings (keep last 20) Future cleanupOld({int keepCount = 20}) async { final dir = Directory(recordingsDir); - final files = dir - .listSync() - .whereType() - .toList() - ..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); + final files = dir.listSync().whereType().toList() + ..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); if (files.length > keepCount) { for (final file in files.skip(keepCount)) { @@ -194,7 +198,8 @@ class SimpleRecorder { String _safeName(String title, String id) { 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'; } diff --git a/lib/core/config/optimization_config.dart b/lib/core/config/optimization_config.dart index f9ef073..4b49e12 100644 --- a/lib/core/config/optimization_config.dart +++ b/lib/core/config/optimization_config.dart @@ -1,6 +1,7 @@ /// Application optimization configuration -/// +/// /// This file contains performance tuning settings for XtremFlow +library; class OptimizationConfig { // ============================================ @@ -26,7 +27,8 @@ class OptimizationConfig { static const bool enableHlsCompression = true; /// 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 static const String ffmpegVodPreset = 'medium'; @@ -175,64 +177,64 @@ class OptimizationConfig { /// Get optimized image cache settings static Map getImageCacheSettings() => { - 'maxSize': maxImageCacheMb * 1024 * 1024, - 'maxItems': maxCachedImages, - 'enableAnimation': enableImageFadeAnimation, - 'fadeAnimationMs': imageFadeAnimationMs, - }; + 'maxSize': maxImageCacheMb * 1024 * 1024, + 'maxItems': maxCachedImages, + 'enableAnimation': enableImageFadeAnimation, + 'fadeAnimationMs': imageFadeAnimationMs, + }; /// Get streaming configuration static Map getStreamingConfig() => { - 'enableAdaptiveBitrate': enableAdaptiveBitrate, - 'defaultQuality': defaultQualityResolution, - 'maxBuffer': maxBufferSeconds, - 'minBuffer': minBufferSeconds, - 'targetBuffer': targetBufferSeconds, - 'enableHlsCompression': enableHlsCompression, - 'ffmpegPreset': ffmpegLivePreset, - 'enableGpu': enableNvidiaGpu, - }; + 'enableAdaptiveBitrate': enableAdaptiveBitrate, + 'defaultQuality': defaultQualityResolution, + 'maxBuffer': maxBufferSeconds, + 'minBuffer': minBufferSeconds, + 'targetBuffer': targetBufferSeconds, + 'enableHlsCompression': enableHlsCompression, + 'ffmpegPreset': ffmpegLivePreset, + 'enableGpu': enableNvidiaGpu, + }; /// Get network configuration static Map getNetworkConfig() => { - 'connectTimeout': connectionTimeoutSeconds, - 'receiveTimeout': receiveTimeoutSeconds, - 'enableAutoRetry': enableAutoRetry, - 'maxRetries': maxRetryAttempts, - 'enableGzip': enableGzipCompression, - 'enableCache': enableNetworkCache, - 'cacheRetentionDays': networkCacheRetentionDays, - }; + 'connectTimeout': connectionTimeoutSeconds, + 'receiveTimeout': receiveTimeoutSeconds, + 'enableAutoRetry': enableAutoRetry, + 'maxRetries': maxRetryAttempts, + 'enableGzip': enableGzipCompression, + 'enableCache': enableNetworkCache, + 'cacheRetentionDays': networkCacheRetentionDays, + }; /// Get UI performance settings static Map getUiPerformanceSettings() => { - 'itemsPerPage': itemsPerPage, - 'liveItemsPerPage': liveItemsPerPage, - 'enableLazyLoading': enableLazyLoading, - 'enableSmoothScroll': enableSmoothScroll, - 'maxTexturePoolSize': videoTexturePoolSize, - }; + 'itemsPerPage': itemsPerPage, + 'liveItemsPerPage': liveItemsPerPage, + 'enableLazyLoading': enableLazyLoading, + 'enableSmoothScroll': enableSmoothScroll, + 'maxTexturePoolSize': videoTexturePoolSize, + }; /// Get memory optimization settings static Map getMemoryOptimizationSettings() => { - 'enableProfiling': enableMemoryProfiling, - 'profilingInterval': memoryProfilingIntervalSeconds, - 'aggressiveCleanup': enableMemoryCleanupOnBackground, - 'maxImageCacheMb': maxImageCacheMb, - 'maxMemoryCacheMb': maxMemoryCacheMb, - }; + 'enableProfiling': enableMemoryProfiling, + 'profilingInterval': memoryProfilingIntervalSeconds, + 'aggressiveCleanup': enableMemoryCleanupOnBackground, + 'maxImageCacheMb': maxImageCacheMb, + 'maxMemoryCacheMb': maxMemoryCacheMb, + }; /// Get all enabled features static List getEnabledFeatures() => [ - if (enableEpg) 'EPG', - if (enableOfflineDownloads) 'OfflineDownloads', - if (enableContinueWatching) 'ContinueWatching', - if (enableTrending) 'Trending', - if (enableQualitySelector) 'QualitySelector', - if (enableSubtitles) 'Subtitles', - if (enableWatchHistory) 'WatchHistory', - if (enableFavorites) 'Favorites', - ]; + if (enableEpg) 'EPG', + if (enableOfflineDownloads) 'OfflineDownloads', + if (enableContinueWatching) 'ContinueWatching', + if (enableTrending) 'Trending', + if (enableQualitySelector) 'QualitySelector', + if (enableSubtitles) 'Subtitles', + if (enableWatchHistory) 'WatchHistory', + if (enableFavorites) 'Favorites', + ]; /// Print configuration summary static void printSummary() { @@ -285,13 +287,15 @@ class RuntimeOptimizations { // Detect low storage if (storageFreeMb < 1000) { print( - '[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.'); + '[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.', + ); } // High performance device detection 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 diff --git a/lib/core/services/adaptive_bitrate_service.dart b/lib/core/services/adaptive_bitrate_service.dart index 2e3a650..ba4d34a 100644 --- a/lib/core/services/adaptive_bitrate_service.dart +++ b/lib/core/services/adaptive_bitrate_service.dart @@ -1,10 +1,8 @@ /// Adaptive Bitrate (ABR) Streaming Handler for HLS -/// +/// /// Supports multi-bitrate HLS playlists for adaptive streaming /// with fallback options for poor network conditions - -import 'dart:async'; -import 'dart:io'; +library; /// Represents a video quality level for adaptive bitrate streaming class QualityLevel { @@ -108,23 +106,23 @@ class QualityProfiles { /// Get default profiles for balanced streaming static List getBalancedProfiles() => [ - mobile240p, - mobile360p, - hd480p, - hd720p, - fhd1080p, - ]; + mobile240p, + mobile360p, + hd480p, + hd720p, + fhd1080p, + ]; /// Get all available profiles static List getAllProfiles() => [ - mobile240p, - mobile360p, - hd480p, - hd720p, - fhd1080p, - uhd2k, - uhd4k, - ]; + mobile240p, + mobile360p, + hd480p, + hd720p, + fhd1080p, + uhd2k, + uhd4k, + ]; /// Get profiles suitable for streaming type static List getProfiles({ @@ -159,8 +157,8 @@ class QualityProfiles { /// Bandwidth detector for adaptive bitrate selection class BandwidthDetector { final _samples = {}; // timestamp -> bytes downloaded - int _totalBytesLastPeriod = 0; - Duration _sampleWindow = const Duration(seconds: 10); + final int _totalBytesLastPeriod = 0; + final Duration _sampleWindow = const Duration(seconds: 10); /// Record bytes downloaded at current time void recordBytes(int bytes) { @@ -177,7 +175,9 @@ class BandwidthDetector { if (_samples.isEmpty) return 0; int totalBytes = 0; - _samples.values.forEach((bytes) => totalBytes += bytes); + for (var bytes in _samples.values) { + totalBytes += bytes; + } final timeSpan = DateTime.now() .difference( @@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator { // Variant streams (quality levels) for (final quality in qualityLevels) { + buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},' + 'RESOLUTION=${quality.width}x${quality.height}'); buffer - .writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},' - 'RESOLUTION=${quality.width}x${quality.height}'); - buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8'); + .writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8'); } return buffer.toString(); @@ -317,7 +317,8 @@ class QualitySelector { // Only switch if significantly different from current final overhead = _currentQuality.bitrateBps * 1.2; - if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) { + if (bandwidthBps > overhead || + bandwidthBps < _currentQuality.bitrateBps * 0.5) { final bestQuality = BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles); setQuality(bestQuality); diff --git a/lib/core/services/cache_service.dart b/lib/core/services/cache_service.dart index b5db591..857ec17 100644 --- a/lib/core/services/cache_service.dart +++ b/lib/core/services/cache_service.dart @@ -12,8 +12,8 @@ class CacheEntry { required this.data, required this.sizeEstimate, }) : timestamp = DateTime.now(), - lastAccess = DateTime.now(), - accessCount = 0; + lastAccess = DateTime.now(), + accessCount = 0; bool isExpired({Duration ttl = const Duration(hours: 24)}) { return DateTime.now().difference(timestamp) > ttl; @@ -52,7 +52,7 @@ class CacheService { /// Get cached data T? get(String key) { - final entry = _cache[key] as CacheEntry?; + final entry = _cache[key]; if (entry != null && !entry.isExpired()) { entry.markAccess(); return entry.data as T; @@ -68,11 +68,12 @@ class CacheService { /// Get cache stats Map getStats() => { - 'totalSize': _totalSizeBytes, - 'totalSizeMb': _totalSizeBytes / (1024 * 1024), - 'itemCount': _cache.length, - 'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length, - }; + 'totalSize': _totalSizeBytes, + 'totalSizeMb': _totalSizeBytes / (1024 * 1024), + 'itemCount': _cache.length, + 'averageSizePerItem': + _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length, + }; /// Clear all cache void clear() { @@ -96,7 +97,7 @@ class CacheService { /// Trim cache by removing least recently used entries void _trimCacheIfNeeded() { - final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024; + const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024; if (_totalSizeBytes > maxSize) { // Remove least recently accessed entries final sorted = _cache.entries.toList() @@ -173,10 +174,10 @@ class ImageCacheManager { /// Get cache stats Map getStats() => { - 'totalMemoryMb': _totalMemoryBytes / (1024 * 1024), - 'cachedImages': _imageCache.length, - 'maxImages': _maxImages, - }; + 'totalMemoryMb': _totalMemoryBytes / (1024 * 1024), + 'cachedImages': _imageCache.length, + 'maxImages': _maxImages, + }; /// Clear all image cache void clear() { @@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider((ref) { }); /// Provider for cache statistics -final cacheStatsProvider = StateNotifierProvider< - CacheStatsNotifier, - Map>((ref) { +final cacheStatsProvider = + StateNotifierProvider>((ref) { final cacheService = ref.watch(cacheServiceProvider); return CacheStatsNotifier(cacheService); }); @@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider< class CacheStatsNotifier extends StateNotifier> { final CacheService _cacheService; - CacheStatsNotifier(this._cacheService) - : super(_cacheService.getStats()); + CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats()); void refreshStats() { state = _cacheService.getStats(); diff --git a/lib/core/services/network_service.dart b/lib/core/services/network_service.dart index c5f4bc3..f388168 100644 --- a/lib/core/services/network_service.dart +++ b/lib/core/services/network_service.dart @@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor { @override Future onError( - DioException err, ErrorInterceptorHandler handler) async { + DioException err, + ErrorInterceptorHandler handler, + ) async { // Only retry on timeout or connection errors if (err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.receiveTimeout) { @@ -248,9 +250,11 @@ final networkConfigProvider = class NetworkConfigNotifier extends StateNotifier { NetworkConfigNotifier() - : super(const NetworkConfig( - userAgent: 'VLC/3.0.18 LibVLC/3.0.18', - )); + : super( + const NetworkConfig( + userAgent: 'VLC/3.0.18 LibVLC/3.0.18', + ), + ); void setProxy(String? proxyUrl) { state = state.copyWith(proxyUrl: proxyUrl); diff --git a/lib/core/services/streaming_optimizer.dart b/lib/core/services/streaming_optimizer.dart index 91f8a5c..f73184d 100644 --- a/lib/core/services/streaming_optimizer.dart +++ b/lib/core/services/streaming_optimizer.dart @@ -23,10 +23,7 @@ class StreamingMetrics { }); double get totalPlaybackSeconds => - (endTime ?? DateTime.now()) - .difference(startTime) - .inSeconds - .toDouble(); + (endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble(); double get qualityScore { // Score based on metrics @@ -67,7 +64,7 @@ class StreamingOptimizer { StreamController.broadcast(); late Timer _metricsTimer; - int _segmentStartTime = 0; + final int _segmentStartTime = 0; int _totalBytesDownloaded = 0; int _rebufferCount = 0; int _lastBufferNotificationTime = 0; @@ -98,8 +95,7 @@ class StreamingOptimizer { final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000; _currentMetrics = StreamingMetrics( averageNetworkBitrate: _totalBytesDownloaded > 0 - ? (_totalBytesDownloaded * 8 / - _currentMetrics.totalPlaybackSeconds) + ? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds) : 0, currentNetworkBitrate: bandwidthBps, bufferDurationMs: _currentMetrics.bufferDurationMs, @@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier { late StreamingOptimizer _optimizer; StreamingOptimizerNotifier(this._qualitySelector) - : super(StreamingMetrics( - averageNetworkBitrate: 0, - currentNetworkBitrate: 0, - bufferDurationMs: 0, - segmentDownloadTimeMs: 0, - rebufferCount: 0, - startTime: DateTime.now(), - )) { + : super( + StreamingMetrics( + averageNetworkBitrate: 0, + currentNetworkBitrate: 0, + bufferDurationMs: 0, + segmentDownloadTimeMs: 0, + rebufferCount: 0, + startTime: DateTime.now(), + ), + ) { _optimizer = StreamingOptimizer(qualitySelector: _qualitySelector); // Listen to metrics updates diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 4272496..362f5f6 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -23,9 +23,9 @@ class AppTheme { static const double spacing24 = 24.0; static const double spacing32 = 32.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 spacing64 = 64.0; // Hero section padding + static const double spacing64 = 64.0; // Hero section padding // ============ RADIUS (modern, organic curves) ============ static const double radiusXs = 4.0; @@ -114,7 +114,6 @@ class AppTheme { final baseTheme = ThemeData.dark(useMaterial3: true); return baseTheme.copyWith( - useMaterial3: true, colorScheme: AppColors.darkColorScheme, scaffoldBackgroundColor: AppColors.background, canvasColor: AppColors.background, @@ -387,7 +386,7 @@ class AppTheme { margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusLg), - side: BorderSide( + side: const BorderSide( color: AppColors.border, width: 1, ), @@ -400,7 +399,7 @@ class AppTheme { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusXl), - side: BorderSide( + side: const BorderSide( color: AppColors.border, width: 1, ), @@ -498,14 +497,14 @@ class AppTheme { ), // ============ NAVIGATION RAIL (Side menu) ============ - navigationRailTheme: NavigationRailThemeData( + navigationRailTheme: const NavigationRailThemeData( backgroundColor: AppColors.surface, elevation: 0, - selectedIconTheme: const IconThemeData( + selectedIconTheme: IconThemeData( color: AppColors.primary, size: 28, ), - unselectedIconTheme: const IconThemeData( + unselectedIconTheme: IconThemeData( color: AppColors.textSecondary, size: 24, ), @@ -538,7 +537,7 @@ class AppTheme { static const double buttonHeightSm = 36.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 ============ static const double posterAspectRatio = 2 / 3; @@ -564,29 +563,29 @@ class _AppThemeExtension extends ThemeExtension<_AppThemeExtension> { final List? elevatedShadow; static _AppThemeExtension get dark => _AppThemeExtension( - glassShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 16, - spreadRadius: -4, - ), - ], - focusShadow: [ - BoxShadow( - color: AppColors.primary.withOpacity(0.5), - blurRadius: 20, - spreadRadius: 4, - ), - ], - elevatedShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.25), - blurRadius: 12, - spreadRadius: 0, - offset: const Offset(0, 4), - ), - ], - ); + glassShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 16, + spreadRadius: -4, + ), + ], + focusShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.5), + blurRadius: 20, + spreadRadius: 4, + ), + ], + elevatedShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 12, + spreadRadius: 0, + offset: const Offset(0, 4), + ), + ], + ); @override ThemeExtension<_AppThemeExtension> copyWith({ diff --git a/lib/core/widgets/glass_container.dart b/lib/core/widgets/glass_container.dart index 6e39a72..097b2ab 100644 --- a/lib/core/widgets/glass_container.dart +++ b/lib/core/widgets/glass_container.dart @@ -4,10 +4,10 @@ import '../theme/app_colors.dart'; import '../theme/app_theme.dart'; /// Modern Apple TV Glass Container -/// +/// /// Sophisticated glassmorphism with premium blur effects, /// configurable transparency, and elegant borders. -/// +/// /// Features: /// - Backdrop blur (iOS-style glass effect) /// - Subtle gradient overlay @@ -151,11 +151,11 @@ class _GlassCardState extends State duration: AppTheme.durationMd, ); - _scaleAnimation = Tween(begin: 1.0, end: 1.04) - .animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); + _scaleAnimation = Tween(begin: 1.0, end: 1.04).animate( + CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); - _opacityAnimation = Tween(begin: 1.0, end: 0.8) - .animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); + _opacityAnimation = Tween(begin: 1.0, end: 0.8).animate( + CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); } @override @@ -189,12 +189,16 @@ class _GlassCardState extends State child: GestureDetector( onTap: widget.onTap, onLongPress: widget.onLongPress, - onTapDown: (_) => _onPointerDown(PointerDownEvent( - position: Offset.zero, - )), - onTapUp: (_) => _onPointerUp(PointerUpEvent( - position: Offset.zero, - )), + onTapDown: (_) => _onPointerDown( + const PointerDownEvent( + position: Offset.zero, + ), + ), + onTapUp: (_) => _onPointerUp( + const PointerUpEvent( + position: Offset.zero, + ), + ), onTapCancel: () => _controller.reverse(), child: ScaleTransition( scale: _scaleAnimation, @@ -211,7 +215,7 @@ class _GlassCardState extends State content = Stack( children: [ Opacity(opacity: 0.5, child: content), - Center( + const Center( child: SizedBox( width: 40, height: 40, diff --git a/lib/core/widgets/hero_carousel.dart b/lib/core/widgets/hero_carousel.dart index 96cf4f3..ca9b536 100644 --- a/lib/core/widgets/hero_carousel.dart +++ b/lib/core/widgets/hero_carousel.dart @@ -285,7 +285,8 @@ class _HeroCarouselState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.play_arrow, color: AppColors.textPrimary), + const Icon(Icons.play_arrow, + color: AppColors.textPrimary), const SizedBox(width: 8), Text( 'Play', @@ -320,8 +321,10 @@ class _HeroCarouselState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.info_outline, - color: AppColors.textPrimary), + const Icon( + Icons.info_outline, + color: AppColors.textPrimary, + ), const SizedBox(width: 8), Text( 'More Info', @@ -361,9 +364,8 @@ class _HeroCarouselState extends State width: _currentIndex == index ? 32 : 8, height: 8, decoration: BoxDecoration( - color: _currentIndex == index - ? AppColors.primary - : AppColors.border, + color: + _currentIndex == index ? AppColors.primary : AppColors.border, borderRadius: BorderRadius.circular(4), ), ), diff --git a/lib/core/widgets/themed_loading_screen.dart b/lib/core/widgets/themed_loading_screen.dart index 7b18417..0014ea6 100644 --- a/lib/core/widgets/themed_loading_screen.dart +++ b/lib/core/widgets/themed_loading_screen.dart @@ -17,15 +17,15 @@ class ThemedLoading extends StatelessWidget { ], ), ), - child: Center( + child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator( + CircularProgressIndicator( color: AppColors.primary, strokeWidth: 3, ), - const SizedBox(height: 16), + SizedBox(height: 16), Text( 'Loading...', style: TextStyle( diff --git a/lib/core/widgets/tv_channel_grid.dart b/lib/core/widgets/tv_channel_grid.dart index f56fc7f..5495e13 100644 --- a/lib/core/widgets/tv_channel_grid.dart +++ b/lib/core/widgets/tv_channel_grid.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; import '../theme/app_theme.dart'; /// Apple TV Modern Channel Grid -/// +/// /// Responsive grid layout for channels with: /// - Flexible column count based on screen size /// - Smooth animations @@ -57,7 +56,8 @@ class TvChannelGrid extends StatelessWidget { // Compute item width, clamped to avoid negative/NaN values. final totalSpacing = horizontalSpacing * (columnCount - 1); 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( controller: scrollController, diff --git a/lib/core/widgets/tv_modern_card.dart b/lib/core/widgets/tv_modern_card.dart index 4b5505f..72229a6 100644 --- a/lib/core/widgets/tv_modern_card.dart +++ b/lib/core/widgets/tv_modern_card.dart @@ -6,7 +6,7 @@ import '../theme/app_theme.dart'; import 'glass_container.dart'; /// Apple TV Modern Content Card -/// +/// /// Premium card for displaying movies, shows, or other content with: /// - Poster image with skeleton loading /// - Title and metadata @@ -149,19 +149,19 @@ class _TvModernCardState extends State right: 0, child: Container( height: 3, - decoration: BoxDecoration( + decoration: const BoxDecoration( borderRadius: BorderRadius.vertical( bottom: Radius.circular(AppTheme.radiusLg), ), ), child: ClipRRect( - borderRadius: BorderRadius.vertical( + borderRadius: const BorderRadius.vertical( bottom: Radius.circular(AppTheme.radiusLg), ), child: LinearProgressIndicator( value: widget.progress, backgroundColor: AppColors.surfaceVariant, - valueColor: AlwaysStoppedAnimation( + valueColor: const AlwaysStoppedAnimation( AppColors.primary, ), minHeight: 3, @@ -171,7 +171,8 @@ class _TvModernCardState extends State ), // Action buttons (on hover) - if (_isHovered && (widget.onPlayTap != null || widget.onMoreTap != null)) + if (_isHovered && + (widget.onPlayTap != null || widget.onMoreTap != null)) Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -182,7 +183,8 @@ class _TvModernCardState extends State label: 'Play', onTap: widget.onPlayTap!, ), - if (widget.onPlayTap != null && widget.onMoreTap != null) + if (widget.onPlayTap != null && + widget.onMoreTap != null) const SizedBox(width: 16), if (widget.onMoreTap != null) _buildActionButton( @@ -219,7 +221,7 @@ class _TvModernCardState extends State Row( children: [ if (widget.rating != null) ...[ - Icon( + const Icon( Icons.star, size: 14, color: AppColors.ratingGold, @@ -258,7 +260,7 @@ class _TvModernCardState extends State if (widget.imageUrl == null || widget.imageUrl!.isEmpty) { return Container( color: AppColors.surface, - child: Center( + child: const Center( child: Icon( Icons.image_outlined, color: AppColors.textSecondary, @@ -273,7 +275,7 @@ class _TvModernCardState extends State fit: BoxFit.cover, placeholder: (context, url) => Container( color: AppColors.shimmer, - child: Center( + child: const Center( child: SizedBox( width: 40, height: 40, @@ -286,7 +288,7 @@ class _TvModernCardState extends State ), errorWidget: (context, url, error) => Container( color: AppColors.surface, - child: Center( + child: const Center( child: Icon( Icons.broken_image_outlined, color: AppColors.textSecondary, diff --git a/lib/features/iptv/providers/recommendations_provider.dart b/lib/features/iptv/providers/recommendations_provider.dart index fb26c45..82ca28f 100644 --- a/lib/features/iptv/providers/recommendations_provider.dart +++ b/lib/features/iptv/providers/recommendations_provider.dart @@ -33,20 +33,23 @@ class RecommendationService { List allContent, ) { final recommendations = []; - + for (final item in allContent) { final historyKey = item.streamId.toString(); if (watchHistory.containsKey(historyKey)) { final lastPosition = watchHistory[historyKey] as double? ?? 0.0; - + // Only show if watched more than 5 seconds ago and less than 95% complete if (lastPosition > 5 && lastPosition < 95) { - recommendations.add(Recommendation( - item: item, - type: RecommendationType.continueWatching, - score: 100.0 - lastPosition, // Score higher if recently started - reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%', - )); + recommendations.add( + Recommendation( + item: item, + type: RecommendationType.continueWatching, + score: 100.0 - lastPosition, // Score higher if recently started + reason: + 'Continue watching from ${lastPosition.toStringAsFixed(0)}%', + ), + ); } } } @@ -62,16 +65,18 @@ class RecommendationService { List allContent, ) { final recommendations = []; - + for (final item in allContent) { final views = viewCounts[item.streamId.toString()] ?? 0; if (views > 0) { - recommendations.add(Recommendation( - item: item, - type: RecommendationType.trending, - score: views.toDouble(), - reason: '$views views - Trending now', - )); + recommendations.add( + Recommendation( + item: item, + type: RecommendationType.trending, + score: views.toDouble(), + reason: '$views views - Trending now', + ), + ); } } @@ -82,7 +87,7 @@ class RecommendationService { /// Get recently added content static List getRecentlyAdded(List allContent) { final recommendations = []; - + // Sort by added date (most recent first) final sorted = [...allContent]; sorted.sort((a, b) { @@ -95,13 +100,15 @@ class RecommendationService { final daysAgo = DateTime.now() .difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000)) .inDays; - - recommendations.add(Recommendation( - item: item, - type: RecommendationType.recentlyAdded, - score: (20 - daysAgo).toDouble().clamp(0, 100), - reason: 'Added $daysAgo days ago', - )); + + recommendations.add( + Recommendation( + item: item, + type: RecommendationType.recentlyAdded, + score: (20 - daysAgo).toDouble().clamp(0, 100), + reason: 'Added $daysAgo days ago', + ), + ); } return recommendations.take(10).toList(); @@ -113,7 +120,7 @@ class RecommendationService { List allContent, ) { final recommendations = []; - + // Collect watched categories final watchedCategories = {}; watchHistory.forEach((key, value) { @@ -125,12 +132,14 @@ class RecommendationService { // Recommend similar content for (final item in allContent) { if (watchedCategories.contains(item.categoryId)) { - recommendations.add(Recommendation( - item: item, - type: RecommendationType.forYou, - score: 75.0, - reason: 'Based on your interests', - )); + recommendations.add( + Recommendation( + item: item, + type: RecommendationType.forYou, + score: 75.0, + reason: 'Based on your interests', + ), + ); } } @@ -146,12 +155,14 @@ class RecommendationService { final rating = double.tryParse(i.rating ?? '0') ?? 0.0; return rating >= 7.5; }).take(5)) { - recommendations.add(Recommendation( - item: item, - type: RecommendationType.topRated, - score: double.tryParse(item.rating ?? '0') ?? 0.0, - reason: 'Highly rated - ${item.rating}/10', - )); + recommendations.add( + Recommendation( + item: item, + type: RecommendationType.topRated, + score: double.tryParse(item.rating ?? '0') ?? 0.0, + reason: 'Highly rated - ${item.rating}/10', + ), + ); } // Remove duplicates and limit @@ -169,9 +180,8 @@ class RecommendationService { } /// Provider for watch history -final watchHistoryProvider = StateNotifierProvider< - WatchHistoryNotifier, - Map>((ref) { +final watchHistoryProvider = + StateNotifierProvider>((ref) { return WatchHistoryNotifier(); }); @@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier> { } /// Provider for view counts/trending -final trendingProvider = StateNotifierProvider< - TrendingNotifier, - Map>((ref) { +final trendingProvider = + StateNotifierProvider>((ref) { return TrendingNotifier(); }); @@ -213,11 +222,11 @@ class TrendingNotifier extends StateNotifier> { } /// Provider for recommendations -final recommendationsProvider = - FutureProvider.family, (Playlist, Map, Map, List)>( +final recommendationsProvider = FutureProvider.family, + (Playlist, Map, Map, List)>( (ref, params) async { final (playlist, watchHistory, trending, allContent) = params; - + // Combine all recommendation types final allRecommendations = [ ...RecommendationService.getContinueWatching(watchHistory, allContent), @@ -229,7 +238,7 @@ final recommendationsProvider = // Deduplicate and score final seen = {}; final scored = []; - + for (final rec in allRecommendations) { if (!seen.contains(rec.item.streamId)) { scored.add(rec); diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 72c580b..8850381 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -1,4 +1,3 @@ -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; @@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget { class _DashboardScreenState extends ConsumerState { int _selectedIndex = 0; - final List _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings']; + final List _tabs = [ + 'Live TV', + 'Movies', + 'Series', + 'Recordings', + 'Settings' + ]; final List _icons = [ Icons.live_tv_rounded, Icons.movie_rounded, diff --git a/lib/features/iptv/screens/epg_grid_screen.dart b/lib/features/iptv/screens/epg_grid_screen.dart index b1eeab6..ba73b4d 100644 --- a/lib/features/iptv/screens/epg_grid_screen.dart +++ b/lib/features/iptv/screens/epg_grid_screen.dart @@ -30,7 +30,7 @@ class EpgProgram { }); Duration get duration => endTime.difference(startTime); - + double get progress { if (!isNow) return 0; final now = DateTime.now(); @@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget { final Playlist playlist; const EpgGridScreen({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override ConsumerState createState() => _EpgGridScreenState(); @@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState } List _parseEpgPrograms( - Map epgData, String channelId) { + Map epgData, + String channelId, + ) { final programs = []; final now = DateTime.now(); @@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState final start = _parseEpgTime(startStr); final end = _parseEpgTime(endStr); - programs.add(EpgProgram( - id: '${channelId}_${start.millisecondsSinceEpoch}', - channelId: channelId, - title: title, - description: desc, - startTime: start, - endTime: end, - isNow: now.isAfter(start) && now.isBefore(end), - isNext: start.isAfter(now) && - start.difference(now).inMinutes < 60, - )); + programs.add( + EpgProgram( + id: '${channelId}_${start.millisecondsSinceEpoch}', + channelId: channelId, + title: title, + description: desc, + startTime: start, + endTime: end, + isNow: now.isAfter(start) && now.isBefore(end), + isNext: + start.isAfter(now) && start.difference(now).inMinutes < 60, + ), + ); } } catch (e) { continue; @@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState mainAxisAlignment: MainAxisAlignment.center, children: [ 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 child: Center( child: Text( '$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.map((program) { 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( onTap: () => onProgramTap(program), @@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget { value: program.progress / 100, minHeight: 3, backgroundColor: Colors.white24, - valueColor: - const AlwaysStoppedAnimation(Colors.red), + valueColor: const AlwaysStoppedAnimation(Colors.red), ), ), ), @@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget { ), const SizedBox(height: 16), Text( - program.description.isEmpty ? 'No description available' : program.description, + program.description.isEmpty + ? 'No description available' + : program.description, style: const TextStyle(fontSize: 14), ), const SizedBox(height: 24), diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index e68a659..5bd5c9f 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart'; // ... (existing imports) // ... (existing imports) 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/theme/app_colors.dart'; -import '../widgets/epg_overlay.dart'; enum StreamType { live, vod, series } @@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState { StreamSubscription? _messageSubscription; final String _aspectRatio = 'contain'; bool _isSeeking = false; - late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString(); + late final String _viewIdPrefix = + DateTime.now().millisecondsSinceEpoch.toString(); String _viewId = 'iptv-player'; String? _currentStreamUrl; String _statusMessage = 'Loading...'; @@ -134,7 +132,7 @@ class _PlayerScreenState extends ConsumerState { // Store URL for Lite Player final isMobile = MediaQuery.of(context).size.width < 600; final isLiveTV = widget.streamType == StreamType.live; - + // TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping if (isLiveTV && !isMobile) { streamUrl = service.getLiveStreamUrlTs(currentStreamId); @@ -152,15 +150,19 @@ class _PlayerScreenState extends ConsumerState { // Force player choice based on stream type: // - Live TV: Player Lite (simple TS playback with mpegts.js) // - VOD/Series: Player Standard (fuller controls, HLS support) - + // Cache buster to force reload of updated player.html final cacheBuster = DateTime.now().millisecondsSinceEpoch; - final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; - final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html'); - + final streamTypeParam = + widget.streamType == StreamType.live ? 'live' : 'vod'; + final playerFile = isMobile + ? 'player_mobile.html' + : (isLiveTV ? 'player_lite.html' : 'player.html'); + // 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) { playerSrc += '&t=$startTimeOverride'; @@ -300,31 +302,16 @@ class _PlayerScreenState extends ConsumerState { void _toggleFullscreen() { try { final doc = html.document; - final isFullscreen = doc.fullscreenElement != null || - (doc as dynamic).webkitFullscreenElement != null || - (doc as dynamic).mozFullScreenElement != null; + final isFullscreen = doc.fullscreenElement != null || + (doc as dynamic).webkitFullscreenElement != null || + (doc as dynamic).mozFullScreenElement != null; if (isFullscreen) { - if (doc.exitFullscreen != null) { - doc.exitFullscreen(); - } else if ((doc as dynamic).webkitExitFullscreen != null) { - (doc as dynamic).webkitExitFullscreen(); - } else if ((doc as dynamic).mozCancelFullScreen != null) { - (doc as dynamic).mozCancelFullScreen(); - } + doc.exitFullscreen(); } else { final element = doc.documentElement; if (element != null) { - if (element.requestFullscreen != null) { - 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'}); - } + element.requestFullscreen(); } } } catch (e) { @@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState { children: [ Text( widget.channels != null - ? (widget.channels![_currentIndex] + ? (widget + .channels![ + _currentIndex] .name ?? - widget.channels![ + widget + .channels![ _currentIndex] .title ?? 'Unknown') @@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState { // Unified Bottom Bar - EPG + Controls in one bar AnimatedPositioned( 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, right: 16, child: LayoutBuilder( builder: (context, constraints) { - final isSmallScreen = constraints.maxWidth < 600; + final isSmallScreen = + constraints.maxWidth < 600; return Container( padding: EdgeInsets.symmetric( horizontal: isSmallScreen ? 16 : 20, @@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState { borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( - color: AppColors.background.withOpacity(0.3), + color: AppColors.background + .withOpacity(0.3), blurRadius: 10, spreadRadius: 2, ), ], border: Border.all( - color: AppColors.textPrimary.withOpacity(0.15), + color: AppColors.textPrimary + .withOpacity(0.15), ), ), child: isSmallScreen @@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState { // EPG Info (top in mobile) _buildInlineEpg(), const SizedBox(height: 12), - const Divider(color: Colors.white10), + const Divider( + color: Colors.white10), const SizedBox(height: 8), // Control Buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: _buildControlButtons( - isSmallScreen), + isSmallScreen, + ), ), ], ) @@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState { Row( mainAxisSize: MainAxisSize.min, children: _buildControlButtons( - isSmallScreen), + isSmallScreen, + ), ), ], ), @@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState { onTap: _toggleMute, size: buttonSize, ), - ]; } @@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState { }, ); } - } diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 5a1832a..f0b9f98 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget { actions: [ if (currentUser?.isAdmin ?? false) IconButton( - icon: const Icon(Icons.admin_panel_settings, - color: AppColors.textSecondary), + icon: const Icon( + Icons.admin_panel_settings, + color: AppColors.textSecondary, + ), onPressed: () => context.go('/admin'), tooltip: 'Admin Panel', ), @@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget { !constraints.maxHeight.isFinite || constraints.maxHeight <= 0) { return const Center( - child: - CircularProgressIndicator(color: AppColors.primary)); + child: CircularProgressIndicator(color: AppColors.primary), + ); } return playlistsAsync.when( @@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, - size: 64, color: AppColors.error), + const Icon( + Icons.error_outline, + size: 64, + color: AppColors.error, + ), const SizedBox(height: 16), Text( 'Error loading playlists', diff --git a/lib/features/iptv/services/download_service.dart b/lib/features/iptv/services/download_service.dart index 8dac95d..31b8e22 100644 --- a/lib/features/iptv/services/download_service.dart +++ b/lib/features/iptv/services/download_service.dart @@ -120,12 +120,15 @@ class DownloadService { /// Get download progress double getDownloadProgress(String id) { - final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask( - id: id, - title: '', - url: '', - filePath: '', - )); + final task = _downloads.firstWhere( + (t) => t.id == id, + orElse: () => DownloadTask( + id: id, + title: '', + url: '', + filePath: '', + ), + ); return task.progress; } @@ -151,13 +154,12 @@ class DownloadService { if (available < 1024 * 1024 * 1024) { // Less than 1GB free final dir = Directory(_downloadDir); - final files = dir.listSync() - .whereType() - .toList(); + final files = dir.listSync().whereType().toList(); // Sort by modification time, delete oldest first - files.sort((a, b) => - a.statSync().modified.compareTo(b.statSync().modified)); + files.sort( + (a, b) => a.statSync().modified.compareTo(b.statSync().modified), + ); for (final file in files.take(files.length ~/ 3)) { try { @@ -216,7 +218,8 @@ class DownloadService { } // 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 || !_activeDownloads.contains(task.id)) { break; @@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier> { } void clearCompleted() { - state = state - .where((d) => d.status != DownloadStatus.completed) - .toList(); + state = state.where((d) => d.status != DownloadStatus.completed).toList(); } } diff --git a/lib/features/iptv/services/subtitle_service.dart b/lib/features/iptv/services/subtitle_service.dart index 05a9283..5bc5cee 100644 --- a/lib/features/iptv/services/subtitle_service.dart +++ b/lib/features/iptv/services/subtitle_service.dart @@ -17,12 +17,12 @@ class SubtitleTrack { }); SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack( - id: id, - name: name, - language: language, - url: url, - isEnabled: isEnabled ?? this.isEnabled, - ); + id: id, + name: name, + language: language, + url: url, + isEnabled: isEnabled ?? this.isEnabled, + ); } /// Subtitle content with timing @@ -42,16 +42,17 @@ class SubtitleEntry { /// Service for handling subtitles 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 static List parseSrt(String content) { final entries = []; final blocks = content.split('\n\n'); - + for (final block in blocks) { if (block.trim().isEmpty) continue; - + final lines = block.trim().split('\n'); if (lines.length < 3) continue; @@ -67,12 +68,14 @@ class SubtitleService { final startTime = _parseTime(times[0].trim()); final endTime = _parseTime(times[1].trim()); - entries.add(SubtitleEntry( - index: index, - startTime: startTime, - endTime: endTime, - text: text, - )); + entries.add( + SubtitleEntry( + index: index, + startTime: startTime, + endTime: endTime, + text: text, + ), + ); } catch (e) { continue; } @@ -85,10 +88,10 @@ class SubtitleService { static List parseWebVtt(String content) { final entries = []; final blocks = content.split('\n\n'); - + for (final block in blocks) { if (block.trim().isEmpty || block.startsWith('WEBVTT')) continue; - + final lines = block.trim().split('\n'); if (lines.length < 2) continue; @@ -103,12 +106,14 @@ class SubtitleService { final startTime = _parseWebVttTime(times[0].trim()); final endTime = _parseWebVttTime(times[1].trim()); - entries.add(SubtitleEntry( - index: entries.length, - startTime: startTime, - endTime: endTime, - text: text, - )); + entries.add( + SubtitleEntry( + index: entries.length, + startTime: startTime, + endTime: endTime, + text: text, + ), + ); } catch (e) { continue; } @@ -120,7 +125,8 @@ class SubtitleService { /// Get subtitle at specific time static String? getSubtitleAtTime(List entries, Duration time) { 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; } } diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 1e513d7..f584859 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -183,11 +183,14 @@ class XtreamService { /// Get all live TV channels grouped by category /// /// Returns a Map where key is category name and value is list of channels - Future>> getLiveChannels({bool refresh = false}) async { + Future>> getLiveChannels( + {bool refresh = false}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); - final options = refresh - ? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() + final options = refresh + ? _cacheOptions + .copyWith(policy: CachePolicy.refreshForceCache) + .toExtra() : _cacheOptions.toExtra(); try { @@ -226,7 +229,8 @@ class XtreamService { return groupedChannels; } catch (e) { // Try to return from local cache if network fails - final cached = _loadFromLocalCache('live_channels', Channel.fromJson); + final cached = + _loadFromLocalCache('live_channels', Channel.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch live channels: $e'); } @@ -236,27 +240,30 @@ class XtreamService { if (!kIsWeb) return; try { final jsonStr = jsonEncode(data); - html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr; + html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = + jsonStr; } catch (e) { debugPrint('Error saving to local cache ($key): $e'); } } Map> _loadFromLocalCache( - String key, T Function(Map) fromJson) { + String key, + T Function(Map) fromJson, + ) { if (!kIsWeb) return {}; 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 {}; - + final data = jsonDecode(jsonStr) as Map; final result = >{}; - + data.forEach((cat, items) { if (items is List) { - result[cat] = items - .map((i) => fromJson(i as Map)) - .toList(); + result[cat] = + items.map((i) => fromJson(i as Map)).toList(); } }); return result; @@ -301,8 +308,10 @@ class XtreamService { Future>> getVodItems({bool refresh = false}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); - final options = refresh - ? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() + final options = refresh + ? _cacheOptions + .copyWith(policy: CachePolicy.refreshForceCache) + .toExtra() : _cacheOptions.toExtra(); try { @@ -342,7 +351,8 @@ class XtreamService { return groupedVods; } catch (e) { // Try to return from local cache if network fails - final cached = _loadFromLocalCache('vod_items', VodItem.fromJson); + final cached = + _loadFromLocalCache('vod_items', VodItem.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch VOD items: $e'); } @@ -383,8 +393,10 @@ class XtreamService { Future>> getSeries({bool refresh = false}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); - final options = refresh - ? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() + final options = refresh + ? _cacheOptions + .copyWith(policy: CachePolicy.refreshForceCache) + .toExtra() : _cacheOptions.toExtra(); try { @@ -424,15 +436,18 @@ class XtreamService { return groupedSeries; } catch (e) { // Try to return from local cache if network fails - final cached = _loadFromLocalCache('series_items', Series.fromJson); + final cached = + _loadFromLocalCache('series_items', Series.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch series: $e'); } } /// Get movies with pagination support - Future> getMoviesPaginated( - {int offset = 0, int limit = 100}) async { + Future> getMoviesPaginated({ + int offset = 0, + int limit = 100, + }) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -511,8 +526,10 @@ class XtreamService { } /// Get series with pagination support (returns flat list) - Future> getSeriesPaginated( - {int offset = 0, int limit = 100}) async { + Future> getSeriesPaginated({ + int offset = 0, + int limit = 100, + }) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { diff --git a/lib/features/iptv/widgets/channel_card.dart b/lib/features/iptv/widgets/channel_card.dart index 0bd860c..ff3e7cb 100644 --- a/lib/features/iptv/widgets/channel_card.dart +++ b/lib/features/iptv/widgets/channel_card.dart @@ -79,7 +79,9 @@ class _ChannelCardState extends State child: GestureDetector( onTap: widget.onTap, child: ScaleTransition( - scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0), + scale: widget.isActive + ? _pulseAnimation + : const AlwaysStoppedAnimation(1.0), child: AnimatedContainer( duration: AppTheme.durationMd, curve: AppTheme.curveDefault, @@ -90,7 +92,9 @@ class _ChannelCardState extends State border: Border.all( color: widget.isActive ? 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, ), borderRadius: BorderRadius.circular(AppTheme.radiusMd), diff --git a/lib/features/iptv/widgets/continue_watching_widget.dart b/lib/features/iptv/widgets/continue_watching_widget.dart index 1aacc8b..4b8568c 100644 --- a/lib/features/iptv/widgets/continue_watching_widget.dart +++ b/lib/features/iptv/widgets/continue_watching_widget.dart @@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget { final VoidCallback? onItemTap; const ContinueWatchingWidget({ - Key? key, + super.key, required this.playlist, required this.content, this.onItemTap, - }) : super(key: key); + }); @override ConsumerState createState() => @@ -68,12 +68,12 @@ class _ContinueWatchingWidgetState const SizedBox(width: 16), ...continueWatching.take(5).map((rec) { final item = rec.item; - final percentage = watchHistory[item.streamId.toString()] - is Map - ? (watchHistory[item.streamId.toString()] - as Map)['percentage'] as double? ?? - 0.0 - : 0.0; + final percentage = + watchHistory[item.streamId.toString()] is Map + ? (watchHistory[item.streamId.toString()] + as Map)['percentage'] as double? ?? + 0.0 + : 0.0; return Container( margin: const EdgeInsets.only(right: 12), @@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget { placeholder: (context, url) => Container( color: Colors.grey[800], ), - errorWidget: (context, url, error) => - Icon(Icons.image_not_supported, - color: Colors.grey[600]), + errorWidget: (context, url, error) => Icon( + Icons.image_not_supported, + color: Colors.grey[600], + ), ), ), @@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget { final VoidCallback? onItemTap; const TrendingWidget({ - Key? key, + super.key, required this.playlist, required this.content, this.onItemTap, - }) : super(key: key); + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( + const Row( children: [ - const Icon(Icons.local_fire_department, - color: Colors.red, size: 24), - const SizedBox(width: 8), - const Text( + Icon( + Icons.local_fire_department, + color: Colors.red, + size: 24, + ), + SizedBox(width: 8), + Text( 'Trending Now', style: TextStyle( fontSize: 18, @@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget { placeholder: (context, url) => Container( color: Colors.grey[800], ), - errorWidget: (context, url, error) => - Icon(Icons.image_not_supported, - color: Colors.grey[600]), + errorWidget: (context, url, error) => Icon( + Icons.image_not_supported, + color: Colors.grey[600], + ), ), ), ), @@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget { if (item.rating != null) Row( children: [ - const Icon(Icons.star, - color: Colors.amber, size: 12), + const Icon( + Icons.star, + color: Colors.amber, + size: 12, + ), const SizedBox(width: 4), Text( item.rating ?? '0', @@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget { final VoidCallback? onItemTap; const RecentlyAddedWidget({ - Key? key, + super.key, required this.playlist, required this.content, this.onItemTap, - }) : super(key: key); + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: const Text( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( 'Recently Added', style: TextStyle( fontSize: 18, diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index c59d60e..105e7ea 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState child: Container( decoration: BoxDecoration( color: Colors.white.withOpacity( - 0.05), // Subtle transparent background + 0.05, + ), // Subtle transparent background borderRadius: const BorderRadius.vertical( - top: Radius.circular(12)), + top: Radius.circular(12), + ), ), padding: const EdgeInsets.all(16), child: Center( @@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState // Footer Container( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + horizontal: 12, + vertical: 8, + ), color: Colors.black54, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState EpgEntry.parseDateTime(entry.start); final end = EpgEntry.parseDateTime(entry.end); - if (start == null || end == null) + if (start == null || end == null) { return false; + } return now.isAfter(start) && now.isBefore(end); } catch (e) { diff --git a/lib/features/iptv/widgets/quality_selector_widget.dart b/lib/features/iptv/widgets/quality_selector_widget.dart index 18bc922..2207dcf 100644 --- a/lib/features/iptv/widgets/quality_selector_widget.dart +++ b/lib/features/iptv/widgets/quality_selector_widget.dart @@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget { final VoidCallback onClose; const QualitySelectorWidget({ - Key? key, + super.key, required this.qualitySelector, required this.onClose, - }) : super(key: key); + }); @override ConsumerState createState() => @@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState { // Header Container( padding: const EdgeInsets.all(16), - decoration: BoxDecoration( + decoration: const BoxDecoration( border: Border( bottom: BorderSide(color: Colors.white24), ), @@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState { const EdgeInsets.symmetric(horizontal: 24, vertical: 8), leading: isSelected ? const Icon(Icons.check_circle, color: Colors.blue) - : const Icon(Icons.radio_button_unchecked, - color: Colors.white30), + : const Icon( + Icons.radio_button_unchecked, + color: Colors.white30, + ), title: Text( quality.label, style: TextStyle( color: isSelected ? Colors.blue : Colors.white, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, ), ), subtitle: Text( @@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( border: Border( top: BorderSide(color: Colors.white24), ), @@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget { final VoidCallback onTap; const QualityIndicator({ - Key? key, + super.key, required this.qualitySelector, required this.onTap, - }) : super(key: key); + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget { final QualitySelector qualitySelector; const BandwidthMonitor({ - Key? key, + super.key, required this.qualitySelector, - }) : super(key: key); + }); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 2ac5d4a..978338d 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -5,10 +5,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:http/http.dart' as http; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; -import '../../../core/widgets/glass_container.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; -import '../services/xtream_service.dart'; // ═══════════════════════════════════════════════════════════════════════════ // ENTRÉE — Onglet "TV & Enregistrements" avec 3 sous-onglets @@ -48,15 +46,20 @@ class _RecordingsTabState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Icon(Icons.tv, color: Colors.white, size: 28), - const SizedBox(width: 12), - Text('TV & Enregistrements', + Row( + children: [ + const Icon(Icons.tv, color: Colors.white, size: 28), + const SizedBox(width: 12), + Text( + 'TV & Enregistrements', style: GoogleFonts.outfit( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Colors.white)), - ]), + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), const SizedBox(height: 16), TabBar( controller: _tabController, @@ -65,16 +68,20 @@ class _RecordingsTabState extends State indicatorColor: Colors.redAccent, indicatorWeight: 3, labelStyle: GoogleFonts.outfit( - fontWeight: FontWeight.w600, fontSize: 14), + fontWeight: FontWeight.w600, + fontSize: 14, + ), unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14), tabs: const [ Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'), Tab( - icon: Icon(Icons.fiber_manual_record, size: 18), - text: 'Enregistrements'), + icon: Icon(Icons.fiber_manual_record, size: 18), + text: 'Enregistrements', + ), Tab( - icon: Icon(Icons.repeat, size: 18), - text: 'Season Passes'), + icon: Icon(Icons.repeat, size: 18), + text: 'Season Passes', + ), ], ), ], @@ -130,7 +137,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> void initState() { super.initState(); _searchCtrl.addListener( - () => setState(() => _searchQuery = _searchCtrl.text.toLowerCase())); + () => setState(() => _searchQuery = _searchCtrl.text.toLowerCase()), + ); } @override @@ -191,9 +199,11 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> error: (e, _) => Center(child: Text('Erreur: $e')), data: (groupedChannels) { final categories = groupedChannels.keys - .where((cat) => - settings.liveTvKeywords.isEmpty || - settings.matchesLiveTvFilter(cat)) + .where( + (cat) => + settings.liveTvKeywords.isEmpty || + settings.matchesLiveTvFilter(cat), + ) .toList(); // Initial category selection @@ -217,12 +227,15 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> width: 180, child: Column( children: [ - Text('GROUPES', - style: GoogleFonts.outfit( - color: Colors.white38, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.2)), + Text( + 'GROUPES', + style: GoogleFonts.outfit( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), const SizedBox(height: 8), Expanded( child: ListView.builder( @@ -238,7 +251,9 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> }), child: Container( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), + horizontal: 12, + vertical: 10, + ), margin: const EdgeInsets.only(bottom: 4), decoration: BoxDecoration( color: isSelected @@ -249,13 +264,14 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> child: Text( cat, style: GoogleFonts.outfit( - color: isSelected - ? Colors.white - : Colors.white60, - fontSize: 12, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal), + color: isSelected + ? Colors.white + : Colors.white60, + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -273,90 +289,111 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> // ── Colonne 2 : CHAÎNES ── SizedBox( width: 250, - child: Column(children: [ - // Barre de recherche - TextField( - controller: _searchCtrl, - style: const TextStyle(color: Colors.white, fontSize: 13), - decoration: InputDecoration( - hintText: 'Rechercher...', - hintStyle: - const TextStyle(color: Colors.white38, fontSize: 13), - prefixIcon: const Icon(Icons.search, - color: Colors.white38, size: 18), - filled: true, - fillColor: Colors.white.withOpacity(0.07), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none, + child: Column( + children: [ + // Barre de recherche + TextField( + controller: _searchCtrl, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: 'Rechercher...', + hintStyle: const TextStyle( + color: Colors.white38, fontSize: 13), + prefixIcon: const Icon( + Icons.search, + color: Colors.white38, + size: 18, + ), + filled: true, + fillColor: Colors.white.withOpacity(0.07), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), ), ), - ), - const SizedBox(height: 12), - Expanded( - child: visibleChannels.isEmpty - ? Center( - child: Text('Aucune chaîne', + const SizedBox(height: 12), + Expanded( + child: visibleChannels.isEmpty + ? const Center( + child: Text( + 'Aucune chaîne', style: TextStyle( - color: Colors.white24, fontSize: 12))) - : ListView.builder( - itemCount: visibleChannels.length, - itemBuilder: (ctx, i) { - final ch = visibleChannels[i]; - final isSelected = - _selectedChannel?.streamId == ch.streamId; - return InkWell( - onTap: () => _loadEpg(ch), - borderRadius: BorderRadius.circular(8), - child: Container( - margin: const EdgeInsets.only(bottom: 3), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? Colors.redAccent.withOpacity(0.2) - : Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSelected - ? Colors.redAccent.withOpacity(0.5) - : Colors.transparent, + color: Colors.white24, + fontSize: 12, + ), + ), + ) + : ListView.builder( + itemCount: visibleChannels.length, + itemBuilder: (ctx, i) { + final ch = visibleChannels[i]; + final isSelected = + _selectedChannel?.streamId == ch.streamId; + return InkWell( + onTap: () => _loadEpg(ch), + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 3), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, ), - ), - child: Row(children: [ - ch.streamIcon.isNotEmpty - ? Image.network(ch.streamIcon, - width: 24, - height: 16, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => - const Icon(Icons.tv, - color: Colors.white24, - size: 16)) - : const Icon(Icons.tv, - color: Colors.white24, size: 16), - const SizedBox(width: 10), - Expanded( - child: Text( - ch.name, - style: GoogleFonts.outfit( - color: isSelected - ? Colors.white - : Colors.white70, - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + decoration: BoxDecoration( + color: isSelected + ? Colors.redAccent.withOpacity(0.2) + : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? Colors.redAccent.withOpacity(0.5) + : Colors.transparent, ), ), - ]), - ), - ); - }, - ), - ), - ]), + child: Row( + children: [ + ch.streamIcon.isNotEmpty + ? Image.network( + ch.streamIcon, + width: 24, + height: 16, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + const Icon( + Icons.tv, + color: Colors.white24, + size: 16, + ), + ) + : const Icon( + Icons.tv, + color: Colors.white24, + size: 16, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + ch.name, + style: GoogleFonts.outfit( + color: isSelected + ? Colors.white + : Colors.white70, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), ), const VerticalDivider(width: 32, color: Colors.white10), @@ -368,14 +405,19 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.tv_off, - size: 64, color: Color(0x1FFFFFFF)), + const Icon( + Icons.tv_off, + size: 64, + color: Color(0x1FFFFFFF), + ), const SizedBox(height: 16), Text( 'Sélectionnez une chaîne\npour voir son guide des programmes', textAlign: TextAlign.center, style: GoogleFonts.outfit( - color: Colors.white38, fontSize: 15), + color: Colors.white38, + fontSize: 15, + ), ), ], ), @@ -383,35 +425,42 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Text( - _selectedChannel!.name, - style: GoogleFonts.outfit( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, + Row( + children: [ + Text( + _selectedChannel!.name, + style: GoogleFonts.outfit( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.refresh, - color: Colors.white54, size: 18), - tooltip: 'Recharger l\'EPG', - onPressed: () => _loadEpg(_selectedChannel!), - ), - ]), + const Spacer(), + IconButton( + icon: const Icon( + Icons.refresh, + color: Colors.white54, + size: 18, + ), + tooltip: 'Recharger l\'EPG', + onPressed: () => _loadEpg(_selectedChannel!), + ), + ], + ), const Divider(color: Colors.white12), Expanded( child: _loadingEpg ? const Center( - child: CircularProgressIndicator()) + child: CircularProgressIndicator(), + ) : _epgError.isNotEmpty ? Center( child: Text( 'Erreur EPG: $_epgError', style: const TextStyle( - color: Colors.redAccent, - fontSize: 12), + color: Colors.redAccent, + fontSize: 12, + ), textAlign: TextAlign.center, ), ) @@ -421,7 +470,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> 'Aucun programme EPG disponible\npour cette chaîne', textAlign: TextAlign.center, style: GoogleFonts.outfit( - color: Colors.white38), + color: Colors.white38, + ), ), ) : ListView.builder( @@ -499,28 +549,43 @@ class _ProgrammeCard extends StatelessWidget { } void _showConfirm( - BuildContext context, String title, DateTime start, DateTime end) { + BuildContext context, + String title, + DateTime start, + DateTime end, + ) { showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: const Color(0xFF1E1E2E), - title: Row(children: [ - const Icon(Icons.fiber_manual_record, - color: Colors.redAccent, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text('Enregistrer', - style: GoogleFonts.outfit(color: Colors.white))), - ]), + title: Row( + children: [ + const Icon( + Icons.fiber_manual_record, + color: Colors.redAccent, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Enregistrer', + style: GoogleFonts.outfit(color: Colors.white), + ), + ), + ], + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title.isEmpty ? channel.name : title, - style: GoogleFonts.outfit( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16)), + Text( + title.isEmpty ? channel.name : title, + style: GoogleFonts.outfit( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), const SizedBox(height: 8), Text( '${_fmt(_start)} → ${_fmt(_end)}', @@ -539,14 +604,19 @@ class _ProgrammeCard extends StatelessWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Annuler')), + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), onPressed: () async { Navigator.pop(ctx); await _saveRecording( - context, title.isEmpty ? channel.name : title, start, end); + context, + title.isEmpty ? channel.name : title, + start, + end, + ); }, child: const Text('🔴 Enregistrer'), ), @@ -556,7 +626,11 @@ class _ProgrammeCard extends StatelessWidget { } Future _saveRecording( - BuildContext context, String title, DateTime start, DateTime end) async { + BuildContext context, + String title, + DateTime start, + DateTime end, + ) async { try { final response = await http.post( Uri.parse('/api/recordings'), @@ -570,14 +644,18 @@ class _ProgrammeCard extends StatelessWidget { }), ); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(response.statusCode == 200 - ? '✅ "$title" planifié !' - : '❌ Erreur: ${response.body}'), - backgroundColor: response.statusCode == 200 - ? Colors.green.shade800 - : Colors.red.shade800, - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + response.statusCode == 200 + ? '✅ "$title" planifié !' + : '❌ Erreur: ${response.body}', + ), + backgroundColor: response.statusCode == 200 + ? Colors.green.shade800 + : Colors.red.shade800, + ), + ); } } catch (e) { if (context.mounted) { @@ -619,13 +697,17 @@ class _ProgrammeCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( - color: Colors.redAccent, - borderRadius: BorderRadius.circular(4)), - child: Text('LIVE', - style: GoogleFonts.outfit( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold)), + color: Colors.redAccent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'LIVE', + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), ) else Text( @@ -664,8 +746,11 @@ class _ProgrammeCard extends StatelessWidget { : null, trailing: !isPast ? IconButton( - icon: const Icon(Icons.fiber_manual_record, - color: Colors.redAccent, size: 20), + icon: const Icon( + Icons.fiber_manual_record, + color: Colors.redAccent, + size: 20, + ), tooltip: 'Enregistrer ce programme', onPressed: () => _record(context), ) @@ -747,23 +832,29 @@ class _RecordingsListViewState extends State<_RecordingsListView> { context: context, builder: (ctx) => AlertDialog( backgroundColor: const Color(0xFF1E1E2E), - title: Text(title, - style: GoogleFonts.outfit(color: Colors.white, fontSize: 14)), + title: Text( + title, + style: GoogleFonts.outfit(color: Colors.white, fontSize: 14), + ), content: SizedBox( width: 500, height: 300, child: SingleChildScrollView( - child: Text(content, - style: const TextStyle( - color: Colors.white70, - fontSize: 11, - fontFamily: 'monospace')), + child: Text( + content, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontFamily: 'monospace', + ), + ), ), ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Fermer')) + onPressed: () => Navigator.pop(ctx), + child: const Text('Fermer'), + ), ], ), ); @@ -807,31 +898,44 @@ class _RecordingsListViewState extends State<_RecordingsListView> { padding: const EdgeInsets.all(16), child: Column( children: [ - Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - IconButton( - icon: const Icon(Icons.refresh, color: Colors.white70), - onPressed: _fetchRecordings, - tooltip: 'Rafraîchir', - ), - ]), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white70), + onPressed: _fetchRecordings, + tooltip: 'Rafraîchir', + ), + ], + ), Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( - child: Text(_error!, - style: const TextStyle(color: Colors.redAccent))) + child: Text( + _error!, + style: const TextStyle(color: Colors.redAccent), + ), + ) : _recordings.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.videocam_off, - size: 64, color: Color(0x1FFFFFFF)), + const Icon( + Icons.videocam_off, + size: 64, + color: Color(0x1FFFFFFF), + ), const SizedBox(height: 16), - Text('Aucun enregistrement', - style: GoogleFonts.outfit( - color: Colors.white38, fontSize: 16)), + Text( + 'Aucun enregistrement', + style: GoogleFonts.outfit( + color: Colors.white38, + fontSize: 16, + ), + ), ], ), ) @@ -848,11 +952,14 @@ class _RecordingsListViewState extends State<_RecordingsListView> { color: Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.1)), + color: Colors.white.withOpacity(0.1), + ), ), child: ListTile( contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), + horizontal: 16, + vertical: 8, + ), leading: Icon( status == 'recording' ? Icons.fiber_manual_record @@ -860,10 +967,13 @@ class _RecordingsListViewState extends State<_RecordingsListView> { color: color, size: 28, ), - title: Text(rec['title'] ?? '—', - style: GoogleFonts.outfit( - color: Colors.white, - fontWeight: FontWeight.w600)), + title: Text( + rec['title'] ?? '—', + style: GoogleFonts.outfit( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -871,14 +981,18 @@ class _RecordingsListViewState extends State<_RecordingsListView> { Text( '${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}', style: const TextStyle( - color: Colors.white54, - fontSize: 12), + color: Colors.white54, + fontSize: 12, + ), ), if (rec['error_reason'] != null) - Text('⚠ ${rec['error_reason']}', - style: const TextStyle( - color: Colors.orangeAccent, - fontSize: 11)), + Text( + '⚠ ${rec['error_reason']}', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), ], ), trailing: Row( @@ -886,41 +1000,57 @@ class _RecordingsListViewState extends State<_RecordingsListView> { children: [ Container( padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(20), border: Border.all( - color: color.withOpacity(0.4)), + color: color.withOpacity(0.4), + ), + ), + child: Text( + _statusLabel(status), + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 11, + ), ), - child: Text(_statusLabel(status), - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - fontSize: 11)), ), const SizedBox(width: 4), if (status == 'recording') IconButton( - icon: const Icon(Icons.stop_circle, - color: Colors.redAccent), + icon: const Icon( + Icons.stop_circle, + color: Colors.redAccent, + ), tooltip: 'Arrêter', onPressed: () => _stopRecording( - rec['id'], rec['title'] ?? ''), + rec['id'], + rec['title'] ?? '', + ), ), IconButton( icon: const Icon( - Icons.description_outlined, - color: Colors.blueAccent, - size: 20), + Icons.description_outlined, + color: Colors.blueAccent, + size: 20, + ), tooltip: 'Logs', onPressed: () => _showLogs( - rec['id'], rec['title'] ?? ''), + rec['id'], + rec['title'] ?? '', + ), ), IconButton( - icon: const Icon(Icons.delete_outline, - color: Colors.white38, size: 20), + icon: const Icon( + Icons.delete_outline, + color: Colors.white38, + size: 20, + ), tooltip: 'Supprimer', onPressed: () => _deleteRecording(rec['id']), @@ -980,7 +1110,8 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { _loadPasses(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Season Pass "$title" supprimé'))); + SnackBar(content: Text('Season Pass "$title" supprimé')), + ); } } @@ -992,33 +1123,45 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { context: context, builder: (ctx) => AlertDialog( backgroundColor: const Color(0xFF1E1E2E), - title: Row(children: [ - const Icon(Icons.repeat, color: Colors.purpleAccent), - const SizedBox(width: 8), - Text('Nouveau Season Pass', - style: GoogleFonts.outfit(color: Colors.white)), - ]), + title: Row( + children: [ + const Icon(Icons.repeat, color: Colors.purpleAccent), + const SizedBox(width: 8), + Text( + 'Nouveau Season Pass', + style: GoogleFonts.outfit(color: Colors.white), + ), + ], + ), content: SizedBox( width: 400, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.', - style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), - ), - const SizedBox(height: 16), - _buildField( - titleCtrl, 'Titre de l\'émission (ex: Champions League)'), - const SizedBox(height: 8), - _buildField(channelCtrl, 'Channel ID (ex: 554021)'), - const SizedBox(height: 8), - _buildField( - urlCtrl, 'stream_url (optionnel, ex: /api/live/554021.ts)'), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.', + style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), + ), + const SizedBox(height: 16), + _buildField( + titleCtrl, + 'Titre de l\'émission (ex: Champions League)', + ), + const SizedBox(height: 8), + _buildField(channelCtrl, 'Channel ID (ex: 554021)'), + const SizedBox(height: 8), + _buildField( + urlCtrl, + 'stream_url (optionnel, ex: /api/live/554021.ts)', + ), + ], + ), ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Annuler')), + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.purpleAccent), @@ -1034,15 +1177,20 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { Uri.parse('/api/season-passes'), headers: {'Content-Type': 'application/json'}, body: json.encode( - {'show_title': t, 'channel_id': c, 'stream_url': u}), + {'show_title': t, 'channel_id': c, 'stream_url': u}, + ), ); _loadPasses(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(r.statusCode == 201 - ? '✅ Season Pass créé !' - : '❌ ${r.body}'), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + r.statusCode == 201 + ? '✅ Season Pass créé !' + : '❌ ${r.body}', + ), + ), + ); } }, child: const Text('Créer'), @@ -1060,9 +1208,11 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { labelText: label, labelStyle: const TextStyle(color: Colors.white54, fontSize: 12), enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24)), + borderSide: BorderSide(color: Colors.white24), + ), focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.purpleAccent)), + borderSide: BorderSide(color: Colors.purpleAccent), + ), ), ); } @@ -1071,109 +1221,147 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), - child: Column(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Enregistrements automatiques', - style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13)), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.purpleAccent), - icon: const Icon(Icons.add, size: 18), - label: const Text('Nouveau'), - onPressed: _showCreate, - ), - ], - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.purpleAccent.withOpacity(0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)), - ), - child: Row(children: [ - const Icon(Icons.info_outline, - color: Colors.purpleAccent, size: 18), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.', - style: GoogleFonts.outfit( - color: Colors.purpleAccent.shade100, fontSize: 12), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Enregistrements automatiques', + style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purpleAccent, + ), + icon: const Icon(Icons.add, size: 18), + label: const Text('Nouveau'), + onPressed: _showCreate, + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purpleAccent.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)), ), - ]), - ), - const SizedBox(height: 16), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _passes.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.repeat, - size: 64, color: Color(0x1FFFFFFF)), - const SizedBox(height: 16), - Text('Aucun Season Pass actif', + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: Colors.purpleAccent, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.', + style: GoogleFonts.outfit( + color: Colors.purpleAccent.shade100, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _passes.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.repeat, + size: 64, + color: Color(0x1FFFFFFF), + ), + const SizedBox(height: 16), + Text( + 'Aucun Season Pass actif', style: GoogleFonts.outfit( - color: Colors.white38, fontSize: 16)), - const SizedBox(height: 8), - Text( + color: Colors.white38, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( 'Créez-en un pour enregistrer automatiquement vos émissions préférées', style: GoogleFonts.outfit( - color: Colors.white24, fontSize: 12), - textAlign: TextAlign.center), - ], - ), - ) - : ListView.builder( - itemCount: _passes.length, - itemBuilder: (ctx, i) { - final p = _passes[i]; - return Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: Colors.purpleAccent.withOpacity(0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.purpleAccent.withOpacity(0.2)), - ), - child: ListTile( - leading: const Icon(Icons.repeat, - color: Colors.purpleAccent), - title: Text(p['show_title'] ?? '—', + color: Colors.white24, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + itemCount: _passes.length, + itemBuilder: (ctx, i) { + final p = _passes[i]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.purpleAccent.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.purpleAccent.withOpacity(0.2), + ), + ), + child: ListTile( + leading: const Icon( + Icons.repeat, + color: Colors.purpleAccent, + ), + title: Text( + p['show_title'] ?? '—', style: GoogleFonts.outfit( - color: Colors.white, - fontWeight: FontWeight.w600)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Chaîne : ${p['channel_id']}', + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Chaîne : ${p['channel_id']}', style: const TextStyle( - color: Colors.white54, fontSize: 11)), - Text('Flux : ${p['stream_url']}', + color: Colors.white54, + fontSize: 11, + ), + ), + Text( + 'Flux : ${p['stream_url']}', style: const TextStyle( - color: Colors.white38, fontSize: 10)), - ], + color: Colors.white38, + fontSize: 10, + ), + ), + ], + ), + trailing: IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.white38, + ), + tooltip: 'Supprimer', + onPressed: () => + _deletePass(p['id'], p['show_title'] ?? ''), + ), ), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, - color: Colors.white38), - tooltip: 'Supprimer', - onPressed: () => - _deletePass(p['id'], p['show_title'] ?? ''), - ), - ), - ); - }, - ), - ), - ]), + ); + }, + ), + ), + ], + ), ); } } diff --git a/lib/features/iptv/widgets/simple_recording_widget.dart b/lib/features/iptv/widgets/simple_recording_widget.dart index 641a868..9808465 100644 --- a/lib/features/iptv/widgets/simple_recording_widget.dart +++ b/lib/features/iptv/widgets/simple_recording_widget.dart @@ -47,7 +47,7 @@ class _SimpleRecordingWidgetState extends State { _isRecording = true; _status = '🔴 Recording for $minutes min'; }); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('✅ Recording started!')), @@ -80,7 +80,7 @@ class _SimpleRecordingWidgetState extends State { if (response.statusCode == 200) { setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}'); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('✅ Recording scheduled!')), @@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State { value: _durationMinutes, dropdownColor: Colors.grey[800], items: [30, 60, 120, 240, 480] - .map((m) => DropdownMenuItem( - value: m, - child: Text('$m min'), - )) + .map( + (m) => DropdownMenuItem( + value: m, + child: Text('$m min'), + ), + ) .toList(), onChanged: (v) { if (v != null) { diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart index 25f72eb..8bbe723 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -65,8 +65,11 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, - size: 48, color: AppColors.error), + const Icon( + Icons.error_outline, + size: 48, + color: AppColors.error, + ), const SizedBox(height: 16), Text('Error loading playlists', style: GoogleFonts.inter()), TextButton( @@ -82,12 +85,16 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.playlist_remove, - size: 48, color: AppColors.textSecondary), + const Icon( + Icons.playlist_remove, + size: 48, + color: AppColors.textSecondary, + ), const SizedBox(height: 16), - Text('No playlists available', - style: - GoogleFonts.inter(color: AppColors.textSecondary)), + Text( + 'No playlists available', + style: GoogleFonts.inter(color: AppColors.textSecondary), + ), ], ), ); @@ -158,8 +165,11 @@ class _MobilePlaylistCard extends StatelessWidget { color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.playlist_play, - color: AppColors.primary, size: 28), + child: const Icon( + Icons.playlist_play, + color: AppColors.primary, + size: 28, + ), ), const SizedBox(width: 16), Expanded( diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index cffcf6b..89771b0 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -48,14 +48,16 @@ class _MobileLiveTVTabState extends ConsumerState { children: [ const CircularProgressIndicator(), 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), FutureBuilder( future: Future.delayed(const Duration(seconds: 8)), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return TextButton( - onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), + onPressed: () => ref.refresh( + liveChannelsByPlaylistProvider(widget.playlist)), child: const Text('Taking too long? Tap to retry'), ); } @@ -73,12 +75,19 @@ class _MobileLiveTVTabState extends ConsumerState { children: [ const Icon(Icons.error_outline, color: Colors.red, size: 48), 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), - 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), ElevatedButton( - onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), + onPressed: () => ref + .refresh(liveChannelsByPlaylistProvider(widget.playlist)), child: const Text('Retry'), ), ], @@ -285,7 +294,10 @@ class _MobileLiveTVTabState extends ConsumerState { channel: channel, playlist: widget.playlist, onTap: () => _playChannel( - context, channel, displayedChannels), + context, + channel, + displayedChannels, + ), ); }, ), @@ -299,7 +311,10 @@ class _MobileLiveTVTabState extends ConsumerState { } void _playChannel( - BuildContext context, Channel channel, List channels) { + BuildContext context, + Channel channel, + List channels, + ) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => MobilePlayerScreen( diff --git a/lib/mobile/theme/mobile_theme.dart b/lib/mobile/theme/mobile_theme.dart index a8d8a88..52470d4 100644 --- a/lib/mobile/theme/mobile_theme.dart +++ b/lib/mobile/theme/mobile_theme.dart @@ -31,7 +31,6 @@ class MobileTheme { final baseTheme = ThemeData.dark(useMaterial3: true); return baseTheme.copyWith( - useMaterial3: true, colorScheme: AppColors.darkColorScheme, scaffoldBackgroundColor: AppColors.background, canvasColor: AppColors.background, diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 8b3d68c..d2549d9 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -48,7 +48,7 @@ class MobileScaffold extends ConsumerWidget { child: Container( decoration: BoxDecoration( color: AppColors.surface.withOpacity(0.9), - border: Border( + border: const Border( top: BorderSide( color: AppColors.border, width: 0.5, diff --git a/web/player_mobile.html b/web/player_mobile.html index 27f8670..700b389 100644 --- a/web/player_mobile.html +++ b/web/player_mobile.html @@ -228,7 +228,6 @@ // Android / Chrome - HLS.js if (isHLS && Hls.isSupported()) { log('Using Hls.js optimized for mobile'); - const isTurbo = urlParams.get('turbo') === 'true'; const hls = new Hls({ maxBufferLength: 60,