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

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

No files matched your search

+7 -4
View File
@@ -30,7 +30,8 @@ class EpgApi {
if (playlist == null) { if (playlist == null) {
return Response.forbidden( return Response.forbidden(
json.encode({'error': 'Playlist non trouvée'}), json.encode({'error': 'Playlist non trouvée'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
final dns = playlist.dns; final dns = playlist.dns;
@@ -43,7 +44,7 @@ class EpgApi {
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
Map<String, dynamic> epgData = { Map<String, dynamic> epgData = {
'channel_id': channelId, 'channel_id': channelId,
'programmes': [] 'programmes': [],
}; };
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -72,8 +73,10 @@ class EpgApi {
expiresAt: DateTime.now().add(const Duration(minutes: 30)), expiresAt: DateTime.now().add(const Duration(minutes: 30)),
); );
return Response.ok(jsonStr, return Response.ok(
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}); jsonStr,
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
);
} catch (e) { } catch (e) {
return Response.internalServerError( return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}), body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
+10 -6
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/playlist.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
import '../database/database.dart'; import '../database/database.dart';
@@ -34,7 +33,8 @@ class ProxyHandler {
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async { Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
// Basic caching to avoid DB overhead on every video segment // Basic caching to avoid DB overhead on every video segment
final now = DateTime.now(); final now = DateTime.now();
final cacheKey = 'global_playlist'; // Currently app has one primary playlist per user/global const cacheKey =
'global_playlist'; // Currently app has one primary playlist per user/global
if (_playlistCache.containsKey(cacheKey)) { if (_playlistCache.containsKey(cacheKey)) {
final (cached, expiry) = _playlistCache[cacheKey]!; final (cached, expiry) = _playlistCache[cacheKey]!;
@@ -134,7 +134,8 @@ class ProxyHandler {
final playlist = await _getCachedPlaylist(request); final playlist = await _getCachedPlaylist(request);
if (playlist == null) { if (playlist == null) {
return Response.forbidden( return Response.forbidden(
'No active playlist configuration found to validate request'); 'No active playlist configuration found to validate request',
);
} }
final targetHost = targetUrl.host.toLowerCase(); final targetHost = targetUrl.host.toLowerCase();
@@ -142,9 +143,11 @@ class ProxyHandler {
if (targetHost != allowedHost) { if (targetHost != allowedHost) {
print( print(
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)'); '[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
);
return Response.forbidden( return Response.forbidden(
'Access to this domain is forbidden by policy'); 'Access to this domain is forbidden by policy',
);
} }
} }
@@ -215,7 +218,8 @@ class ProxyHandler {
targetUrl?.path.endsWith('.jpg') == true) { targetUrl?.path.endsWith('.jpg') == true) {
return Response.ok( return Response.ok(
base64Decode( base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
),
headers: {'content-type': 'image/png'}, headers: {'content-type': 'image/png'},
); );
} }
+26 -13
View File
@@ -36,19 +36,25 @@ class SeasonPassesApi {
final streamUrl = data['stream_url'] as String?; final streamUrl = data['stream_url'] as String?;
if (showTitle == null || showTitle.isEmpty) { if (showTitle == null || showTitle.isEmpty) {
return Response(400, return Response(
400,
body: json.encode({'error': 'show_title est requis'}), body: json.encode({'error': 'show_title est requis'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
if (channelId == null || channelId.isEmpty) { if (channelId == null || channelId.isEmpty) {
return Response(400, return Response(
400,
body: json.encode({'error': 'channel_id est requis'}), body: json.encode({'error': 'channel_id est requis'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
if (streamUrl == null || streamUrl.isEmpty) { if (streamUrl == null || streamUrl.isEmpty) {
return Response(400, return Response(
400,
body: json.encode({'error': 'stream_url est requis'}), body: json.encode({'error': 'stream_url est requis'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
// Récupérer l'utilisateur depuis le contexte // Récupérer l'utilisateur depuis le contexte
@@ -57,13 +63,18 @@ class SeasonPassesApi {
// Vérifier si un season pass identique existe déjà // Vérifier si un season pass identique existe déjà
final existing = _db.getAllSeasonPasses(); final existing = _db.getAllSeasonPasses();
final duplicate = existing.any((p) => final duplicate = existing.any(
(p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() && (p) =>
p['channel_id'] == channelId); (p['show_title'] as String).toLowerCase() ==
showTitle.toLowerCase() &&
p['channel_id'] == channelId,
);
if (duplicate) { if (duplicate) {
return Response(409, return Response(
409,
body: json.encode({'error': 'Un Season Pass identique existe déjà'}), body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} }
final pass = _db.createSeasonPass( final pass = _db.createSeasonPass(
@@ -74,9 +85,11 @@ class SeasonPassesApi {
); );
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId'); print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
return Response(201, return Response(
201,
body: json.encode(pass), body: json.encode(pass),
headers: {'Content-Type': 'application/json'}); headers: {'Content-Type': 'application/json'},
);
} catch (e) { } catch (e) {
return Response.internalServerError( return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}), body: json.encode({'error': 'Erreur: $e'}),
+18 -8
View File
@@ -153,11 +153,14 @@ Handler createLiveStreamHandler(
'-i', targetUrl, '-i', targetUrl,
// Video // Video
if (useNvidiaGpu) ...[ if (useNvidiaGpu) ...[
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune',
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate 'hq', // Better quality preset for NVENC
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize',
'16000k', // Quality native / high bitrate
'-g', '50', '-g', '50',
] else ...[ ] else ...[
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency', // Better detail than ultrafast '-c:v', 'libx264', '-preset', 'medium', '-tune',
'zerolatency', // Better detail than ultrafast
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k', '-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-g', '50', '-g', '50',
], ],
@@ -201,15 +204,19 @@ Handler createLiveStreamHandler(
retries++; retries++;
} }
if (!file.existsSync()) if (!file.existsSync()) {
return Response.internalServerError(body: 'Timeout starting live HLS'); return Response.internalServerError(body: 'Timeout starting live HLS');
}
return Response.ok(file.openRead(), headers: { return Response.ok(
file.openRead(),
headers: {
'Content-Type': 'application/vnd.apple.mpegurl', 'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
}); },
);
}); });
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback) // Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
@@ -245,11 +252,14 @@ Handler createLiveStreamHandler(
final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found'); if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: { return Response.ok(
file.openRead(),
headers: {
'Content-Type': 'video/mp2t', 'Content-Type': 'video/mp2t',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=60', 'Cache-Control': 'max-age=60',
}); },
);
}); });
return router; return router;
+16 -5
View File
@@ -469,7 +469,7 @@ class AppDatabase {
startTime.toIso8601String(), startTime.toIso8601String(),
endTime.toIso8601String(), endTime.toIso8601String(),
now, now,
now now,
], ],
); );
@@ -489,7 +489,8 @@ class AppDatabase {
/// Lister tous les enregistrements (pour le Scheduler et l'admin) /// Lister tous les enregistrements (pour le Scheduler et l'admin)
List<Recording> getAllRecordings() { List<Recording> getAllRecordings() {
final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC'); final result =
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
return result.map((row) => Recording.fromMap(row)).toList(); return result.map((row) => Recording.fromMap(row)).toList();
} }
@@ -510,7 +511,8 @@ class AppDatabase {
} }
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement /// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) { void updateRecordingStatus(String id, String status,
{String? filePath, String? errorReason}) {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
_db.execute( _db.execute(
''' '''
@@ -542,12 +544,21 @@ class AppDatabase {
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, now], [id, userId, showTitle, channelId, streamUrl, now],
); );
return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now}; return {
'id': id,
'user_id': userId,
'show_title': showTitle,
'channel_id': channelId,
'stream_url': streamUrl,
'enabled': 1,
'created_at': now
};
} }
/// Lister tous les Season Passes /// Lister tous les Season Passes
List<Map<String, dynamic>> getAllSeasonPasses() { List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC'); final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
return result.map((r) => Map<String, dynamic>.from(r)).toList(); return result.map((r) => Map<String, dynamic>.from(r)).toList();
} }
+6 -4
View File
@@ -46,7 +46,7 @@ void main(List<String> args) async {
// Injecter la config playlist dans le scheduler pour les Season Passes // Injecter la config playlist dans le scheduler pour les Season Passes
// (on prend la playlist du premier utilisateur disponible) // (on prend la playlist du premier utilisateur disponible)
Future<void> _injectPlaylistToScheduler() async { Future<void> injectPlaylistToScheduler() async {
final users = db.getAllUsers(); final users = db.getAllUsers();
if (users.isNotEmpty) { if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id); final playlists = db.getPlaylists(users[0].id);
@@ -61,7 +61,7 @@ void main(List<String> args) async {
} }
// Injecter après 5s pour attendre l'initialisation complète // Injecter après 5s pour attendre l'initialisation complète
Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler); Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
// Initialize Streaming Subsystem // Initialize Streaming Subsystem
await initStreaming(); await initStreaming();
@@ -84,14 +84,16 @@ void main(List<String> args) async {
if (users.isNotEmpty) { if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id); final playlists = db.getPlaylists(users[0].id);
print( print(
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists'); '[getPlaylist] User ${users[0].username} has ${playlists.length} playlists',
);
if (playlists.isNotEmpty) playlist = playlists.first; if (playlists.isNotEmpty) playlist = playlists.first;
} }
} }
if (playlist != null) { if (playlist != null) {
print( print(
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})'); '[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
);
return PlaylistConfig( return PlaylistConfig(
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
+83 -40
View File
@@ -25,11 +25,14 @@ class RecordingScheduler {
RecordingScheduler(this._db); RecordingScheduler(this._db);
void start() { void start() {
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV'); print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat) // Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
_timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings()); _timer = Timer.periodic(
const Duration(seconds: 10), (_) => _checkAndRunRecordings());
// Season Passes : vérifier toutes les 4 heures // Season Passes : vérifier toutes les 4 heures
_seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses()); _seasonPassTimer =
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
// Lancer une première vérification immédiatement // Lancer une première vérification immédiatement
_checkAndRunRecordings(); _checkAndRunRecordings();
// Vérification initiale des season passes après 30s (laisser le serveur démarrer) // Vérification initiale des season passes après 30s (laisser le serveur démarrer)
@@ -52,12 +55,14 @@ class RecordingScheduler {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final recordings = _db.getAllRecordings(); final recordings = _db.getAllRecordings();
print('[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}'); print(
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter // Si un enregistrement est en cours, vérifier s'il doit s'arrêter
if (_currentRecording != null) { if (_currentRecording != null) {
if (now.isAfter(_currentRecording!.endTime.toUtc())) { if (now.isAfter(_currentRecording!.endTime.toUtc())) {
print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); print(
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
await _stopCurrentRecording(); await _stopCurrentRecording();
} }
} }
@@ -65,32 +70,41 @@ class RecordingScheduler {
// Rechercher les enregistrements planifiés // Rechercher les enregistrements planifiés
for (final recording in recordings) { for (final recording in recordings) {
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
if (recording.status == 'recording' && _currentRecording?.id != recording.id) { if (recording.status == 'recording' &&
print('[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}'); _currentRecording?.id != recording.id) {
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur'); print(
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Interruption inattendue du serveur');
continue; continue;
} }
if (recording.status == 'scheduled') { if (recording.status == 'scheduled') {
final startUtc = recording.startTime.toUtc(); final startUtc = recording.startTime.toUtc();
final endUtc = recording.endTime.toUtc(); final endUtc = recording.endTime.toUtc();
print('[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}'); print(
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini // Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
if (now.isAfter(startUtc) && now.isBefore(endUtc)) { if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
if (_currentRecording != null) { if (_currentRecording != null) {
print('[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours.'); '[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Un autre enregistrement était déjà en cours.');
continue; continue;
} }
print('[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***'); print(
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
await _startRecording(recording); await _startRecording(recording);
} }
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
if (now.isAfter(endUtc)) { if (now.isAfter(endUtc)) {
print('[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Heure de fin dépassée avant le démarrage'); '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Heure de fin dépassée avant le démarrage');
} }
} }
} }
@@ -111,7 +125,8 @@ class RecordingScheduler {
final password = playlistPassword; final password = playlistPassword;
if (dns == null || username == null || password == null) { if (dns == null || username == null || password == null) {
print('[SeasonPass] Config playlist non disponible, vérification annulée'); print(
'[SeasonPass] Config playlist non disponible, vérification annulée');
return; return;
} }
@@ -128,29 +143,36 @@ class RecordingScheduler {
final url = '$dns/player_api.php?username=$username&password=$password' final url = '$dns/player_api.php?username=$username&password=$password'
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48'; '&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode != 200) continue; if (response.statusCode != 200) continue;
final raw = json.decode(response.body); final raw = json.decode(response.body);
final listings = (raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? []; final listings =
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
for (final item in listings) { for (final item in listings) {
String title = item['title'] as String? ?? ''; String title = item['title'] as String? ?? '';
try { title = utf8.decode(base64Decode(title)); } catch (_) {} try {
title = utf8.decode(base64Decode(title));
} catch (_) {}
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle) // Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue; if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
// Parser les heures de début/fin // Parser les heures de début/fin
final startStr = item['start'] as String? ?? ''; final startStr = item['start'] as String? ?? '';
final endStr = item['stop'] as String? ?? item['end'] as String? ?? ''; final endStr =
item['stop'] as String? ?? item['end'] as String? ?? '';
if (startStr.isEmpty || endStr.isEmpty) continue; if (startStr.isEmpty || endStr.isEmpty) continue;
DateTime startTime, endTime; DateTime startTime, endTime;
try { try {
startTime = DateTime.parse(startStr).toUtc(); startTime = DateTime.parse(startStr).toUtc();
endTime = DateTime.parse(endStr).toUtc(); endTime = DateTime.parse(endStr).toUtc();
} catch (_) { continue; } } catch (_) {
continue;
}
// Ne pas créer pour les programmes déjà terminés // Ne pas créer pour les programmes déjà terminés
if (endTime.isBefore(DateTime.now().toUtc())) continue; if (endTime.isBefore(DateTime.now().toUtc())) continue;
@@ -170,7 +192,8 @@ class RecordingScheduler {
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
); );
print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
} }
} catch (e) { } catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e'); print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
@@ -195,8 +218,13 @@ class RecordingScheduler {
await _checkDiskSpaceAndRotate(recordingsDir); await _checkDiskSpaceAndRotate(recordingsDir);
// Génération d'un nom de fichier unique et sûr // Génération d'un nom de fichier unique et sûr
final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); final safeTitle =
final dateStr = recording.startTime.toUtc().toIso8601String().replaceAll(':', '').split('.')[0]; recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
final dateStr = recording.startTime
.toUtc()
.toIso8601String()
.replaceAll(':', '')
.split('.')[0];
final fileName = '${safeTitle}_$dateStr.mkv'; final fileName = '${safeTitle}_$dateStr.mkv';
final filePath = p.join(recordingsDir.path, fileName); final filePath = p.join(recordingsDir.path, fileName);
final logFilePath = filePath.replaceAll('.mkv', '.log'); final logFilePath = filePath.replaceAll('.mkv', '.log');
@@ -210,10 +238,13 @@ class RecordingScheduler {
final args = [ final args = [
'-y', '-y',
'-i', streamUrl, '-i',
'-c', 'copy', streamUrl,
'-t', '${recording.endTime.difference(DateTime.now()).inSeconds}', '-c',
filePath 'copy',
'-t',
'${recording.endTime.difference(DateTime.now()).inSeconds}',
filePath,
]; ];
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception) // Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
@@ -225,9 +256,11 @@ class RecordingScheduler {
logSink.writeln('Destination: $filePath'); logSink.writeln('Destination: $filePath');
} catch (logError) { } catch (logError) {
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire) // Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
print('[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError'); print(
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
// On note la filePath dans la DB quand même pour pouvoir retourner le statut // On note la filePath dans la DB quand même pour pouvoir retourner le statut
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); _db.updateRecordingStatus(recording.id, 'recording',
filePath: filePath);
} }
// Trouver ffmpeg // Trouver ffmpeg
@@ -251,15 +284,19 @@ class RecordingScheduler {
// Écouter la fin du processus FFmpeg de manière asynchrone // Écouter la fin du processus FFmpeg de manière asynchrone
_ffmpegProcess!.exitCode.then((exitCode) async { _ffmpegProcess!.exitCode.then((exitCode) async {
if (_currentRecording?.id == recording.id) { if (_currentRecording?.id == recording.id) {
logSink?.writeln('\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode'); logSink?.writeln(
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
await logSink?.close(); await logSink?.close();
if (exitCode == 0 || exitCode == 255) { if (exitCode == 0 || exitCode == 255) {
print('[RecordingScheduler] Enregistrement terminé: ${recording.title}'); print(
'[RecordingScheduler] Enregistrement terminé: ${recording.title}');
_db.updateRecordingStatus(recording.id, 'completed'); _db.updateRecordingStatus(recording.id, 'completed');
} else { } else {
print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}'); print(
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); '[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
_db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
} }
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -267,11 +304,11 @@ class RecordingScheduler {
await logSink?.close(); await logSink?.close();
} }
}); });
} catch (e, st) { } catch (e, st) {
// Attraper TOUTES les exceptions pour éviter de crasher le serveur // Attraper TOUTES les exceptions pour éviter de crasher le serveur
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st'); print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur au lancement: $e'); _db.updateRecordingStatus(recording.id, 'failed',
errorReason: 'Erreur au lancement: $e');
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
} }
@@ -280,7 +317,8 @@ class RecordingScheduler {
/// Arrêter un enregistrement en cours (appelé depuis l'API) /// Arrêter un enregistrement en cours (appelé depuis l'API)
Future<bool> stopRecording(String id) async { Future<bool> stopRecording(String id) async {
if (_ffmpegProcess != null && _currentRecording?.id == id) { if (_ffmpegProcess != null && _currentRecording?.id == id) {
print('[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}'); print(
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
_ffmpegProcess!.kill(ProcessSignal.sigterm); _ffmpegProcess!.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed'); _db.updateRecordingStatus(id, 'completed');
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -305,12 +343,16 @@ class RecordingScheduler {
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint. // et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers // Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
try { try {
final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
final files = dir.listSync().whereType<File>().toList(); final files = dir.listSync().whereType<File>().toList();
if (files.length > maxFiles) { if (files.length > maxFiles) {
print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements'); print(
files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent '[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
files.sort((a, b) => a
.statSync()
.modified
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
// Supprimer les plus anciens pour revenir sous la limite // Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles); final filesToDelete = files.take(files.length - maxFiles);
@@ -319,7 +361,8 @@ class RecordingScheduler {
} }
} }
} catch (e) { } catch (e) {
print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e'); print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
} }
} }
} }
+17 -12
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../database/database.dart'; import '../database/database.dart';
import '../models/recording.dart';
/// 🎯 SIMPLIFIED Recording System - One job: Record streams /// 🎯 SIMPLIFIED Recording System - One job: Record streams
class SimpleRecorder { class SimpleRecorder {
@@ -56,9 +55,12 @@ class SimpleRecorder {
try { try {
final ffmpeg = await Process.start('ffmpeg', [ final ffmpeg = await Process.start('ffmpeg', [
'-y', '-y',
'-i', streamUrl, '-i',
'-c', 'copy', streamUrl,
'-t', '${duration.inSeconds}', '-c',
'copy',
'-t',
'${duration.inSeconds}',
filepath, filepath,
]); ]);
@@ -78,7 +80,8 @@ class SimpleRecorder {
_active.remove(channelId); _active.remove(channelId);
print('✅ Recording done: $title'); print('✅ Recording done: $title');
}).catchError((e) { }).catchError((e) {
db.updateRecordingStatus(recordingId, 'failed', errorReason: 'FFmpeg error'); db.updateRecordingStatus(recordingId, 'failed',
errorReason: 'FFmpeg error');
_active.remove(channelId); _active.remove(channelId);
}); });
@@ -167,21 +170,22 @@ class SimpleRecorder {
/// Get active recordings /// Get active recordings
List<Map<String, dynamic>> getActive() { List<Map<String, dynamic>> getActive() {
return _active.entries.map((e) => { return _active.entries
.map(
(e) => {
'id': e.value.id, 'id': e.value.id,
'channel': e.key, 'channel': e.key,
'filepath': e.value.filepath, 'filepath': e.value.filepath,
'endsAt': e.value.endTime.toIso8601String(), 'endsAt': e.value.endTime.toIso8601String(),
}).toList(); },
)
.toList();
} }
/// Cleanup old recordings (keep last 20) /// Cleanup old recordings (keep last 20)
Future<void> cleanupOld({int keepCount = 20}) async { Future<void> cleanupOld({int keepCount = 20}) async {
final dir = Directory(recordingsDir); final dir = Directory(recordingsDir);
final files = dir final files = dir.listSync().whereType<File>().toList()
.listSync()
.whereType<File>()
.toList()
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); ..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
if (files.length > keepCount) { if (files.length > keepCount) {
@@ -194,7 +198,8 @@ class SimpleRecorder {
String _safeName(String title, String id) { String _safeName(String title, String id) {
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_'); final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0]; final timestamp =
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
return '${safe}_$timestamp.mkv'; return '${safe}_$timestamp.mkv';
} }
+7 -3
View File
@@ -1,6 +1,7 @@
/// Application optimization configuration /// Application optimization configuration
/// ///
/// This file contains performance tuning settings for XtremFlow /// This file contains performance tuning settings for XtremFlow
library;
class OptimizationConfig { class OptimizationConfig {
// ============================================ // ============================================
@@ -26,7 +27,8 @@ class OptimizationConfig {
static const bool enableHlsCompression = true; static const bool enableHlsCompression = true;
/// FFmpeg preset for live transcoding /// FFmpeg preset for live transcoding
static const String ffmpegLivePreset = 'medium'; // ultrafast, fast, medium, slow static const String ffmpegLivePreset =
'medium'; // ultrafast, fast, medium, slow
/// FFmpeg preset for VOD transcoding /// FFmpeg preset for VOD transcoding
static const String ffmpegVodPreset = 'medium'; static const String ffmpegVodPreset = 'medium';
@@ -285,13 +287,15 @@ class RuntimeOptimizations {
// Detect low storage // Detect low storage
if (storageFreeMb < 1000) { if (storageFreeMb < 1000) {
print( print(
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.'); '[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.',
);
} }
// High performance device detection // High performance device detection
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000; hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
print('[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB'); print(
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
} }
/// Get dynamic cache size based on available memory /// Get dynamic cache size based on available memory
+11 -10
View File
@@ -2,9 +2,7 @@
/// ///
/// Supports multi-bitrate HLS playlists for adaptive streaming /// Supports multi-bitrate HLS playlists for adaptive streaming
/// with fallback options for poor network conditions /// with fallback options for poor network conditions
library;
import 'dart:async';
import 'dart:io';
/// Represents a video quality level for adaptive bitrate streaming /// Represents a video quality level for adaptive bitrate streaming
class QualityLevel { class QualityLevel {
@@ -159,8 +157,8 @@ class QualityProfiles {
/// Bandwidth detector for adaptive bitrate selection /// Bandwidth detector for adaptive bitrate selection
class BandwidthDetector { class BandwidthDetector {
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
int _totalBytesLastPeriod = 0; final int _totalBytesLastPeriod = 0;
Duration _sampleWindow = const Duration(seconds: 10); final Duration _sampleWindow = const Duration(seconds: 10);
/// Record bytes downloaded at current time /// Record bytes downloaded at current time
void recordBytes(int bytes) { void recordBytes(int bytes) {
@@ -177,7 +175,9 @@ class BandwidthDetector {
if (_samples.isEmpty) return 0; if (_samples.isEmpty) return 0;
int totalBytes = 0; int totalBytes = 0;
_samples.values.forEach((bytes) => totalBytes += bytes); for (var bytes in _samples.values) {
totalBytes += bytes;
}
final timeSpan = DateTime.now() final timeSpan = DateTime.now()
.difference( .difference(
@@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator {
// Variant streams (quality levels) // Variant streams (quality levels)
for (final quality in qualityLevels) { for (final quality in qualityLevels) {
buffer buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}'); 'RESOLUTION=${quality.width}x${quality.height}');
buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8'); buffer
.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
} }
return buffer.toString(); return buffer.toString();
@@ -317,7 +317,8 @@ class QualitySelector {
// Only switch if significantly different from current // Only switch if significantly different from current
final overhead = _currentQuality.bitrateBps * 1.2; final overhead = _currentQuality.bitrateBps * 1.2;
if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) { if (bandwidthBps > overhead ||
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
final bestQuality = final bestQuality =
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles); BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
setQuality(bestQuality); setQuality(bestQuality);
+7 -8
View File
@@ -52,7 +52,7 @@ class CacheService {
/// Get cached data /// Get cached data
T? get<T>(String key) { T? get<T>(String key) {
final entry = _cache[key] as CacheEntry?; final entry = _cache[key];
if (entry != null && !entry.isExpired()) { if (entry != null && !entry.isExpired()) {
entry.markAccess(); entry.markAccess();
return entry.data as T; return entry.data as T;
@@ -71,7 +71,8 @@ class CacheService {
'totalSize': _totalSizeBytes, 'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024), 'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length, 'itemCount': _cache.length,
'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length, 'averageSizePerItem':
_cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
}; };
/// Clear all cache /// Clear all cache
@@ -96,7 +97,7 @@ class CacheService {
/// Trim cache by removing least recently used entries /// Trim cache by removing least recently used entries
void _trimCacheIfNeeded() { void _trimCacheIfNeeded() {
final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024; const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
if (_totalSizeBytes > maxSize) { if (_totalSizeBytes > maxSize) {
// Remove least recently accessed entries // Remove least recently accessed entries
final sorted = _cache.entries.toList() final sorted = _cache.entries.toList()
@@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider<ImageCacheManager>((ref) {
}); });
/// Provider for cache statistics /// Provider for cache statistics
final cacheStatsProvider = StateNotifierProvider< final cacheStatsProvider =
CacheStatsNotifier, StateNotifierProvider<CacheStatsNotifier, Map<String, dynamic>>((ref) {
Map<String, dynamic>>((ref) {
final cacheService = ref.watch(cacheServiceProvider); final cacheService = ref.watch(cacheServiceProvider);
return CacheStatsNotifier(cacheService); return CacheStatsNotifier(cacheService);
}); });
@@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider<
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> { class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
final CacheService _cacheService; final CacheService _cacheService;
CacheStatsNotifier(this._cacheService) CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats());
: super(_cacheService.getStats());
void refreshStats() { void refreshStats() {
state = _cacheService.getStats(); state = _cacheService.getStats();
+7 -3
View File
@@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor {
@override @override
Future<void> onError( Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async { DioException err,
ErrorInterceptorHandler handler,
) async {
// Only retry on timeout or connection errors // Only retry on timeout or connection errors
if (err.type == DioExceptionType.connectionTimeout || if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout) { err.type == DioExceptionType.receiveTimeout) {
@@ -248,9 +250,11 @@ final networkConfigProvider =
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> { class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
NetworkConfigNotifier() NetworkConfigNotifier()
: super(const NetworkConfig( : super(
const NetworkConfig(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18', userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
)); ),
);
void setProxy(String? proxyUrl) { void setProxy(String? proxyUrl) {
state = state.copyWith(proxyUrl: proxyUrl); state = state.copyWith(proxyUrl: proxyUrl);
+7 -9
View File
@@ -23,10 +23,7 @@ class StreamingMetrics {
}); });
double get totalPlaybackSeconds => double get totalPlaybackSeconds =>
(endTime ?? DateTime.now()) (endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
.difference(startTime)
.inSeconds
.toDouble();
double get qualityScore { double get qualityScore {
// Score based on metrics // Score based on metrics
@@ -67,7 +64,7 @@ class StreamingOptimizer {
StreamController.broadcast(); StreamController.broadcast();
late Timer _metricsTimer; late Timer _metricsTimer;
int _segmentStartTime = 0; final int _segmentStartTime = 0;
int _totalBytesDownloaded = 0; int _totalBytesDownloaded = 0;
int _rebufferCount = 0; int _rebufferCount = 0;
int _lastBufferNotificationTime = 0; int _lastBufferNotificationTime = 0;
@@ -98,8 +95,7 @@ class StreamingOptimizer {
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000; final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
_currentMetrics = StreamingMetrics( _currentMetrics = StreamingMetrics(
averageNetworkBitrate: _totalBytesDownloaded > 0 averageNetworkBitrate: _totalBytesDownloaded > 0
? (_totalBytesDownloaded * 8 / ? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
_currentMetrics.totalPlaybackSeconds)
: 0, : 0,
currentNetworkBitrate: bandwidthBps, currentNetworkBitrate: bandwidthBps,
bufferDurationMs: _currentMetrics.bufferDurationMs, bufferDurationMs: _currentMetrics.bufferDurationMs,
@@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
late StreamingOptimizer _optimizer; late StreamingOptimizer _optimizer;
StreamingOptimizerNotifier(this._qualitySelector) StreamingOptimizerNotifier(this._qualitySelector)
: super(StreamingMetrics( : super(
StreamingMetrics(
averageNetworkBitrate: 0, averageNetworkBitrate: 0,
currentNetworkBitrate: 0, currentNetworkBitrate: 0,
bufferDurationMs: 0, bufferDurationMs: 0,
segmentDownloadTimeMs: 0, segmentDownloadTimeMs: 0,
rebufferCount: 0, rebufferCount: 0,
startTime: DateTime.now(), startTime: DateTime.now(),
)) { ),
) {
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector); _optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
// Listen to metrics updates // Listen to metrics updates
+5 -6
View File
@@ -114,7 +114,6 @@ class AppTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( return baseTheme.copyWith(
useMaterial3: true,
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background, canvasColor: AppColors.background,
@@ -387,7 +386,7 @@ class AppTheme {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg), borderRadius: BorderRadius.circular(radiusLg),
side: BorderSide( side: const BorderSide(
color: AppColors.border, color: AppColors.border,
width: 1, width: 1,
), ),
@@ -400,7 +399,7 @@ class AppTheme {
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl), borderRadius: BorderRadius.circular(radiusXl),
side: BorderSide( side: const BorderSide(
color: AppColors.border, color: AppColors.border,
width: 1, width: 1,
), ),
@@ -498,14 +497,14 @@ class AppTheme {
), ),
// ============ NAVIGATION RAIL (Side menu) ============ // ============ NAVIGATION RAIL (Side menu) ============
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: const NavigationRailThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
elevation: 0, elevation: 0,
selectedIconTheme: const IconThemeData( selectedIconTheme: IconThemeData(
color: AppColors.primary, color: AppColors.primary,
size: 28, size: 28,
), ),
unselectedIconTheme: const IconThemeData( unselectedIconTheme: IconThemeData(
color: AppColors.textSecondary, color: AppColors.textSecondary,
size: 24, size: 24,
), ),
+13 -9
View File
@@ -151,11 +151,11 @@ class _GlassCardState extends State<GlassCard>
duration: AppTheme.durationMd, duration: AppTheme.durationMd,
); );
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04) _scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8) _opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
} }
@override @override
@@ -189,12 +189,16 @@ class _GlassCardState extends State<GlassCard>
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
onTapDown: (_) => _onPointerDown(PointerDownEvent( onTapDown: (_) => _onPointerDown(
const PointerDownEvent(
position: Offset.zero, position: Offset.zero,
)), ),
onTapUp: (_) => _onPointerUp(PointerUpEvent( ),
onTapUp: (_) => _onPointerUp(
const PointerUpEvent(
position: Offset.zero, position: Offset.zero,
)), ),
),
onTapCancel: () => _controller.reverse(), onTapCancel: () => _controller.reverse(),
child: ScaleTransition( child: ScaleTransition(
scale: _scaleAnimation, scale: _scaleAnimation,
@@ -211,7 +215,7 @@ class _GlassCardState extends State<GlassCard>
content = Stack( content = Stack(
children: [ children: [
Opacity(opacity: 0.5, child: content), Opacity(opacity: 0.5, child: content),
Center( const Center(
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
+8 -6
View File
@@ -285,7 +285,8 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.play_arrow, color: AppColors.textPrimary), const Icon(Icons.play_arrow,
color: AppColors.textPrimary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Play', 'Play',
@@ -320,8 +321,10 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.info_outline, const Icon(
color: AppColors.textPrimary), Icons.info_outline,
color: AppColors.textPrimary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'More Info', 'More Info',
@@ -361,9 +364,8 @@ class _HeroCarouselState extends State<HeroCarousel>
width: _currentIndex == index ? 32 : 8, width: _currentIndex == index ? 32 : 8,
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _currentIndex == index color:
? AppColors.primary _currentIndex == index ? AppColors.primary : AppColors.border,
: AppColors.border,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
+3 -3
View File
@@ -17,15 +17,15 @@ class ThemedLoading extends StatelessWidget {
], ],
), ),
), ),
child: Center( child: const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator( CircularProgressIndicator(
color: AppColors.primary, color: AppColors.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
const SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'Loading...', 'Loading...',
style: TextStyle( style: TextStyle(
+2 -2
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Apple TV Modern Channel Grid /// Apple TV Modern Channel Grid
@@ -57,7 +56,8 @@ class TvChannelGrid extends StatelessWidget {
// Compute item width, clamped to avoid negative/NaN values. // Compute item width, clamped to avoid negative/NaN values.
final totalSpacing = horizontalSpacing * (columnCount - 1); final totalSpacing = horizontalSpacing * (columnCount - 1);
final availableWidth = screenWidth - horizontalPadding - totalSpacing; final availableWidth = screenWidth - horizontalPadding - totalSpacing;
final itemWidth = (availableWidth / columnCount).clamp(1.0, double.infinity); final itemWidth =
(availableWidth / columnCount).clamp(1.0, double.infinity);
return SingleChildScrollView( return SingleChildScrollView(
controller: scrollController, controller: scrollController,
+11 -9
View File
@@ -149,19 +149,19 @@ class _TvModernCardState extends State<TvModernCard>
right: 0, right: 0,
child: Container( child: Container(
height: 3, height: 3,
decoration: BoxDecoration( decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg), bottom: Radius.circular(AppTheme.radiusLg),
), ),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg), bottom: Radius.circular(AppTheme.radiusLg),
), ),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: widget.progress, value: widget.progress,
backgroundColor: AppColors.surfaceVariant, backgroundColor: AppColors.surfaceVariant,
valueColor: AlwaysStoppedAnimation( valueColor: const AlwaysStoppedAnimation(
AppColors.primary, AppColors.primary,
), ),
minHeight: 3, minHeight: 3,
@@ -171,7 +171,8 @@ class _TvModernCardState extends State<TvModernCard>
), ),
// Action buttons (on hover) // Action buttons (on hover)
if (_isHovered && (widget.onPlayTap != null || widget.onMoreTap != null)) if (_isHovered &&
(widget.onPlayTap != null || widget.onMoreTap != null))
Center( Center(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -182,7 +183,8 @@ class _TvModernCardState extends State<TvModernCard>
label: 'Play', label: 'Play',
onTap: widget.onPlayTap!, onTap: widget.onPlayTap!,
), ),
if (widget.onPlayTap != null && widget.onMoreTap != null) if (widget.onPlayTap != null &&
widget.onMoreTap != null)
const SizedBox(width: 16), const SizedBox(width: 16),
if (widget.onMoreTap != null) if (widget.onMoreTap != null)
_buildActionButton( _buildActionButton(
@@ -219,7 +221,7 @@ class _TvModernCardState extends State<TvModernCard>
Row( Row(
children: [ children: [
if (widget.rating != null) ...[ if (widget.rating != null) ...[
Icon( const Icon(
Icons.star, Icons.star,
size: 14, size: 14,
color: AppColors.ratingGold, color: AppColors.ratingGold,
@@ -258,7 +260,7 @@ class _TvModernCardState extends State<TvModernCard>
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) { if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
return Container( return Container(
color: AppColors.surface, color: AppColors.surface,
child: Center( child: const Center(
child: Icon( child: Icon(
Icons.image_outlined, Icons.image_outlined,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -273,7 +275,7 @@ class _TvModernCardState extends State<TvModernCard>
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: AppColors.shimmer, color: AppColors.shimmer,
child: Center( child: const Center(
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
@@ -286,7 +288,7 @@ class _TvModernCardState extends State<TvModernCard>
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
color: AppColors.surface, color: AppColors.surface,
child: Center( child: const Center(
child: Icon( child: Icon(
Icons.broken_image_outlined, Icons.broken_image_outlined,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -41,12 +41,15 @@ class RecommendationService {
// Only show if watched more than 5 seconds ago and less than 95% complete // Only show if watched more than 5 seconds ago and less than 95% complete
if (lastPosition > 5 && lastPosition < 95) { if (lastPosition > 5 && lastPosition < 95) {
recommendations.add(Recommendation( recommendations.add(
Recommendation(
item: item, item: item,
type: RecommendationType.continueWatching, type: RecommendationType.continueWatching,
score: 100.0 - lastPosition, // Score higher if recently started score: 100.0 - lastPosition, // Score higher if recently started
reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%', reason:
)); 'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
),
);
} }
} }
} }
@@ -66,12 +69,14 @@ class RecommendationService {
for (final item in allContent) { for (final item in allContent) {
final views = viewCounts[item.streamId.toString()] ?? 0; final views = viewCounts[item.streamId.toString()] ?? 0;
if (views > 0) { if (views > 0) {
recommendations.add(Recommendation( recommendations.add(
Recommendation(
item: item, item: item,
type: RecommendationType.trending, type: RecommendationType.trending,
score: views.toDouble(), score: views.toDouble(),
reason: '$views views - Trending now', reason: '$views views - Trending now',
)); ),
);
} }
} }
@@ -96,12 +101,14 @@ class RecommendationService {
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000)) .difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
.inDays; .inDays;
recommendations.add(Recommendation( recommendations.add(
Recommendation(
item: item, item: item,
type: RecommendationType.recentlyAdded, type: RecommendationType.recentlyAdded,
score: (20 - daysAgo).toDouble().clamp(0, 100), score: (20 - daysAgo).toDouble().clamp(0, 100),
reason: 'Added $daysAgo days ago', reason: 'Added $daysAgo days ago',
)); ),
);
} }
return recommendations.take(10).toList(); return recommendations.take(10).toList();
@@ -125,12 +132,14 @@ class RecommendationService {
// Recommend similar content // Recommend similar content
for (final item in allContent) { for (final item in allContent) {
if (watchedCategories.contains(item.categoryId)) { if (watchedCategories.contains(item.categoryId)) {
recommendations.add(Recommendation( recommendations.add(
Recommendation(
item: item, item: item,
type: RecommendationType.forYou, type: RecommendationType.forYou,
score: 75.0, score: 75.0,
reason: 'Based on your interests', reason: 'Based on your interests',
)); ),
);
} }
} }
@@ -146,12 +155,14 @@ class RecommendationService {
final rating = double.tryParse(i.rating ?? '0') ?? 0.0; final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
return rating >= 7.5; return rating >= 7.5;
}).take(5)) { }).take(5)) {
recommendations.add(Recommendation( recommendations.add(
Recommendation(
item: item, item: item,
type: RecommendationType.topRated, type: RecommendationType.topRated,
score: double.tryParse(item.rating ?? '0') ?? 0.0, score: double.tryParse(item.rating ?? '0') ?? 0.0,
reason: 'Highly rated - ${item.rating}/10', reason: 'Highly rated - ${item.rating}/10',
)); ),
);
} }
// Remove duplicates and limit // Remove duplicates and limit
@@ -169,9 +180,8 @@ class RecommendationService {
} }
/// Provider for watch history /// Provider for watch history
final watchHistoryProvider = StateNotifierProvider< final watchHistoryProvider =
WatchHistoryNotifier, StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
Map<String, dynamic>>((ref) {
return WatchHistoryNotifier(); return WatchHistoryNotifier();
}); });
@@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
} }
/// Provider for view counts/trending /// Provider for view counts/trending
final trendingProvider = StateNotifierProvider< final trendingProvider =
TrendingNotifier, StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
Map<String, int>>((ref) {
return TrendingNotifier(); return TrendingNotifier();
}); });
@@ -213,8 +222,8 @@ class TrendingNotifier extends StateNotifier<Map<String, int>> {
} }
/// Provider for recommendations /// Provider for recommendations
final recommendationsProvider = final recommendationsProvider = FutureProvider.family<List<Recommendation>,
FutureProvider.family<List<Recommendation>, (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>( (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
(ref, params) async { (ref, params) async {
final (playlist, watchHistory, trending, allContent) = params; final (playlist, watchHistory, trending, allContent) = params;
@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
class _DashboardScreenState extends ConsumerState<DashboardScreen> { class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
final List<String> _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings']; final List<String> _tabs = [
'Live TV',
'Movies',
'Series',
'Recordings',
'Settings'
];
final List<IconData> _icons = [ final List<IconData> _icons = [
Icons.live_tv_rounded, Icons.live_tv_rounded,
Icons.movie_rounded, Icons.movie_rounded,
+22 -13
View File
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
final Playlist playlist; final Playlist playlist;
const EpgGridScreen({ const EpgGridScreen({
Key? key, super.key,
required this.playlist, required this.playlist,
}) : super(key: key); });
@override @override
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState(); ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
} }
List<EpgProgram> _parseEpgPrograms( List<EpgProgram> _parseEpgPrograms(
Map<String, dynamic> epgData, String channelId) { Map<String, dynamic> epgData,
String channelId,
) {
final programs = <EpgProgram>[]; final programs = <EpgProgram>[];
final now = DateTime.now(); final now = DateTime.now();
@@ -130,7 +132,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
final start = _parseEpgTime(startStr); final start = _parseEpgTime(startStr);
final end = _parseEpgTime(endStr); final end = _parseEpgTime(endStr);
programs.add(EpgProgram( programs.add(
EpgProgram(
id: '${channelId}_${start.millisecondsSinceEpoch}', id: '${channelId}_${start.millisecondsSinceEpoch}',
channelId: channelId, channelId: channelId,
title: title, title: title,
@@ -138,9 +141,10 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
startTime: start, startTime: start,
endTime: end, endTime: end,
isNow: now.isAfter(start) && now.isBefore(end), isNow: now.isAfter(start) && now.isBefore(end),
isNext: start.isAfter(now) && isNext:
start.difference(now).inMinutes < 60, start.isAfter(now) && start.difference(now).inMinutes < 60,
)); ),
);
} }
} catch (e) { } catch (e) {
continue; continue;
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(day, style: const TextStyle(fontSize: 11)), Text(day, style: const TextStyle(fontSize: 11)),
Text(dateNum, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), Text(dateNum,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.bold)),
], ],
), ),
); );
@@ -250,7 +256,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
child: Center( child: Center(
child: Text( child: Text(
'$hour:00', '$hour:00',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.bold),
), ),
), ),
); );
@@ -335,7 +342,8 @@ class _EpgChannelRow extends StatelessWidget {
// Programs // Programs
...programs.map((program) { ...programs.map((program) {
final duration = program.duration.inMinutes; final duration = program.duration.inMinutes;
final width = (duration / 60 * 100).clamp(40, double.infinity).toDouble(); final width =
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
return GestureDetector( return GestureDetector(
onTap: () => onProgramTap(program), onTap: () => onProgramTap(program),
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
value: program.progress / 100, value: program.progress / 100,
minHeight: 3, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
valueColor: valueColor: const AlwaysStoppedAnimation(Colors.red),
const AlwaysStoppedAnimation(Colors.red),
), ),
), ),
), ),
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
program.description.isEmpty ? 'No description available' : program.description, program.description.isEmpty
? 'No description available'
: program.description,
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
+29 -33
View File
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
// ... (existing imports) // ... (existing imports)
// ... (existing imports) // ... (existing imports)
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/glass_container.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../widgets/epg_overlay.dart';
enum StreamType { live, vod, series } enum StreamType { live, vod, series }
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
StreamSubscription? _messageSubscription; StreamSubscription? _messageSubscription;
final String _aspectRatio = 'contain'; final String _aspectRatio = 'contain';
bool _isSeeking = false; bool _isSeeking = false;
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString(); late final String _viewIdPrefix =
DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player'; String _viewId = 'iptv-player';
String? _currentStreamUrl; String? _currentStreamUrl;
String _statusMessage = 'Loading...'; String _statusMessage = 'Loading...';
@@ -156,11 +154,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Cache buster to force reload of updated player.html // Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch; final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; final streamTypeParam =
final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html'); widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag // Inject Turbo-Start flag
var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster'; var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
if (startTimeOverride != null) { if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride'; playerSrc += '&t=$startTimeOverride';
@@ -305,26 +307,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
(doc as dynamic).mozFullScreenElement != null; (doc as dynamic).mozFullScreenElement != null;
if (isFullscreen) { if (isFullscreen) {
if (doc.exitFullscreen != null) {
doc.exitFullscreen(); doc.exitFullscreen();
} else if ((doc as dynamic).webkitExitFullscreen != null) {
(doc as dynamic).webkitExitFullscreen();
} else if ((doc as dynamic).mozCancelFullScreen != null) {
(doc as dynamic).mozCancelFullScreen();
}
} else { } else {
final element = doc.documentElement; final element = doc.documentElement;
if (element != null) { if (element != null) {
if (element.requestFullscreen != null) {
element.requestFullscreen(); element.requestFullscreen();
} else if ((element as dynamic).webkitRequestFullscreen != null) {
(element as dynamic).webkitRequestFullscreen();
} else if ((element as dynamic).mozRequestFullScreen != null) {
(element as dynamic).mozRequestFullScreen();
} else {
// Fallback: Message the player if browser API fails
_sendMessage({'type': 'request_fullscreen'});
}
} }
} }
} catch (e) { } catch (e) {
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [ children: [
Text( Text(
widget.channels != null widget.channels != null
? (widget.channels![_currentIndex] ? (widget
.channels![
_currentIndex]
.name ?? .name ??
widget.channels![ widget
.channels![
_currentIndex] _currentIndex]
.title ?? .title ??
'Unknown') 'Unknown')
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Unified Bottom Bar - EPG + Controls in one bar // Unified Bottom Bar - EPG + Controls in one bar
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
bottom: _showControls ? 24 : -150, // More offset for stacked layout bottom: _showControls
? 24
: -150, // More offset for stacked layout
left: 16, left: 16,
right: 16, right: 16,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isSmallScreen = constraints.maxWidth < 600; final isSmallScreen =
constraints.maxWidth < 600;
return Container( return Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isSmallScreen ? 16 : 20, horizontal: isSmallScreen ? 16 : 20,
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.background.withOpacity(0.3), color: AppColors.background
.withOpacity(0.3),
blurRadius: 10, blurRadius: 10,
spreadRadius: 2, spreadRadius: 2,
), ),
], ],
border: Border.all( border: Border.all(
color: AppColors.textPrimary.withOpacity(0.15), color: AppColors.textPrimary
.withOpacity(0.15),
), ),
), ),
child: isSmallScreen child: isSmallScreen
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// EPG Info (top in mobile) // EPG Info (top in mobile)
_buildInlineEpg(), _buildInlineEpg(),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(color: Colors.white10), const Divider(
color: Colors.white10),
const SizedBox(height: 8), const SizedBox(height: 8),
// Control Buttons // Control Buttons
Row( Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.spaceEvenly, MainAxisAlignment.spaceEvenly,
children: _buildControlButtons( children: _buildControlButtons(
isSmallScreen), isSmallScreen,
),
), ),
], ],
) )
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: _buildControlButtons( children: _buildControlButtons(
isSmallScreen), isSmallScreen,
),
), ),
], ],
), ),
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onTap: _toggleMute, onTap: _toggleMute,
size: buttonSize, size: buttonSize,
), ),
]; ];
} }
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}, },
); );
} }
} }
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
actions: [ actions: [
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
IconButton( IconButton(
icon: const Icon(Icons.admin_panel_settings, icon: const Icon(
color: AppColors.textSecondary), Icons.admin_panel_settings,
color: AppColors.textSecondary,
),
onPressed: () => context.go('/admin'), onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel', tooltip: 'Admin Panel',
), ),
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
!constraints.maxHeight.isFinite || !constraints.maxHeight.isFinite ||
constraints.maxHeight <= 0) { constraints.maxHeight <= 0) {
return const Center( return const Center(
child: child: CircularProgressIndicator(color: AppColors.primary),
CircularProgressIndicator(color: AppColors.primary)); );
} }
return playlistsAsync.when( return playlistsAsync.when(
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, const Icon(
size: 64, color: AppColors.error), Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Error loading playlists', 'Error loading playlists',
@@ -120,12 +120,15 @@ class DownloadService {
/// Get download progress /// Get download progress
double getDownloadProgress(String id) { double getDownloadProgress(String id) {
final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask( final task = _downloads.firstWhere(
(t) => t.id == id,
orElse: () => DownloadTask(
id: id, id: id,
title: '', title: '',
url: '', url: '',
filePath: '', filePath: '',
)); ),
);
return task.progress; return task.progress;
} }
@@ -151,13 +154,12 @@ class DownloadService {
if (available < 1024 * 1024 * 1024) { if (available < 1024 * 1024 * 1024) {
// Less than 1GB free // Less than 1GB free
final dir = Directory(_downloadDir); final dir = Directory(_downloadDir);
final files = dir.listSync() final files = dir.listSync().whereType<File>().toList();
.whereType<File>()
.toList();
// Sort by modification time, delete oldest first // Sort by modification time, delete oldest first
files.sort((a, b) => files.sort(
a.statSync().modified.compareTo(b.statSync().modified)); (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
);
for (final file in files.take(files.length ~/ 3)) { for (final file in files.take(files.length ~/ 3)) {
try { try {
@@ -216,7 +218,8 @@ class DownloadService {
} }
// Check if paused or cancelled // Check if paused or cancelled
final currentTask = _downloads.firstWhere((t) => t.id == task.id, orElse: () => task); final currentTask =
_downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
if (currentTask.status == DownloadStatus.paused || if (currentTask.status == DownloadStatus.paused ||
!_activeDownloads.contains(task.id)) { !_activeDownloads.contains(task.id)) {
break; break;
@@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier<List<DownloadTask>> {
} }
void clearCompleted() { void clearCompleted() {
state = state state = state.where((d) => d.status != DownloadStatus.completed).toList();
.where((d) => d.status != DownloadStatus.completed)
.toList();
} }
} }
@@ -42,7 +42,8 @@ class SubtitleEntry {
/// Service for handling subtitles /// Service for handling subtitles
class SubtitleService { class SubtitleService {
static const _srtPattern = r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)'; static const _srtPattern =
r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
/// Parse SRT subtitle content /// Parse SRT subtitle content
static List<SubtitleEntry> parseSrt(String content) { static List<SubtitleEntry> parseSrt(String content) {
@@ -67,12 +68,14 @@ class SubtitleService {
final startTime = _parseTime(times[0].trim()); final startTime = _parseTime(times[0].trim());
final endTime = _parseTime(times[1].trim()); final endTime = _parseTime(times[1].trim());
entries.add(SubtitleEntry( entries.add(
SubtitleEntry(
index: index, index: index,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
text: text, text: text,
)); ),
);
} catch (e) { } catch (e) {
continue; continue;
} }
@@ -103,12 +106,14 @@ class SubtitleService {
final startTime = _parseWebVttTime(times[0].trim()); final startTime = _parseWebVttTime(times[0].trim());
final endTime = _parseWebVttTime(times[1].trim()); final endTime = _parseWebVttTime(times[1].trim());
entries.add(SubtitleEntry( entries.add(
SubtitleEntry(
index: entries.length, index: entries.length,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
text: text, text: text,
)); ),
);
} catch (e) { } catch (e) {
continue; continue;
} }
@@ -120,7 +125,8 @@ class SubtitleService {
/// Get subtitle at specific time /// Get subtitle at specific time
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) { static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
for (final entry in entries) { for (final entry in entries) {
if (time.compareTo(entry.startTime) >= 0 && time.compareTo(entry.endTime) <= 0) { if (time.compareTo(entry.startTime) >= 0 &&
time.compareTo(entry.endTime) <= 0) {
return entry.text; return entry.text;
} }
} }
+34 -17
View File
@@ -183,11 +183,14 @@ class XtreamService {
/// Get all live TV channels grouped by category /// Get all live TV channels grouped by category
/// ///
/// Returns a Map where key is category name and value is list of channels /// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels({bool refresh = false}) async { Future<Map<String, List<Channel>>> getLiveChannels(
{bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -226,7 +229,8 @@ class XtreamService {
return groupedChannels; return groupedChannels;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<Channel>('live_channels', Channel.fromJson); final cached =
_loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch live channels: $e'); throw Exception('Failed to fetch live channels: $e');
} }
@@ -236,17 +240,21 @@ class XtreamService {
if (!kIsWeb) return; if (!kIsWeb) return;
try { try {
final jsonStr = jsonEncode(data); final jsonStr = jsonEncode(data);
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr; html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
jsonStr;
} catch (e) { } catch (e) {
debugPrint('Error saving to local cache ($key): $e'); debugPrint('Error saving to local cache ($key): $e');
} }
} }
Map<String, List<T>> _loadFromLocalCache<T>( Map<String, List<T>> _loadFromLocalCache<T>(
String key, T Function(Map<String, dynamic>) fromJson) { String key,
T Function(Map<String, dynamic>) fromJson,
) {
if (!kIsWeb) return {}; if (!kIsWeb) return {};
try { try {
final jsonStr = html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key']; final jsonStr =
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
if (jsonStr == null) return {}; if (jsonStr == null) return {};
final data = jsonDecode(jsonStr) as Map<String, dynamic>; final data = jsonDecode(jsonStr) as Map<String, dynamic>;
@@ -254,9 +262,8 @@ class XtreamService {
data.forEach((cat, items) { data.forEach((cat, items) {
if (items is List) { if (items is List) {
result[cat] = items result[cat] =
.map((i) => fromJson(i as Map<String, dynamic>)) items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
.toList();
} }
}); });
return result; return result;
@@ -302,7 +309,9 @@ class XtreamService {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -342,7 +351,8 @@ class XtreamService {
return groupedVods; return groupedVods;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson); final cached =
_loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch VOD items: $e'); throw Exception('Failed to fetch VOD items: $e');
} }
@@ -384,7 +394,9 @@ class XtreamService {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra() ? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra(); : _cacheOptions.toExtra();
try { try {
@@ -424,15 +436,18 @@ class XtreamService {
return groupedSeries; return groupedSeries;
} catch (e) { } catch (e) {
// Try to return from local cache if network fails // Try to return from local cache if network fails
final cached = _loadFromLocalCache<Series>('series_items', Series.fromJson); final cached =
_loadFromLocalCache<Series>('series_items', Series.fromJson);
if (cached.isNotEmpty) return cached; if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch series: $e'); throw Exception('Failed to fetch series: $e');
} }
} }
/// Get movies with pagination support /// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated( Future<List<xm.Movie>> getMoviesPaginated({
{int offset = 0, int limit = 100}) async { int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -511,8 +526,10 @@ class XtreamService {
} }
/// Get series with pagination support (returns flat list) /// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated( Future<List<xm.Series>> getSeriesPaginated({
{int offset = 0, int limit = 100}) async { int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
+6 -2
View File
@@ -79,7 +79,9 @@ class _ChannelCardState extends State<ChannelCard>
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: ScaleTransition( child: ScaleTransition(
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0), scale: widget.isActive
? _pulseAnimation
: const AlwaysStoppedAnimation(1.0),
child: AnimatedContainer( child: AnimatedContainer(
duration: AppTheme.durationMd, duration: AppTheme.durationMd,
curve: AppTheme.curveDefault, curve: AppTheme.curveDefault,
@@ -90,7 +92,9 @@ class _ChannelCardState extends State<ChannelCard>
border: Border.all( border: Border.all(
color: widget.isActive color: widget.isActive
? AppColors.primary ? AppColors.primary
: (_isHovered ? AppColors.border : AppColors.border.withOpacity(0.5)), : (_isHovered
? AppColors.border
: AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5, width: widget.isActive ? 2.5 : 1.5,
), ),
borderRadius: BorderRadius.circular(AppTheme.radiusMd), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
@@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const ContinueWatchingWidget({ const ContinueWatchingWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
ConsumerState<ContinueWatchingWidget> createState() => ConsumerState<ContinueWatchingWidget> createState() =>
@@ -68,8 +68,8 @@ class _ContinueWatchingWidgetState
const SizedBox(width: 16), const SizedBox(width: 16),
...continueWatching.take(5).map((rec) { ...continueWatching.take(5).map((rec) {
final item = rec.item; final item = rec.item;
final percentage = watchHistory[item.streamId.toString()] final percentage =
is Map watchHistory[item.streamId.toString()] is Map
? (watchHistory[item.streamId.toString()] ? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ?? as Map)['percentage'] as double? ??
0.0 0.0
@@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget {
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: Colors.grey[800], color: Colors.grey[800],
), ),
errorWidget: (context, url, error) => errorWidget: (context, url, error) => Icon(
Icon(Icons.image_not_supported, Icons.image_not_supported,
color: Colors.grey[600]), color: Colors.grey[600],
),
), ),
), ),
@@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const TrendingWidget({ const TrendingWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( const Row(
children: [ children: [
const Icon(Icons.local_fire_department, Icon(
color: Colors.red, size: 24), Icons.local_fire_department,
const SizedBox(width: 8), color: Colors.red,
const Text( size: 24,
),
SizedBox(width: 8),
Text(
'Trending Now', 'Trending Now',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget {
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: Colors.grey[800], color: Colors.grey[800],
), ),
errorWidget: (context, url, error) => errorWidget: (context, url, error) => Icon(
Icon(Icons.image_not_supported, Icons.image_not_supported,
color: Colors.grey[600]), color: Colors.grey[600],
),
), ),
), ),
), ),
@@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget {
if (item.rating != null) if (item.rating != null)
Row( Row(
children: [ children: [
const Icon(Icons.star, const Icon(
color: Colors.amber, size: 12), Icons.star,
color: Colors.amber,
size: 12,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
item.rating ?? '0', item.rating ?? '0',
@@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget {
final VoidCallback? onItemTap; final VoidCallback? onItemTap;
const RecentlyAddedWidget({ const RecentlyAddedWidget({
Key? key, super.key,
required this.playlist, required this.playlist,
required this.content, required this.content,
this.onItemTap, this.onItemTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( const Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text( child: Text(
'Recently Added', 'Recently Added',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
+9 -4
View File
@@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity( color: Colors.white.withOpacity(
0.05), // Subtle transparent background 0.05,
), // Subtle transparent background
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(12)), top: Radius.circular(12),
),
), ),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Footer // Footer
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8), horizontal: 12,
vertical: 8,
),
color: Colors.black54, color: Colors.black54,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
EpgEntry.parseDateTime(entry.start); EpgEntry.parseDateTime(entry.start);
final end = final end =
EpgEntry.parseDateTime(entry.end); EpgEntry.parseDateTime(entry.end);
if (start == null || end == null) if (start == null || end == null) {
return false; return false;
}
return now.isAfter(start) && return now.isAfter(start) &&
now.isBefore(end); now.isBefore(end);
} catch (e) { } catch (e) {
@@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget {
final VoidCallback onClose; final VoidCallback onClose;
const QualitySelectorWidget({ const QualitySelectorWidget({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
required this.onClose, required this.onClose,
}) : super(key: key); });
@override @override
ConsumerState<QualitySelectorWidget> createState() => ConsumerState<QualitySelectorWidget> createState() =>
@@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
// Header // Header
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: Colors.white24), bottom: BorderSide(color: Colors.white24),
), ),
@@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
const EdgeInsets.symmetric(horizontal: 24, vertical: 8), const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue) ? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(Icons.radio_button_unchecked, : const Icon(
color: Colors.white30), Icons.radio_button_unchecked,
color: Colors.white30,
),
title: Text( title: Text(
quality.label, quality.label,
style: TextStyle( style: TextStyle(
color: isSelected ? Colors.blue : Colors.white, color: isSelected ? Colors.blue : Colors.white,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
), ),
), ),
subtitle: Text( subtitle: Text(
@@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
top: BorderSide(color: Colors.white24), top: BorderSide(color: Colors.white24),
), ),
@@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget {
final VoidCallback onTap; final VoidCallback onTap;
const QualityIndicator({ const QualityIndicator({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
required this.onTap, required this.onTap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget {
final QualitySelector qualitySelector; final QualitySelector qualitySelector;
const BandwidthMonitor({ const BandwidthMonitor({
Key? key, super.key,
required this.qualitySelector, required this.qualitySelector,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
+327 -139
View File
@@ -5,10 +5,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/widgets/glass_container.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/xtream_service.dart';
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ENTRÉE — Onglet "TV & Enregistrements" avec 3 sous-onglets // ENTRÉE — Onglet "TV & Enregistrements" avec 3 sous-onglets
@@ -48,15 +46,20 @@ class _RecordingsTabState extends State<RecordingsTab>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Row(
children: [
const Icon(Icons.tv, color: Colors.white, size: 28), const Icon(Icons.tv, color: Colors.white, size: 28),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('TV & Enregistrements', Text(
'TV & Enregistrements',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white)), color: Colors.white,
]), ),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
TabBar( TabBar(
controller: _tabController, controller: _tabController,
@@ -65,16 +68,20 @@ class _RecordingsTabState extends State<RecordingsTab>
indicatorColor: Colors.redAccent, indicatorColor: Colors.redAccent,
indicatorWeight: 3, indicatorWeight: 3,
labelStyle: GoogleFonts.outfit( labelStyle: GoogleFonts.outfit(
fontWeight: FontWeight.w600, fontSize: 14), fontWeight: FontWeight.w600,
fontSize: 14,
),
unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14), unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14),
tabs: const [ tabs: const [
Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'), Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'),
Tab( Tab(
icon: Icon(Icons.fiber_manual_record, size: 18), icon: Icon(Icons.fiber_manual_record, size: 18),
text: 'Enregistrements'), text: 'Enregistrements',
),
Tab( Tab(
icon: Icon(Icons.repeat, size: 18), icon: Icon(Icons.repeat, size: 18),
text: 'Season Passes'), text: 'Season Passes',
),
], ],
), ),
], ],
@@ -130,7 +137,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
void initState() { void initState() {
super.initState(); super.initState();
_searchCtrl.addListener( _searchCtrl.addListener(
() => setState(() => _searchQuery = _searchCtrl.text.toLowerCase())); () => setState(() => _searchQuery = _searchCtrl.text.toLowerCase()),
);
} }
@override @override
@@ -191,9 +199,11 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
error: (e, _) => Center(child: Text('Erreur: $e')), error: (e, _) => Center(child: Text('Erreur: $e')),
data: (groupedChannels) { data: (groupedChannels) {
final categories = groupedChannels.keys final categories = groupedChannels.keys
.where((cat) => .where(
(cat) =>
settings.liveTvKeywords.isEmpty || settings.liveTvKeywords.isEmpty ||
settings.matchesLiveTvFilter(cat)) settings.matchesLiveTvFilter(cat),
)
.toList(); .toList();
// Initial category selection // Initial category selection
@@ -217,12 +227,15 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
width: 180, width: 180,
child: Column( child: Column(
children: [ children: [
Text('GROUPES', Text(
'GROUPES',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white38, color: Colors.white38,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1.2)), letterSpacing: 1.2,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -238,7 +251,9 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
}), }),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10), horizontal: 12,
vertical: 10,
),
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
@@ -255,7 +270,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
fontSize: 12, fontSize: 12,
fontWeight: isSelected fontWeight: isSelected
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal), : FontWeight.normal,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -273,17 +289,21 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
// ── Colonne 2 : CHAÎNES ── // ── Colonne 2 : CHAÎNES ──
SizedBox( SizedBox(
width: 250, width: 250,
child: Column(children: [ child: Column(
children: [
// Barre de recherche // Barre de recherche
TextField( TextField(
controller: _searchCtrl, controller: _searchCtrl,
style: const TextStyle(color: Colors.white, fontSize: 13), style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: hintStyle: const TextStyle(
const TextStyle(color: Colors.white38, fontSize: 13), color: Colors.white38, fontSize: 13),
prefixIcon: const Icon(Icons.search, prefixIcon: const Icon(
color: Colors.white38, size: 18), Icons.search,
color: Colors.white38,
size: 18,
),
filled: true, filled: true,
fillColor: Colors.white.withOpacity(0.07), fillColor: Colors.white.withOpacity(0.07),
contentPadding: const EdgeInsets.symmetric(vertical: 8), contentPadding: const EdgeInsets.symmetric(vertical: 8),
@@ -296,10 +316,15 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: visibleChannels.isEmpty child: visibleChannels.isEmpty
? Center( ? const Center(
child: Text('Aucune chaîne', child: Text(
'Aucune chaîne',
style: TextStyle( style: TextStyle(
color: Colors.white24, fontSize: 12))) color: Colors.white24,
fontSize: 12,
),
),
)
: ListView.builder( : ListView.builder(
itemCount: visibleChannels.length, itemCount: visibleChannels.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
@@ -312,7 +337,9 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 3), margin: const EdgeInsets.only(bottom: 3),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 8), horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Colors.redAccent.withOpacity(0.2) ? Colors.redAccent.withOpacity(0.2)
@@ -324,18 +351,26 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
: Colors.transparent, : Colors.transparent,
), ),
), ),
child: Row(children: [ child: Row(
children: [
ch.streamIcon.isNotEmpty ch.streamIcon.isNotEmpty
? Image.network(ch.streamIcon, ? Image.network(
ch.streamIcon,
width: 24, width: 24,
height: 16, height: 16,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, const Icon(
Icons.tv,
color: Colors.white24, color: Colors.white24,
size: 16)) size: 16,
: const Icon(Icons.tv, ),
color: Colors.white24, size: 16), )
: const Icon(
Icons.tv,
color: Colors.white24,
size: 16,
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
@@ -350,13 +385,15 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
]), ],
),
), ),
); );
}, },
), ),
), ),
]), ],
),
), ),
const VerticalDivider(width: 32, color: Colors.white10), const VerticalDivider(width: 32, color: Colors.white10),
@@ -368,14 +405,19 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.tv_off, const Icon(
size: 64, color: Color(0x1FFFFFFF)), Icons.tv_off,
size: 64,
color: Color(0x1FFFFFFF),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Sélectionnez une chaîne\npour voir son guide des programmes', 'Sélectionnez une chaîne\npour voir son guide des programmes',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white38, fontSize: 15), color: Colors.white38,
fontSize: 15,
),
), ),
], ],
), ),
@@ -383,7 +425,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Row(
children: [
Text( Text(
_selectedChannel!.name, _selectedChannel!.name,
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
@@ -394,24 +437,30 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.refresh, icon: const Icon(
color: Colors.white54, size: 18), Icons.refresh,
color: Colors.white54,
size: 18,
),
tooltip: 'Recharger l\'EPG', tooltip: 'Recharger l\'EPG',
onPressed: () => _loadEpg(_selectedChannel!), onPressed: () => _loadEpg(_selectedChannel!),
), ),
]), ],
),
const Divider(color: Colors.white12), const Divider(color: Colors.white12),
Expanded( Expanded(
child: _loadingEpg child: _loadingEpg
? const Center( ? const Center(
child: CircularProgressIndicator()) child: CircularProgressIndicator(),
)
: _epgError.isNotEmpty : _epgError.isNotEmpty
? Center( ? Center(
child: Text( child: Text(
'Erreur EPG: $_epgError', 'Erreur EPG: $_epgError',
style: const TextStyle( style: const TextStyle(
color: Colors.redAccent, color: Colors.redAccent,
fontSize: 12), fontSize: 12,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
) )
@@ -421,7 +470,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
'Aucun programme EPG disponible\npour cette chaîne', 'Aucun programme EPG disponible\npour cette chaîne',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white38), color: Colors.white38,
),
), ),
) )
: ListView.builder( : ListView.builder(
@@ -499,28 +549,43 @@ class _ProgrammeCard extends StatelessWidget {
} }
void _showConfirm( void _showConfirm(
BuildContext context, String title, DateTime start, DateTime end) { BuildContext context,
String title,
DateTime start,
DateTime end,
) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: const Color(0xFF1E1E2E),
title: Row(children: [ title: Row(
const Icon(Icons.fiber_manual_record, children: [
color: Colors.redAccent, size: 20), const Icon(
Icons.fiber_manual_record,
color: Colors.redAccent,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text('Enregistrer', child: Text(
style: GoogleFonts.outfit(color: Colors.white))), 'Enregistrer',
]), style: GoogleFonts.outfit(color: Colors.white),
),
),
],
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title.isEmpty ? channel.name : title, Text(
title.isEmpty ? channel.name : title,
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16)), fontSize: 16,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${_fmt(_start)} → ${_fmt(_end)}', '${_fmt(_start)} → ${_fmt(_end)}',
@@ -540,13 +605,18 @@ class _ProgrammeCard extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')), child: const Text('Annuler'),
),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () async { onPressed: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
await _saveRecording( await _saveRecording(
context, title.isEmpty ? channel.name : title, start, end); context,
title.isEmpty ? channel.name : title,
start,
end,
);
}, },
child: const Text('🔴 Enregistrer'), child: const Text('🔴 Enregistrer'),
), ),
@@ -556,7 +626,11 @@ class _ProgrammeCard extends StatelessWidget {
} }
Future<void> _saveRecording( Future<void> _saveRecording(
BuildContext context, String title, DateTime start, DateTime end) async { BuildContext context,
String title,
DateTime start,
DateTime end,
) async {
try { try {
final response = await http.post( final response = await http.post(
Uri.parse('/api/recordings'), Uri.parse('/api/recordings'),
@@ -570,14 +644,18 @@ class _ProgrammeCard extends StatelessWidget {
}), }),
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(response.statusCode == 200 SnackBar(
content: Text(
response.statusCode == 200
? '✅ "$title" planifié !' ? '✅ "$title" planifié !'
: '❌ Erreur: ${response.body}'), : '❌ Erreur: ${response.body}',
),
backgroundColor: response.statusCode == 200 backgroundColor: response.statusCode == 200
? Colors.green.shade800 ? Colors.green.shade800
: Colors.red.shade800, : Colors.red.shade800,
)); ),
);
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
@@ -620,12 +698,16 @@ class _ProgrammeCard extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 5, vertical: 2), const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.redAccent, color: Colors.redAccent,
borderRadius: BorderRadius.circular(4)), borderRadius: BorderRadius.circular(4),
child: Text('LIVE', ),
child: Text(
'LIVE',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white, color: Colors.white,
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold,
),
),
) )
else else
Text( Text(
@@ -664,8 +746,11 @@ class _ProgrammeCard extends StatelessWidget {
: null, : null,
trailing: !isPast trailing: !isPast
? IconButton( ? IconButton(
icon: const Icon(Icons.fiber_manual_record, icon: const Icon(
color: Colors.redAccent, size: 20), Icons.fiber_manual_record,
color: Colors.redAccent,
size: 20,
),
tooltip: 'Enregistrer ce programme', tooltip: 'Enregistrer ce programme',
onPressed: () => _record(context), onPressed: () => _record(context),
) )
@@ -747,23 +832,29 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: const Color(0xFF1E1E2E),
title: Text(title, title: Text(
style: GoogleFonts.outfit(color: Colors.white, fontSize: 14)), title,
style: GoogleFonts.outfit(color: Colors.white, fontSize: 14),
),
content: SizedBox( content: SizedBox(
width: 500, width: 500,
height: 300, height: 300,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Text(content, child: Text(
content,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 11, fontSize: 11,
fontFamily: 'monospace')), fontFamily: 'monospace',
),
),
), ),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Fermer')) child: const Text('Fermer'),
),
], ],
), ),
); );
@@ -807,31 +898,44 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
Row(mainAxisAlignment: MainAxisAlignment.end, children: [ Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton( IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70), icon: const Icon(Icons.refresh, color: Colors.white70),
onPressed: _fetchRecordings, onPressed: _fetchRecordings,
tooltip: 'Rafraîchir', tooltip: 'Rafraîchir',
), ),
]), ],
),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
child: Text(_error!, child: Text(
style: const TextStyle(color: Colors.redAccent))) _error!,
style: const TextStyle(color: Colors.redAccent),
),
)
: _recordings.isEmpty : _recordings.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.videocam_off, const Icon(
size: 64, color: Color(0x1FFFFFFF)), Icons.videocam_off,
size: 64,
color: Color(0x1FFFFFFF),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Aucun enregistrement', Text(
'Aucun enregistrement',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white38, fontSize: 16)), color: Colors.white38,
fontSize: 16,
),
),
], ],
), ),
) )
@@ -848,11 +952,14 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
color: Colors.white.withOpacity(0.05), color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Colors.white.withOpacity(0.1)), color: Colors.white.withOpacity(0.1),
),
), ),
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8), horizontal: 16,
vertical: 8,
),
leading: Icon( leading: Icon(
status == 'recording' status == 'recording'
? Icons.fiber_manual_record ? Icons.fiber_manual_record
@@ -860,10 +967,13 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
color: color, color: color,
size: 28, size: 28,
), ),
title: Text(rec['title'] ?? '—', title: Text(
rec['title'] ?? '—',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w600)), fontWeight: FontWeight.w600,
),
),
subtitle: Column( subtitle: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
@@ -872,13 +982,17 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}', '${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}',
style: const TextStyle( style: const TextStyle(
color: Colors.white54, color: Colors.white54,
fontSize: 12), fontSize: 12,
),
), ),
if (rec['error_reason'] != null) if (rec['error_reason'] != null)
Text('⚠ ${rec['error_reason']}', Text(
'⚠ ${rec['error_reason']}',
style: const TextStyle( style: const TextStyle(
color: Colors.orangeAccent, color: Colors.orangeAccent,
fontSize: 11)), fontSize: 11,
),
),
], ],
), ),
trailing: Row( trailing: Row(
@@ -886,41 +1000,57 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4), horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.15), color: color.withOpacity(0.15),
borderRadius: borderRadius:
BorderRadius.circular(20), BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: color.withOpacity(0.4)), color: color.withOpacity(0.4),
), ),
child: Text(_statusLabel(status), ),
child: Text(
_statusLabel(status),
style: TextStyle( style: TextStyle(
color: color, color: color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 11)), fontSize: 11,
),
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
if (status == 'recording') if (status == 'recording')
IconButton( IconButton(
icon: const Icon(Icons.stop_circle, icon: const Icon(
color: Colors.redAccent), Icons.stop_circle,
color: Colors.redAccent,
),
tooltip: 'Arrêter', tooltip: 'Arrêter',
onPressed: () => _stopRecording( onPressed: () => _stopRecording(
rec['id'], rec['title'] ?? ''), rec['id'],
rec['title'] ?? '',
),
), ),
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.description_outlined, Icons.description_outlined,
color: Colors.blueAccent, color: Colors.blueAccent,
size: 20), size: 20,
),
tooltip: 'Logs', tooltip: 'Logs',
onPressed: () => _showLogs( onPressed: () => _showLogs(
rec['id'], rec['title'] ?? ''), rec['id'],
rec['title'] ?? '',
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline, icon: const Icon(
color: Colors.white38, size: 20), Icons.delete_outline,
color: Colors.white38,
size: 20,
),
tooltip: 'Supprimer', tooltip: 'Supprimer',
onPressed: () => onPressed: () =>
_deleteRecording(rec['id']), _deleteRecording(rec['id']),
@@ -980,7 +1110,8 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
_loadPasses(); _loadPasses();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: const Color(0xFF1E1E2E),
title: Row(children: [ title: Row(
children: [
const Icon(Icons.repeat, color: Colors.purpleAccent), const Icon(Icons.repeat, color: Colors.purpleAccent),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Nouveau Season Pass', Text(
style: GoogleFonts.outfit(color: Colors.white)), 'Nouveau Season Pass',
]), style: GoogleFonts.outfit(color: Colors.white),
),
],
),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text( Text(
'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.', 'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.',
style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildField( _buildField(
titleCtrl, 'Titre de l\'émission (ex: Champions League)'), titleCtrl,
'Titre de l\'émission (ex: Champions League)',
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildField(channelCtrl, 'Channel ID (ex: 554021)'), _buildField(channelCtrl, 'Channel ID (ex: 554021)'),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildField( _buildField(
urlCtrl, 'stream_url (optionnel, ex: /api/live/554021.ts)'), urlCtrl,
]), 'stream_url (optionnel, ex: /api/live/554021.ts)',
),
],
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')), child: const Text('Annuler'),
),
ElevatedButton( ElevatedButton(
style: style:
ElevatedButton.styleFrom(backgroundColor: Colors.purpleAccent), ElevatedButton.styleFrom(backgroundColor: Colors.purpleAccent),
@@ -1034,15 +1177,20 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Uri.parse('/api/season-passes'), Uri.parse('/api/season-passes'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: json.encode( body: json.encode(
{'show_title': t, 'channel_id': c, 'stream_url': u}), {'show_title': t, 'channel_id': c, 'stream_url': u},
),
); );
_loadPasses(); _loadPasses();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(r.statusCode == 201 SnackBar(
content: Text(
r.statusCode == 201
? '✅ Season Pass créé !' ? '✅ Season Pass créé !'
: '❌ ${r.body}'), : '❌ ${r.body}',
)); ),
),
);
} }
}, },
child: const Text('Créer'), child: const Text('Créer'),
@@ -1060,9 +1208,11 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
labelText: label, labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 12), labelStyle: const TextStyle(color: Colors.white54, fontSize: 12),
enabledBorder: const UnderlineInputBorder( enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24)), borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: const UnderlineInputBorder( focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.purpleAccent)), borderSide: BorderSide(color: Colors.purpleAccent),
),
), ),
); );
} }
@@ -1071,15 +1221,19 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(children: [ child: Column(
children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Enregistrements automatiques', Text(
style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13)), 'Enregistrements automatiques',
style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13),
),
ElevatedButton.icon( ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.purpleAccent), backgroundColor: Colors.purpleAccent,
),
icon: const Icon(Icons.add, size: 18), icon: const Icon(Icons.add, size: 18),
label: const Text('Nouveau'), label: const Text('Nouveau'),
onPressed: _showCreate, onPressed: _showCreate,
@@ -1094,18 +1248,25 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)), border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)),
), ),
child: Row(children: [ child: Row(
const Icon(Icons.info_outline, children: [
color: Colors.purpleAccent, size: 18), const Icon(
Icons.info_outline,
color: Colors.purpleAccent,
size: 18,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.', 'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.purpleAccent.shade100, fontSize: 12), color: Colors.purpleAccent.shade100,
fontSize: 12,
), ),
), ),
]), ),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
@@ -1116,18 +1277,28 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.repeat, const Icon(
size: 64, color: Color(0x1FFFFFFF)), Icons.repeat,
size: 64,
color: Color(0x1FFFFFFF),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Aucun Season Pass actif', Text(
'Aucun Season Pass actif',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white38, fontSize: 16)), color: Colors.white38,
fontSize: 16,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Créez-en un pour enregistrer automatiquement vos émissions préférées', 'Créez-en un pour enregistrer automatiquement vos émissions préférées',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white24, fontSize: 12), color: Colors.white24,
textAlign: TextAlign.center), fontSize: 12,
),
textAlign: TextAlign.center,
),
], ],
), ),
) )
@@ -1141,29 +1312,45 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
color: Colors.purpleAccent.withOpacity(0.08), color: Colors.purpleAccent.withOpacity(0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Colors.purpleAccent.withOpacity(0.2)), color: Colors.purpleAccent.withOpacity(0.2),
),
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.repeat, leading: const Icon(
color: Colors.purpleAccent), Icons.repeat,
title: Text(p['show_title'] ?? '—', color: Colors.purpleAccent,
),
title: Text(
p['show_title'] ?? '—',
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w600)), fontWeight: FontWeight.w600,
),
),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Chaîne : ${p['channel_id']}', Text(
'Chaîne : ${p['channel_id']}',
style: const TextStyle( style: const TextStyle(
color: Colors.white54, fontSize: 11)), color: Colors.white54,
Text('Flux : ${p['stream_url']}', fontSize: 11,
),
),
Text(
'Flux : ${p['stream_url']}',
style: const TextStyle( style: const TextStyle(
color: Colors.white38, fontSize: 10)), color: Colors.white38,
fontSize: 10,
),
),
], ],
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete_outline, icon: const Icon(
color: Colors.white38), Icons.delete_outline,
color: Colors.white38,
),
tooltip: 'Supprimer', tooltip: 'Supprimer',
onPressed: () => onPressed: () =>
_deletePass(p['id'], p['show_title'] ?? ''), _deletePass(p['id'], p['show_title'] ?? ''),
@@ -1173,7 +1360,8 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
}, },
), ),
), ),
]), ],
),
); );
} }
} }
@@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
value: _durationMinutes, value: _durationMinutes,
dropdownColor: Colors.grey[800], dropdownColor: Colors.grey[800],
items: [30, 60, 120, 240, 480] items: [30, 60, 120, 240, 480]
.map((m) => DropdownMenuItem( .map(
(m) => DropdownMenuItem(
value: m, value: m,
child: Text('$m min'), child: Text('$m min'),
)) ),
)
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
if (v != null) { if (v != null) {
@@ -65,8 +65,11 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, const Icon(
size: 48, color: AppColors.error), Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.inter()), Text('Error loading playlists', style: GoogleFonts.inter()),
TextButton( TextButton(
@@ -82,12 +85,16 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.playlist_remove, const Icon(
size: 48, color: AppColors.textSecondary), Icons.playlist_remove,
size: 48,
color: AppColors.textSecondary,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No playlists available', Text(
style: 'No playlists available',
GoogleFonts.inter(color: AppColors.textSecondary)), style: GoogleFonts.inter(color: AppColors.textSecondary),
),
], ],
), ),
); );
@@ -158,8 +165,11 @@ class _MobilePlaylistCard extends StatelessWidget {
color: AppColors.primary.withOpacity(0.2), color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.playlist_play, child: const Icon(
color: AppColors.primary, size: 28), Icons.playlist_play,
color: AppColors.primary,
size: 28,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -48,14 +48,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Loading channels...', style: TextStyle(color: Colors.white70)), const Text('Loading channels...',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 24), const SizedBox(height: 24),
FutureBuilder( FutureBuilder(
future: Future.delayed(const Duration(seconds: 8)), future: Future.delayed(const Duration(seconds: 8)),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
return TextButton( return TextButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), onPressed: () => ref.refresh(
liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Taking too long? Tap to retry'), child: const Text('Taking too long? Tap to retry'),
); );
} }
@@ -73,12 +75,19 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48), const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Failed to load channels', style: GoogleFonts.inter(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), Text('Failed to load channels',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('$e', style: const TextStyle(color: Colors.white54), textAlign: TextAlign.center), Text('$e',
style: const TextStyle(color: Colors.white54),
textAlign: TextAlign.center),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)), onPressed: () => ref
.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Retry'), child: const Text('Retry'),
), ),
], ],
@@ -285,7 +294,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
channel: channel, channel: channel,
playlist: widget.playlist, playlist: widget.playlist,
onTap: () => _playChannel( onTap: () => _playChannel(
context, channel, displayedChannels), context,
channel,
displayedChannels,
),
); );
}, },
), ),
@@ -299,7 +311,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
} }
void _playChannel( void _playChannel(
BuildContext context, Channel channel, List<Channel> channels) { BuildContext context,
Channel channel,
List<Channel> channels,
) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => MobilePlayerScreen(
-1
View File
@@ -31,7 +31,6 @@ class MobileTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( return baseTheme.copyWith(
useMaterial3: true,
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background, canvasColor: AppColors.background,
+1 -1
View File
@@ -48,7 +48,7 @@ class MobileScaffold extends ConsumerWidget {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface.withOpacity(0.9), color: AppColors.surface.withOpacity(0.9),
border: Border( border: const Border(
top: BorderSide( top: BorderSide(
color: AppColors.border, color: AppColors.border,
width: 0.5, width: 0.5,
-1
View File
@@ -228,7 +228,6 @@
// Android / Chrome - HLS.js // Android / Chrome - HLS.js
if (isHLS && Hls.isSupported()) { if (isHLS && Hls.isSupported()) {
log('Using Hls.js optimized for mobile'); log('Using Hls.js optimized for mobile');
const isTurbo = urlParams.get('turbo') === 'true';
const hls = new Hls({ const hls = new Hls({
maxBufferLength: 60, maxBufferLength: 60,