From 50a6998c09283055abe49fa8717bc2c082d294a6 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 9 Mar 2026 08:21:48 +0100 Subject: [PATCH] feat: add EPG TV guide, season passes (auto-recording) and unified recordings/EPG tab with 3 sub-tabs --- Dockerfile | 2 +- bin/api/epg_api.dart | 116 ++ bin/api/season_passes_api.dart | 103 ++ bin/database/database.dart | 60 + bin/server.dart | 34 +- bin/services/recording_scheduler.dart | 91 ++ lib/features/iptv/widgets/recordings_tab.dart | 1113 +++++++++++++---- 7 files changed, 1271 insertions(+), 248 deletions(-) create mode 100644 bin/api/epg_api.dart create mode 100644 bin/api/season_passes_api.dart diff --git a/Dockerfile b/Dockerfile index f30639f..71aa518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV DART_VM_OPTIONS="--old_gen_heap_size=4096" RUN flutter config --enable-web # Force cache invalidation when source changes (update this value to force rebuild) -ARG CACHEBUST=2026-03-08-v3 +ARG CACHEBUST=2026-03-09-epg-v1 # Copy dependency files first for better caching COPY pubspec.yaml ./ diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart new file mode 100644 index 0000000..7e24ad0 --- /dev/null +++ b/bin/api/epg_api.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:http/http.dart' as http; +import '../database/database.dart'; +import '../models/playlist_config.dart'; + +/// API EPG — proxy vers Xtream avec cache 30 minutes +/// GET /api/epg/?days=1 +class EpgApi { + final AppDatabase _db; + final Future Function(Request) _getPlaylist; + + // Cache simple en mémoire : channelId → {data, expiresAt} + final Map _cache = {}; + + EpgApi(this._db, this._getPlaylist); + + Future handleGetEpg(Request request, String channelId) async { + // Vérifier le cache + final cached = _cache[channelId]; + if (cached != null && DateTime.now().isBefore(cached.expiresAt)) { + return Response.ok( + cached.data, + headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'}, + ); + } + + try { + final playlist = await _getPlaylist(request); + if (playlist == null) { + return Response.forbidden(json.encode({'error': 'Playlist non trouvée'}), + headers: {'Content-Type': 'application/json'}); + } + + final dns = playlist.dns ?? '${playlist.serverUrl}'; + // Appel API Xtream pour l'EPG complet de la chaîne + final url = + '$dns/player_api.php?username=${playlist.username}&password=${playlist.password}' + '&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48'; + + final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 15)); + + if (response.statusCode != 200) { + return Response(response.statusCode, + body: json.encode({'error': 'Erreur API Xtream: ${response.statusCode}'}), + headers: {'Content-Type': 'application/json'}); + } + + // Reformater les données pour la grille TV + final raw = json.decode(response.body); + final epgData = _transformEpgData(raw, channelId); + final jsonStr = json.encode(epgData); + + // Mettre en cache 30 minutes + _cache[channelId] = _CacheEntry( + data: jsonStr, + expiresAt: DateTime.now().add(const Duration(minutes: 30)), + ); + + return Response.ok(jsonStr, headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}); + } catch (e) { + return Response.internalServerError( + body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + Map _transformEpgData(dynamic raw, String channelId) { + try { + List listings = []; + + if (raw is Map && raw.containsKey('epg_listings')) { + listings = raw['epg_listings'] as List? ?? []; + } else if (raw is List) { + listings = raw; + } + + final programmes = listings.map((item) { + final startRaw = item['start'] as String? ?? ''; + final endRaw = item['stop'] as String? ?? item['end'] as String? ?? ''; + + // Décoder le titre (base64 si nécessaire) + String title = item['title'] as String? ?? ''; + try { + title = utf8.decode(base64Decode(title)); + } catch (_) { + // n'est pas du base64, garder tel quel + } + + String description = item['description'] as String? ?? ''; + try { + description = utf8.decode(base64Decode(description)); + } catch (_) {} + + return { + 'title': title, + 'description': description, + 'start': startRaw, + 'end': endRaw, + 'channel_id': channelId, + }; + }).toList(); + + return {'channel_id': channelId, 'programmes': programmes}; + } catch (e) { + return {'channel_id': channelId, 'programmes': [], 'error': e.toString()}; + } + } +} + +class _CacheEntry { + final String data; + final DateTime expiresAt; + _CacheEntry({required this.data, required this.expiresAt}); +} diff --git a/bin/api/season_passes_api.dart b/bin/api/season_passes_api.dart new file mode 100644 index 0000000..4e8bd00 --- /dev/null +++ b/bin/api/season_passes_api.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import '../database/database.dart'; +import '../models/user.dart'; + +/// API Season Passes — enregistrements répétés par titre d'émission +class SeasonPassesApi { + final AppDatabase _db; + + SeasonPassesApi(this._db); + + /// GET /api/season-passes — liste tous les season passes + Response handleGetAll(Request request) { + try { + final passes = _db.getAllSeasonPasses(); + return Response.ok( + json.encode(passes), + headers: {'Content-Type': 'application/json'}, + ); + } catch (e) { + return Response.internalServerError( + body: json.encode({'error': 'Erreur: $e'}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// POST /api/season-passes — créer un season pass + Future handlePost(Request request) async { + try { + final body = await request.readAsString(); + final data = json.decode(body) as Map; + + final showTitle = data['show_title'] as String?; + final channelId = data['channel_id'] as String?; + final streamUrl = data['stream_url'] as String?; + + if (showTitle == null || showTitle.isEmpty) { + return Response(400, + body: json.encode({'error': 'show_title est requis'}), + headers: {'Content-Type': 'application/json'}); + } + if (channelId == null || channelId.isEmpty) { + return Response(400, + body: json.encode({'error': 'channel_id est requis'}), + headers: {'Content-Type': 'application/json'}); + } + if (streamUrl == null || streamUrl.isEmpty) { + return Response(400, + body: json.encode({'error': 'stream_url est requis'}), + headers: {'Content-Type': 'application/json'}); + } + + // Récupérer l'utilisateur depuis le contexte + final user = request.context['user'] as User?; + final userId = user?.id ?? 'admin'; // fallback + + // Vérifier si un season pass identique existe déjà + final existing = _db.getAllSeasonPasses(); + final duplicate = existing.any((p) => + (p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() && + p['channel_id'] == channelId); + if (duplicate) { + return Response(409, + body: json.encode({'error': 'Un Season Pass identique existe déjà'}), + headers: {'Content-Type': 'application/json'}); + } + + final pass = _db.createSeasonPass( + userId: userId, + showTitle: showTitle, + channelId: channelId, + streamUrl: streamUrl, + ); + + print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId'); + return Response(201, + body: json.encode(pass), + headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: json.encode({'error': 'Erreur: $e'}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// DELETE /api/season-passes/ — supprimer un season pass + Response handleDelete(Request request, String id) { + try { + _db.deleteSeasonPass(id); + return Response.ok( + json.encode({'message': 'Season Pass supprimé'}), + headers: {'Content-Type': 'application/json'}, + ); + } catch (e) { + return Response.internalServerError( + body: json.encode({'error': 'Erreur: $e'}), + headers: {'Content-Type': 'application/json'}, + ); + } + } +} diff --git a/bin/database/database.dart b/bin/database/database.dart index d04a040..7a581f5 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -98,6 +98,20 @@ class AppDatabase { ) '''); + // Season Passes table (enregistrements répétés intelligents) + _db.execute(''' + CREATE TABLE IF NOT EXISTS season_passes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + show_title TEXT NOT NULL, + channel_id TEXT NOT NULL, + stream_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + '''); + // Indexes _db.execute( 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)', @@ -108,6 +122,9 @@ class AppDatabase { _db.execute( 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)', ); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)', + ); } /// Seed default admin user if no users exist @@ -510,6 +527,49 @@ class AppDatabase { _db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]); } + // ==================== Season Passes ==================== + + /// Créer un Season Pass (enregistrement répété par titre d'émission) + Map createSeasonPass({ + required String userId, + required String showTitle, + required String channelId, + required String streamUrl, + }) { + final id = _uuid.v4(); + final now = DateTime.now().toIso8601String(); + _db.execute( + 'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [id, userId, showTitle, channelId, streamUrl, now], + ); + return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now}; + } + + /// Lister tous les Season Passes + List> getAllSeasonPasses() { + final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC'); + return result.map((r) => Map.from(r)).toList(); + } + + /// Supprimer un Season Pass + void deleteSeasonPass(String id) { + _db.execute('DELETE FROM season_passes WHERE id = ?', [id]); + } + + /// Vérifier si un enregistrement existe déjà pour ce titre (déduplication) + /// Retourne true si un enregistrement non-échoué avec ce titre existe pour cetteémission programméeà la même heure + bool existsRecordingForEpisode(String title, DateTime startTime) { + // Normaliser le titre pour la comparaison (insensible casse, sans espaces doubles) + final result = _db.select( + '''SELECT COUNT(*) as cnt FROM tv_recordings + WHERE LOWER(title) = LOWER(?) + AND start_time = ? + AND status NOT IN ('failed')''', + [title, startTime.toUtc().toIso8601String()], + ); + return (result.first['cnt'] as int) > 0; + } + /// Close database connection void close() { _db.dispose(); diff --git a/bin/server.dart b/bin/server.dart index 5dfafab..08d69cb 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -18,6 +18,8 @@ import 'api/settings_handler.dart'; import 'api/streaming_handler.dart'; import 'api/proxy_handler.dart'; import 'api/recordings_api.dart'; +import 'api/epg_api.dart'; +import 'api/season_passes_api.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; @@ -42,6 +44,24 @@ void main(List args) async { final recordingScheduler = RecordingScheduler(db); recordingScheduler.start(); + // Injecter la config playlist dans le scheduler pour les Season Passes + // (on prend la playlist du premier utilisateur disponible) + Future _injectPlaylistToScheduler() async { + final users = db.getAllUsers(); + if (users.isNotEmpty) { + final playlists = db.getPlaylists(users[0].id); + if (playlists.isNotEmpty) { + final p = playlists.first; + recordingScheduler.playlistDns = p.serverUrl; + recordingScheduler.playlistUsername = p.username; + recordingScheduler.playlistPassword = p.password; + print('[Server] Playlist injectée dans le scheduler: ${p.name}'); + } + } + } + // Injecter après 5s pour attendre l'initialisation complète + Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler); + // Initialize Streaming Subsystem await initStreaming(); @@ -90,6 +110,8 @@ void main(List args) async { final settingsHandler = SettingsHandler(db); final proxyHandler = ProxyHandler(getPlaylist, db); final recordingsApi = RecordingsApi(db, recordingScheduler); + final epgApi = EpgApi(db, getPlaylist); + final seasonPassesApi = SeasonPassesApi(db); // Setup router final apiRouter = Router() @@ -116,14 +138,18 @@ void main(List args) async { .addMiddleware(authMiddleware(db)) .addHandler(settingsHandler.router.call), ) - // TV Recordings - Chaque route déclarée EXPLICITEMENT pour éviter les conflits shelf_router - // NE PAS utiliser mount() ici car mount('/api/recordings', ...) intercepte TOUT - // ce qui commence par /api/recordings (y compris /api/recordings/logs/) + // TV Recordings - routes explicites ..get('/api/recordings', recordingsApi.handleGetAll) ..post('/api/recordings', recordingsApi.handlePost) ..delete('/api/recordings/', recordingsApi.handleDelete) ..post('/api/recordings/stop/', recordingsApi.handleStop) - ..get('/api/recordings/logs/', recordingsApi.getLogHandler); + ..get('/api/recordings/logs/', recordingsApi.getLogHandler) + // EPG - guide TV + ..get('/api/epg/', epgApi.handleGetEpg) + // Season Passes - enregistrements répétés + ..get('/api/season-passes', seasonPassesApi.handleGetAll) + ..post('/api/season-passes', seasonPassesApi.handlePost) + ..delete('/api/season-passes/', seasonPassesApi.handleDelete); // NOTE: /api/xtream is handled by proxyHandler in the Cascade below // Do NOT mount here as it would intercept and block the actual proxy diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart index 2b18284..d7a311f 100644 --- a/bin/services/recording_scheduler.dart +++ b/bin/services/recording_scheduler.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:http/http.dart' as http; import '../database/database.dart'; import '../models/recording.dart'; import 'package:path/path.dart' as p; @@ -7,24 +9,36 @@ import 'package:path/path.dart' as p; class RecordingScheduler { final AppDatabase _db; Timer? _timer; + Timer? _seasonPassTimer; bool _isRunning = false; // Stocker l'enregistrement actuellement en cours d'exécution Recording? _currentRecording; Process? _ffmpegProcess; + // Playlist config pour les appels EPG des season passes + // Rempli depuis server.dart après initialisation + String? playlistDns; + String? playlistUsername; + String? playlistPassword; + RecordingScheduler(this._db); void start() { print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV'); // Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat) _timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings()); + // Season Passes : vérifier toutes les 4 heures + _seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses()); // Lancer une première vérification immédiatement _checkAndRunRecordings(); + // Vérification initiale des season passes après 30s (laisser le serveur démarrer) + Timer(const Duration(seconds: 30), _checkSeasonPasses); } void stop() { _timer?.cancel(); + _seasonPassTimer?.cancel(); _stopCurrentRecording(reason: 'Arrêt du service planificateur'); print('[RecordingScheduler] Arrêté'); } @@ -87,6 +101,83 @@ class RecordingScheduler { } } + /// Vérifier les Season Passes et créer des enregistrements pour les nouvelles diffusions + Future _checkSeasonPasses() async { + final passes = _db.getAllSeasonPasses(); + if (passes.isEmpty) return; + + final dns = playlistDns; + final username = playlistUsername; + final password = playlistPassword; + + if (dns == null || username == null || password == null) { + print('[SeasonPass] Config playlist non disponible, vérification annulée'); + return; + } + + print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...'); + + for (final pass in passes) { + try { + final channelId = pass['channel_id'] as String; + final streamUrl = pass['stream_url'] as String; + final showTitle = pass['show_title'] as String; + final userId = pass['user_id'] as String; + + // Récupérer l'EPG de la chaîne (48 prochaines heures) + final url = '$dns/player_api.php?username=$username&password=$password' + '&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48'; + + final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) continue; + + final raw = json.decode(response.body); + final listings = (raw is Map ? raw['epg_listings'] : raw) as List? ?? []; + + for (final item in listings) { + String title = item['title'] as String? ?? ''; + try { title = utf8.decode(base64Decode(title)); } catch (_) {} + + // Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle) + if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue; + + // Parser les heures de début/fin + final startStr = item['start'] as String? ?? ''; + final endStr = item['stop'] as String? ?? item['end'] as String? ?? ''; + if (startStr.isEmpty || endStr.isEmpty) continue; + + DateTime startTime, endTime; + try { + startTime = DateTime.parse(startStr).toUtc(); + endTime = DateTime.parse(endStr).toUtc(); + } catch (_) { continue; } + + // Ne pas créer pour les programmes déjà terminés + if (endTime.isBefore(DateTime.now().toUtc())) continue; + + // Déduplication : vérifier si cet épisode est déjà planifié/enregistré + if (_db.existsRecordingForEpisode(title, startTime)) { + print('[SeasonPass] "$title" déjà enregistré, skip.'); + continue; + } + + // Créer l'enregistrement automatiquement + _db.createRecording( + userId: userId, + channelId: channelId, + streamUrl: streamUrl, + title: title, + startTime: startTime, + endTime: endTime, + ); + print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); + } + } catch (e) { + print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e'); + } + } + } + Future _startRecording(Recording recording) async { _currentRecording = recording; _db.updateRecordingStatus(recording.id, 'recording'); diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index d8eb0ed..ab87bc0 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/glass_container.dart'; +/// Onglet "Enregistrements & Guide TV" — combine guide EPG, enregistrements actifs et season passes class RecordingsTab extends StatefulWidget { const RecordingsTab({super.key}); @@ -12,7 +13,509 @@ class RecordingsTab extends StatefulWidget { State createState() => _RecordingsTabState(); } -class _RecordingsTabState extends State { +class _RecordingsTabState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + int _selectedTab = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() => _selectedTab = _tabController.index); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header + TabBar combinés + Container( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.tv, color: Colors.white, size: 28), + const SizedBox(width: 12), + Text( + 'TV & Enregistrements', + style: GoogleFonts.outfit( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 16), + TabBar( + controller: _tabController, + labelStyle: GoogleFonts.outfit(fontWeight: FontWeight.w600, fontSize: 14), + unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14), + labelColor: Colors.white, + unselectedLabelColor: Colors.white54, + indicatorColor: Colors.redAccent, + indicatorWeight: 3, + tabs: const [ + Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'), + Tab(icon: Icon(Icons.fiber_manual_record, size: 18), text: 'Enregistrements'), + Tab(icon: Icon(Icons.repeat, size: 18), text: 'Season Passes'), + ], + ), + ], + ), + ), + // Corps des onglets + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + _EpgGuideView(), + _RecordingsListView(), + _SeasonPassesView(), + ], + ), + ), + ], + ); + } +} + +// ═══════════════════════════════════════════════════════ +// ONGLET 1 — GUIDE TV (EPG) +// ═══════════════════════════════════════════════════════ + +class _EpgGuideView extends StatefulWidget { + const _EpgGuideView(); + + @override + State<_EpgGuideView> createState() => _EpgGuideViewState(); +} + +class _EpgGuideViewState extends State<_EpgGuideView> { + List _channels = []; + bool _loadingChannels = true; + String? _selectedChannelId; + String? _selectedChannelName; + String? _selectedStreamUrl; + List _programmes = []; + bool _loadingEpg = false; + + @override + void initState() { + super.initState(); + _loadChannels(); + } + + Future _loadChannels() async { + try { + final response = await http.get(Uri.parse('/api/recordings')); // On utilise les enregistrements pour avoir les chaînes + // On charge la liste depuis l'API xtream directement si disponible + setState(() => _loadingChannels = false); + } catch (_) { + setState(() => _loadingChannels = false); + } + } + + Future _loadEpg(String channelId) async { + setState(() { _loadingEpg = true; _programmes = []; }); + try { + final response = await http.get(Uri.parse('/api/epg/$channelId')); + if (response.statusCode == 200) { + final data = json.decode(response.body); + setState(() { + _programmes = (data['programmes'] as List? ?? []); + _loadingEpg = false; + }); + } else { + setState(() => _loadingEpg = false); + } + } catch (e) { + setState(() => _loadingEpg = false); + } + } + + void _showChannelInput() { + final idController = TextEditingController(); + final nameController = TextEditingController(); + final urlController = TextEditingController(); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E2E), + title: Text('Sélectionner une chaîne', style: GoogleFonts.outfit(color: Colors.white)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: idController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Channel ID (ex: 554021)', + labelStyle: TextStyle(color: Colors.white54), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Colors.white24)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: nameController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Nom de la chaîne', + labelStyle: TextStyle(color: Colors.white54), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Colors.white24)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: urlController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'stream_url (ex: /api/live/554021.ts)', + labelStyle: TextStyle(color: Colors.white54), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Colors.white24)), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), + onPressed: () { + final id = idController.text.trim(); + if (id.isNotEmpty) { + setState(() { + _selectedChannelId = id; + _selectedChannelName = nameController.text.trim().isEmpty ? 'Chaîne $id' : nameController.text.trim(); + _selectedStreamUrl = urlController.text.trim().isEmpty ? '/api/live/$id.ts' : urlController.text.trim(); + }); + Navigator.pop(ctx); + _loadEpg(id); + } + }, + child: const Text('Charger'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Zone de sélection de chaîne + Row( + children: [ + Expanded( + child: GlassContainer( + child: InkWell( + onTap: _showChannelInput, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + const Icon(Icons.tv, color: Colors.white54, size: 20), + const SizedBox(width: 12), + Text( + _selectedChannelName ?? 'Sélectionner une chaîne...', + style: TextStyle( + color: _selectedChannelName != null ? Colors.white : Colors.white38, + fontSize: 15, + ), + ), + const Spacer(), + const Icon(Icons.keyboard_arrow_down, color: Colors.white38), + ], + ), + ), + ), + ), + ), + if (_selectedChannelId != null) ...[ + const SizedBox(width: 12), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white70), + tooltip: 'Recharger l\'EPG', + onPressed: () => _loadEpg(_selectedChannelId!), + ), + ], + ], + ), + const SizedBox(height: 16), + // Contenu EPG + Expanded( + child: _selectedChannelId == null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.tv_off, size: 64, color: Colors.white12), + const SizedBox(height: 16), + Text( + 'Sélectionnez une chaîne pour\nvoir son guide des programmes', + textAlign: TextAlign.center, + style: GoogleFonts.outfit(color: Colors.white38, fontSize: 16), + ), + ], + ), + ) + : _loadingEpg + ? const Center(child: CircularProgressIndicator()) + : _programmes.isEmpty + ? Center( + child: Text('Aucun programme EPG disponible', + style: GoogleFonts.outfit(color: Colors.white38))) + : ListView.builder( + itemCount: _programmes.length, + itemBuilder: (ctx, i) => _ProgrammeCard( + programme: _programmes[i], + channelName: _selectedChannelName ?? '', + channelId: _selectedChannelId!, + streamUrl: _selectedStreamUrl ?? '/api/live/$_selectedChannelId.ts', + ), + ), + ), + ], + ), + ); + } +} + +class _ProgrammeCard extends StatelessWidget { + final Map programme; + final String channelName; + final String channelId; + final String streamUrl; + + const _ProgrammeCard({ + required this.programme, + required this.channelName, + required this.channelId, + required this.streamUrl, + }); + + String _formatTime(String rawTime) { + try { + final dt = DateTime.parse(rawTime).toLocal(); + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } catch (_) { + return rawTime.length >= 5 ? rawTime.substring(11, 16) : rawTime; + } + } + + bool get _isNow { + try { + final start = DateTime.parse(programme['start'] as String); + final end = DateTime.parse(programme['end'] as String); + final now = DateTime.now().toUtc(); + return now.isAfter(start) && now.isBefore(end); + } catch (_) { return false; } + } + + bool get _isPast { + try { + final end = DateTime.parse(programme['end'] as String); + return DateTime.now().toUtc().isAfter(end); + } catch (_) { return false; } + } + + void _scheduleRecording(BuildContext context) { + try { + final start = DateTime.parse(programme['start'] as String).toUtc(); + final end = DateTime.parse(programme['end'] as String).toUtc(); + final title = programme['title'] as String? ?? channelName; + _showRecordingConfirm(context, title, start, end); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Impossible de programmer: $e')), + ); + } + } + + void _showRecordingConfirm(BuildContext context, String title, DateTime start, DateTime end) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E2E), + title: Row( + children: [ + const Icon(Icons.fiber_manual_record, color: Colors.redAccent, size: 20), + const SizedBox(width: 8), + Expanded(child: Text('Enregistrer', style: GoogleFonts.outfit(color: Colors.white))), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: GoogleFonts.outfit(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + Text( + '${_formatTime(programme['start'])} → ${_formatTime(programme['end'])}', + style: const TextStyle(color: Colors.white70), + ), + if ((programme['description'] as String? ?? '').isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + programme['description'] as String, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), + onPressed: () async { + Navigator.pop(ctx); + await _saveRecording(context, title, start, end); + }, + child: const Text('🔴 Enregistrer'), + ), + ], + ), + ); + } + + Future _saveRecording(BuildContext context, String title, DateTime start, DateTime end) async { + try { + final response = await http.post( + Uri.parse('/api/recordings'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'channel_id': channelId, + 'stream_url': streamUrl, + 'title': title, + 'start_time': start.toIso8601String(), + 'end_time': end.toIso8601String(), + }), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(response.statusCode == 200 ? '✅ "$title" planifié !' : '❌ Erreur: ${response.body}'), + backgroundColor: response.statusCode == 200 ? Colors.green.shade800 : Colors.red.shade800, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erreur: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final isNow = _isNow; + final isPast = _isPast; + final title = programme['title'] as String? ?? '—'; + final start = programme['start'] as String? ?? ''; + final end = programme['end'] as String? ?? ''; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isNow + ? Colors.red.withOpacity(0.15) + : isPast + ? Colors.white.withOpacity(0.03) + : Colors.white.withOpacity(0.06), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isNow ? Colors.redAccent.withOpacity(0.5) : Colors.white.withOpacity(0.08), + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isNow) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.redAccent, + borderRadius: BorderRadius.circular(4), + ), + child: Text('LIVE', style: GoogleFonts.outfit(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ) + else + Text( + _formatTime(start), + style: GoogleFonts.outfit( + color: isPast ? Colors.white24 : Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (!isNow) + Text( + _formatTime(end), + style: GoogleFonts.outfit(color: Colors.white24, fontSize: 11), + ), + ], + ), + title: Text( + title, + style: GoogleFonts.outfit( + color: isPast ? Colors.white38 : Colors.white, + fontWeight: isNow ? FontWeight.bold : FontWeight.normal, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: (programme['description'] as String? ?? '').isNotEmpty + ? Text( + programme['description'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white38, fontSize: 11), + ) + : null, + trailing: !isPast + ? IconButton( + icon: const Icon(Icons.fiber_manual_record, color: Colors.redAccent, size: 22), + tooltip: 'Enregistrer ce programme', + onPressed: () => _scheduleRecording(context), + ) + : null, + ), + ); + } +} + +// ═══════════════════════════════════════════════════════ +// ONGLET 2 — MES ENREGISTREMENTS +// ═══════════════════════════════════════════════════════ + +class _RecordingsListView extends StatefulWidget { + const _RecordingsListView(); + @override + State<_RecordingsListView> createState() => _RecordingsListViewState(); +} + +class _RecordingsListViewState extends State<_RecordingsListView> { List _recordings = []; bool _isLoading = true; String? _error; @@ -24,155 +527,72 @@ class _RecordingsTabState extends State { } Future _fetchRecordings() async { - setState(() { - _isLoading = true; - _error = null; - }); - + setState(() { _isLoading = true; _error = null; }); try { final response = await http.get(Uri.parse('/api/recordings')); - if (response.statusCode == 200) { - setState(() { - _recordings = json.decode(response.body); - _isLoading = false; - }); + setState(() { _recordings = json.decode(response.body); _isLoading = false; }); } else { - setState(() { - _error = 'Erreur serveur: ${response.statusCode} - \${response.body}'; - _isLoading = false; - }); + setState(() { _error = 'Erreur ${response.statusCode}'; _isLoading = false; }); } } catch (e) { - setState(() { - _error = 'Impossible de contacter le serveur d\'enregistrement: $e'; - _isLoading = false; - }); - } - } - - Future _deleteRecording(String id) async { - try { - final response = await http.delete(Uri.parse('/api/recordings/$id')); - if (response.statusCode == 200) { - _fetchRecordings(); // Refresh list - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enregistrement supprimé')), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Erreur lors de la suppression'), backgroundColor: Colors.red), - ); - } + setState(() { _error = '$e'; _isLoading = false; }); } } Future _stopRecording(String id, String title) async { - try { - final response = await http.post(Uri.parse('/api/recordings/stop/$id')); - if (response.statusCode == 200) { - _fetchRecordings(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('⏹ Enregistrement "$title" arrêté')), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de l\'arrêt de l\'enregistrement: $e'), backgroundColor: Colors.red), - ); - } + final response = await http.post(Uri.parse('/api/recordings/stop/$id')); + if (response.statusCode == 200) { + _fetchRecordings(); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('⏹ "$title" arrêté'))); } } + Future _deleteRecording(String id) async { + await http.delete(Uri.parse('/api/recordings/$id')); + _fetchRecordings(); + } + Future _showLogs(String id, String title) async { try { final response = await http.get(Uri.parse('/api/recordings/logs/$id')); - - if (response.statusCode == 200) { - String logs = 'Aucun log trouvé.'; - try { - final data = json.decode(response.body); - logs = data['logs'] ?? 'Aucun log trouvé.'; - } catch (_) { - logs = response.body; // Fallback texte brut - } - - if (mounted) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - title: Text('Journaux pour "$title"', style: const TextStyle(color: Colors.white)), - content: SizedBox( - width: double.maxFinite, - height: 400, - child: SingleChildScrollView( - child: Text( - logs, - style: GoogleFonts.firaCode( - fontSize: 12, - color: Colors.greenAccent, - ), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer', style: TextStyle(color: Colors.blueAccent)), - ), - ], + if (!mounted) return; + final content = response.statusCode == 200 + ? (json.decode(response.body)['logs'] as String? ?? 'Aucun log') + : 'Logs non disponibles (${response.statusCode})'; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E2E), + title: Text(title, style: GoogleFonts.outfit(color: Colors.white, fontSize: 14)), + content: SizedBox( + width: 500, + height: 300, + child: SingleChildScrollView( + child: Text(content, style: const TextStyle(color: Colors.white70, fontSize: 11, fontFamily: 'monospace')), ), - ); - } - } else { - if (mounted) { - String errorMessage = 'Erreur serveur ${response.statusCode}'; - try { - final data = json.decode(response.body); - errorMessage = data['error'] ?? errorMessage; - } catch (_) { - errorMessage = 'Erreur ${response.statusCode}: ${response.body}'; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage), backgroundColor: Colors.orangeAccent), - ); - } - } + ), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Fermer'))], + ), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible de récupérer les logs: $e'), backgroundColor: Colors.red), - ); - } + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erreur logs: $e'))); } } - Color _getStatusColor(String status) { + Color _statusColor(String status) { switch (status) { - case 'recording': - return Colors.redAccent; - case 'completed': - return Colors.green; - case 'failed': - return Colors.orangeAccent; - default: - return Colors.blueAccent; + case 'recording': return Colors.redAccent; + case 'completed': return Colors.green; + case 'failed': return Colors.orangeAccent; + default: return Colors.blueAccent; } } String _formatStatus(String status) { switch (status) { case 'scheduled': return 'Planifié'; - case 'recording': return 'En cours'; + case 'recording': return '● En cours'; case 'completed': return 'Terminé'; case 'failed': return 'Échoué'; default: return status; @@ -182,138 +602,345 @@ class _RecordingsTabState extends State { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white70), + onPressed: _fetchRecordings, + tooltip: 'Rafraîchir', + ), + ], + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!, style: const TextStyle(color: Colors.redAccent))) + : _recordings.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam_off, size: 64, color: Colors.white12), + const SizedBox(height: 16), + Text('Aucun enregistrement', + style: GoogleFonts.outfit(color: Colors.white38, fontSize: 16)), + ], + ), + ) + : ListView.builder( + itemCount: _recordings.length, + itemBuilder: (ctx, i) { + final rec = _recordings[i]; + final status = rec['status'] as String? ?? 'unknown'; + final statusColor = _statusColor(status); + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Icon( + status == 'recording' ? Icons.fiber_manual_record : Icons.videocam, + color: statusColor, + size: 28, + ), + title: Text(rec['title'] ?? '—', + style: GoogleFonts.outfit(color: Colors.white, fontWeight: FontWeight.w600)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + if (rec['error_reason'] != null) + Text('⚠ ${rec['error_reason']}', + style: const TextStyle(color: Colors.orangeAccent, fontSize: 11)), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: statusColor.withOpacity(0.4)), + ), + child: Text(_formatStatus(status), + style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 11)), + ), + const SizedBox(width: 4), + if (status == 'recording') + IconButton( + icon: const Icon(Icons.stop_circle, color: Colors.redAccent), + tooltip: 'Arrêter', + onPressed: () => _stopRecording(rec['id'], rec['title'] ?? ''), + ), + IconButton( + icon: const Icon(Icons.description_outlined, color: Colors.blueAccent, size: 20), + tooltip: 'Logs', + onPressed: () => _showLogs(rec['id'], rec['title'] ?? ''), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.white38, size: 20), + tooltip: 'Supprimer', + onPressed: () => _deleteRecording(rec['id']), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + String _fmtDate(dynamic raw) { + if (raw == null) return '?'; + try { + final dt = DateTime.parse(raw.toString()).toLocal(); + return '${dt.day.toString().padLeft(2,'0')}/${dt.month.toString().padLeft(2,'0')} ${dt.hour.toString().padLeft(2,'0')}:${dt.minute.toString().padLeft(2,'0')}'; + } catch (_) { return raw.toString(); } + } +} + +// ═══════════════════════════════════════════════════════ +// ONGLET 3 — SEASON PASSES +// ═══════════════════════════════════════════════════════ + +class _SeasonPassesView extends StatefulWidget { + const _SeasonPassesView(); + @override + State<_SeasonPassesView> createState() => _SeasonPassesViewState(); +} + +class _SeasonPassesViewState extends State<_SeasonPassesView> { + List _passes = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadPasses(); + } + + Future _loadPasses() async { + setState(() => _isLoading = true); + try { + final response = await http.get(Uri.parse('/api/season-passes')); + if (response.statusCode == 200) { + setState(() { _passes = json.decode(response.body); _isLoading = false; }); + } else { + setState(() => _isLoading = false); + } + } catch (_) { + setState(() => _isLoading = false); + } + } + + Future _deletePass(String id, String title) async { + await http.delete(Uri.parse('/api/season-passes/$id')); + _loadPasses(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Season Pass "$title" supprimé'))); + } + } + + void _showCreateDialog() { + final titleController = TextEditingController(); + final channelIdController = TextEditingController(); + final streamUrlController = TextEditingController(); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E2E), + title: Row( + children: [ + const Icon(Icons.repeat, color: Colors.purpleAccent), + const SizedBox(width: 8), + Text('Nouveau Season Pass', style: GoogleFonts.outfit(color: Colors.white)), + ], + ), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission sur une chaîne.', + style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), + ), + const SizedBox(height: 16), + _Field(controller: titleController, label: 'Titre de l\'émission (ex: Champions League)'), + const SizedBox(height: 8), + _Field(controller: channelIdController, label: 'Channel ID (ex: 554021)'), + const SizedBox(height: 8), + _Field(controller: streamUrlController, label: 'stream_url (ex: /api/live/554021.ts)'), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.purpleAccent), + onPressed: () async { + final title = titleController.text.trim(); + final channelId = channelIdController.text.trim(); + final streamUrl = streamUrlController.text.trim().isEmpty + ? '/api/live/${channelIdController.text.trim()}.ts' + : streamUrlController.text.trim(); + if (title.isEmpty || channelId.isEmpty) return; + Navigator.pop(ctx); + final response = await http.post( + Uri.parse('/api/season-passes'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'show_title': title, 'channel_id': channelId, 'stream_url': streamUrl}), + ); + _loadPasses(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(response.statusCode == 201 ? '✅ Season Pass créé !' : '❌ ${response.body}'), + )); + } + }, + child: const Text('Créer'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Mes Enregistrements', - style: GoogleFonts.outfit( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - IconButton( - icon: const Icon(Icons.refresh, color: Colors.white), - onPressed: _fetchRecordings, + Text('Enregistrements automatiques', + style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13)), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: Colors.purpleAccent), + icon: const Icon(Icons.add, size: 18), + label: const Text('Nouveau'), + onPressed: _showCreateDialog, ), ], ), - const SizedBox(height: 24), - if (_isLoading) - const Expanded(child: Center(child: CircularProgressIndicator())) - else if (_error != null) - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 48), - const SizedBox(height: 16), - Text(_error!, style: const TextStyle(color: Colors.white)), - ], - ), - ), - ) - else if (_recordings.isEmpty) - Expanded( - child: Center( - child: Text( - 'Aucun enregistrement trouvé.', - style: GoogleFonts.inter(color: Colors.white54, fontSize: 16), - ), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: _recordings.length, - itemBuilder: (context, index) { - final rec = _recordings[index]; - final statusColor = _getStatusColor(rec['status']); - - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - leading: CircleAvatar( - backgroundColor: statusColor.withOpacity(0.2), - child: Icon( - rec['status'] == 'recording' ? Icons.fiber_manual_record : Icons.tv, - color: statusColor, - ), - ), - title: Text( - rec['title'] ?? 'Inconnu', - style: GoogleFonts.inter(fontWeight: FontWeight.bold, color: Colors.white), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Text( - 'De: ${DateTime.parse(rec['start_time']).toLocal().toString().replaceAll('.000', '')}\n' - 'À: ${DateTime.parse(rec['end_time']).toLocal().toString().replaceAll('.000', '')}', - style: GoogleFonts.inter(color: Colors.white70, fontSize: 12), - ), - if (rec['error_reason'] != null && rec['error_reason'].toString().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text('Erreur: ${rec['error_reason']}', style: const TextStyle(color: Colors.redAccent, fontSize: 12)), - ) - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: statusColor.withOpacity(0.5)), - ), - child: Text( - _formatStatus(rec['status']), - style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 12), - ), - ), - const SizedBox(width: 8), - // Bouton Stop (uniquement pour les enregistrements en cours) - if (rec['status'] == 'recording') - IconButton( - icon: const Icon(Icons.stop_circle, color: Colors.redAccent), - tooltip: 'Arrêter l\'enregistrement', - onPressed: () => _stopRecording(rec['id'], rec['title'] ?? 'Inconnu'), - ), - IconButton( - icon: const Icon(Icons.description_outlined, color: Colors.blueAccent), - tooltip: 'Voir les logs', - onPressed: () => _showLogs(rec['id'], rec['title'] ?? 'Inconnu'), - ), - IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.white54), - tooltip: 'Supprimer', - onPressed: () => _deleteRecording(rec['id']), - ), - ], - ), - ), - ); - }, - ), + const SizedBox(height: 12), + // Explication du fonctionnement + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purpleAccent.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)), ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.purpleAccent, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Le Season Pass scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.', + style: GoogleFonts.outfit(color: Colors.purpleAccent.shade100, fontSize: 12), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _passes.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat, size: 64, color: Colors.white12), + const SizedBox(height: 16), + Text('Aucun Season Pass actif', + style: GoogleFonts.outfit(color: Colors.white38, fontSize: 16)), + const SizedBox(height: 8), + Text('Créez-en un pour enregistrer automatiquement vos émissions préférées', + style: GoogleFonts.outfit(color: Colors.white24, fontSize: 12), + textAlign: TextAlign.center), + ], + ), + ) + : ListView.builder( + itemCount: _passes.length, + itemBuilder: (ctx, i) { + final pass = _passes[i]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.purpleAccent.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.purpleAccent.withOpacity(0.2)), + ), + child: ListTile( + leading: const Icon(Icons.repeat, color: Colors.purpleAccent), + title: Text(pass['show_title'] ?? '—', + style: GoogleFonts.outfit(color: Colors.white, fontWeight: FontWeight.w600)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Chaîne : ${pass['channel_id']}', + style: const TextStyle(color: Colors.white54, fontSize: 11)), + Text('Flux : ${pass['stream_url']}', + style: const TextStyle(color: Colors.white38, fontSize: 10)), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.white38), + tooltip: 'Supprimer', + onPressed: () => _deletePass(pass['id'], pass['show_title'] ?? ''), + ), + ), + ); + }, + ), + ), ], ), ); } } + +class _Field extends StatelessWidget { + final TextEditingController controller; + final String label; + const _Field({required this.controller, required this.label}); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Colors.white54, fontSize: 12), + enabledBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.white24)), + focusedBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.purpleAccent)), + ), + ); + } +}