diff --git a/Dockerfile b/Dockerfile index febcebd..574c860 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,7 +91,7 @@ RUN groupadd -r xtremuser && useradd -r -g xtremuser -G audio,video xtremuser WORKDIR /app # Create necessary data directories with correct permissions -RUN mkdir -p /app/data /app/web /tmp/xtremflow_streams \ +RUN mkdir -p /app/data /app/web /app/recordings /tmp/xtremflow_streams \ && chown -R xtremuser:xtremuser /app /tmp/xtremflow_streams # Copy built artifacts diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart new file mode 100644 index 0000000..b08c1fc --- /dev/null +++ b/bin/api/recordings_api.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database.dart'; +import '../services/recording_scheduler.dart'; + +class RecordingsApi { + final AppDatabase _db; + final RecordingScheduler _scheduler; + + RecordingsApi(this._db, this._scheduler); + + Router get router { + final router = Router(); + + // Récupérer tous les enregistrements (admin) ou les enregistrements de l'utilisateur courant (si filtré plus tard via les middlewares) + router.get('/', (Request request) { + // Dans une implémentation complète, nous récupérerions l'ID utilisateur + // final session = request.context['session'] as Session?; + // final userId = session?.userId; + + final recordings = _db.getAllRecordings(); + return Response.ok( + json.encode(recordings.map((r) => r.toMap()).toList()), + headers: {'Content-Type': 'application/json'}, + ); + }); + + // Planifier un nouvel enregistrement + router.post('/', (Request request) async { + // final session = request.context['session'] as Session; + // final userId = session.userId; + final payload = await request.readAsString(); + final data = json.decode(payload); + + try { + final recording = _db.createRecording( + userId: 'dev_user_id', // Remplacer par `userId` dans l'implèm réelle avec Auth + channelId: data['channel_id'], + streamUrl: data['stream_url'], + title: data['title'] ?? 'Sans Titre', + startTime: DateTime.parse(data['start_time']), + endTime: DateTime.parse(data['end_time']), + ); + + return Response.ok( + recording.toJson(), + headers: {'Content-Type': 'application/json'}, + ); + } catch (e) { + return Response.internalServerError( + body: json.encode({'error': 'Erreur lors de la programmation de l\'enregistrement : $e'}), + headers: {'Content-Type': 'application/json'}, + ); + } + }); + + // Supprimer (annuler) un enregistrement + router.delete('/', (Request request, String id) { + final recording = _db.getRecordingById(id); + + if (recording == null) { + return Response.notFound(json.encode({'error': 'Enregistrement non trouvé'})); + } + + // 1. Si l'enregistrement est en cours, il faut potentiellement l'arrêter + // _scheduler.cancelRecording(id); // Ceci pourrait être une nouvelle méthode de l'ordonnanceur + + // 2. Supprimer la donnée en BDD + _db.deleteRecording(id); + + // 3. Supprimer le fichier vidéo physiquement si existant + /* + if (recording.filePath != null) { + final file = File(recording.filePath!); + if (file.existsSync()) { + file.deleteSync(); + } + } + */ + + return Response.ok( + json.encode({'message': 'Enregistrement supprimé avec succès'}), + headers: {'Content-Type': 'application/json'}, + ); + }); + + return router; + } +} diff --git a/bin/database/database.dart b/bin/database/database.dart index add0391..dc3dde4 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -78,6 +78,25 @@ class AppDatabase { ) '''); + // TV Recordings table + _db.execute(''' + CREATE TABLE IF NOT EXISTS tv_recordings ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + stream_url TEXT NOT NULL, + title TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'scheduled', + file_path TEXT, + error_reason TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_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)', @@ -404,6 +423,92 @@ class AppDatabase { return false; } + // ==================== TV Recordings ==================== + + /// Créer un nouvel enregistrement + Recording createRecording({ + required String userId, + required String channelId, + required String streamUrl, + required String title, + required DateTime startTime, + required DateTime endTime, + }) { + final recordingId = _uuid.v4(); + final now = DateTime.now().toIso8601String(); + + _db.execute( + ''' + INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + [ + recordingId, + userId, + channelId, + streamUrl, + title, + startTime.toIso8601String(), + endTime.toIso8601String(), + now, + now + ], + ); + + return Recording( + id: recordingId, + userId: userId, + channelId: channelId, + streamUrl: streamUrl, + title: title, + startTime: startTime, + endTime: endTime, + status: 'scheduled', + createdAt: DateTime.parse(now), + updatedAt: DateTime.parse(now), + ); + } + + /// Lister tous les enregistrements (pour le Scheduler et l'admin) + List getAllRecordings() { + final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC'); + return result.map((row) => Recording.fromMap(row)).toList(); + } + + /// Lister les enregistrements d'un utilisateur spécifique + List getUserRecordings(String userId) { + final result = _db.select( + 'SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC', + [userId], + ); + return result.map((row) => Recording.fromMap(row)).toList(); + } + + /// Récupérer un enregistrement par ID + Recording? getRecordingById(String id) { + final result = _db.select('SELECT * FROM tv_recordings WHERE id = ?', [id]); + if (result.isEmpty) return null; + return Recording.fromMap(result.first); + } + + /// Mettre à jour le statut et éventuellement le chemin d'un enregistrement + void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) { + final now = DateTime.now().toIso8601String(); + _db.execute( + ''' + UPDATE tv_recordings + SET status = ?, file_path = COALESCE(?, file_path), error_reason = COALESCE(?, error_reason), updated_at = ? + WHERE id = ? + ''', + [status, filePath, errorReason, now, id], + ); + } + + /// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier) + void deleteRecording(String id) { + _db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]); + } + /// Close database connection void close() { _db.dispose(); diff --git a/bin/models/recording.dart b/bin/models/recording.dart new file mode 100644 index 0000000..e30bdca --- /dev/null +++ b/bin/models/recording.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +class Recording { + final String id; + final String userId; + final String channelId; + final String streamUrl; + final String title; + final DateTime startTime; + final DateTime endTime; + final String status; // 'scheduled', 'recording', 'completed', 'failed', 'cancelled' + final String? filePath; + final String? errorReason; + final DateTime createdAt; + final DateTime updatedAt; + + Recording({ + required this.id, + required this.userId, + required this.channelId, + required this.streamUrl, + required this.title, + required this.startTime, + required this.endTime, + required this.status, + this.filePath, + this.errorReason, + required this.createdAt, + required this.updatedAt, + }); + + Map toMap() { + return { + 'id': id, + 'user_id': userId, + 'channel_id': channelId, + 'stream_url': streamUrl, + 'title': title, + 'start_time': startTime.toIso8601String(), + 'end_time': endTime.toIso8601String(), + 'status': status, + 'file_path': filePath, + 'error_reason': errorReason, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + factory Recording.fromMap(Map map) { + return Recording( + id: map['id'] ?? '', + userId: map['user_id'] ?? '', + channelId: map['channel_id'] ?? '', + streamUrl: map['stream_url'] ?? '', + title: map['title'] ?? '', + startTime: DateTime.parse(map['start_time']), + endTime: DateTime.parse(map['end_time']), + status: map['status'] ?? 'scheduled', + filePath: map['file_path'], + errorReason: map['error_reason'], + createdAt: DateTime.parse(map['created_at']), + updatedAt: DateTime.parse(map['updated_at']), + ); + } + + String toJson() => json.encode(toMap()); + + factory Recording.fromJson(String source) => + Recording.fromMap(json.decode(source)); + + Recording copyWith({ + String? id, + String? userId, + String? channelId, + String? streamUrl, + String? title, + DateTime? startTime, + DateTime? endTime, + String? status, + String? filePath, + String? errorReason, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Recording( + id: id ?? this.id, + userId: userId ?? this.userId, + channelId: channelId ?? this.channelId, + streamUrl: streamUrl ?? this.streamUrl, + title: title ?? this.title, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + status: status ?? this.status, + filePath: filePath ?? this.filePath, + errorReason: errorReason ?? this.errorReason, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/bin/server.dart b/bin/server.dart index 83cc683..a3ae400 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -17,9 +17,11 @@ import 'api/playlists_handler.dart'; import 'api/settings_handler.dart'; import 'api/streaming_handler.dart'; import 'api/proxy_handler.dart'; +import 'api/recordings_api.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; +import 'services/recording_scheduler.dart'; void main(List args) async { // Parse command line arguments @@ -36,6 +38,10 @@ void main(List args) async { await db.init(); await db.seedAdmin(); + // Initialize and start Recording Scheduler + final recordingScheduler = RecordingScheduler(db); + recordingScheduler.start(); + // Initialize Streaming Subsystem await initStreaming(); @@ -83,6 +89,7 @@ void main(List args) async { final usersHandler = UsersHandler(db); final settingsHandler = SettingsHandler(db); final proxyHandler = ProxyHandler(getPlaylist, db); + final recordingsApi = RecordingsApi(db, recordingScheduler); // Setup router final apiRouter = Router() @@ -108,6 +115,13 @@ void main(List args) async { const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(settingsHandler.router.call), + ) + // TV Recordings endpoints + ..mount( + '/api/recordings', + const Pipeline() + //.addMiddleware(authMiddleware(db)) // TODO: Activer quand UI aura l'Auth header + .addHandler(recordingsApi.router.call), ); // 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 new file mode 100644 index 0000000..9710e10 --- /dev/null +++ b/bin/services/recording_scheduler.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:io'; +import '../database/database.dart'; +import '../models/recording.dart'; +import 'package:path/path.dart' as p; + +class RecordingScheduler { + final AppDatabase _db; + Timer? _timer; + bool _isRunning = false; + + // Stocker l'enregistrement actuellement en cours d'exécution + Recording? _currentRecording; + Process? _ffmpegProcess; + + RecordingScheduler(this._db); + + void start() { + print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV'); + // Vérifier toutes les minutes + _timer = Timer.periodic(const Duration(minutes: 1), (_) => _checkAndRunRecordings()); + // Lancer une première vérification immédiatement + _checkAndRunRecordings(); + } + + void stop() { + _timer?.cancel(); + _stopCurrentRecording(reason: 'Arrêt du service planificateur'); + print('[RecordingScheduler] Arrêté'); + } + + Future _checkAndRunRecordings() async { + if (_isRunning) return; + _isRunning = true; + + try { + final now = DateTime.now(); + + // Si un enregistrement est en cours, vérifier s'il doit s'arrêter + if (_currentRecording != null) { + if (now.isAfter(_currentRecording!.endTime)) { + print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); + await _stopCurrentRecording(); + } + } + + // Rechercher les enregistrements planifiés + final recordings = _db.getAllRecordings(); + + for (final recording in recordings) { + // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur + if (recording.status == 'recording' && _currentRecording?.id != recording.id) { + print('[RecordingScheduler] Correction d\'un enregistrement orphelin (état "recording" sans processus)'); + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur'); + continue; + } + + // Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini + if (recording.status == 'scheduled' && now.isAfter(recording.startTime) && now.isBefore(recording.endTime)) { + + if (_currentRecording != null) { + print('[RecordingScheduler] Impossible de démarrer "${recording.title}", un autre enregistrement est en cours (${_currentRecording!.title}).'); + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours (limite de 1 flux simultané).'); + continue; + } + + print('[RecordingScheduler] Démarrage de l\'enregistrement : ${recording.title}'); + await _startRecording(recording); + } + + // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) + if (recording.status == 'scheduled' && now.isAfter(recording.endTime)) { + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Heure de fin dépassée avant le démarrage'); + } + } + } catch (e) { + print('[RecordingScheduler] Erreur lors de la vérification : $e'); + } finally { + _isRunning = false; + } + } + + Future _startRecording(Recording recording) async { + _currentRecording = recording; + _db.updateRecordingStatus(recording.id, 'recording'); + + try { + // Préparation du dossier d'enregistrement + final recordingsDir = Directory('/app/recordings'); + if (!await recordingsDir.exists()) { + await recordingsDir.create(recursive: true); + } + + // Nettoyer l'espace disque si nécessaire (TODO : Implémenter logique de roulement ici) + await _checkDiskSpaceAndRotate(recordingsDir); + + // Génération d'un nom de fichier unique et sûr + final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); + final dateStr = recording.startTime.toIso8601String().replaceAll(':', '').split('.')[0]; + final fileName = '${safeTitle}_$dateStr.mp4'; + final filePath = p.join(recordingsDir.path, fileName); + + // FFmpeg arguments pour transcodage H265 + // Utilisation d'un Timeout ou d'une capture directe + final args = [ + '-y', // Force overwrite + '-i', recording.streamUrl, // Input stream + '-c:v', 'libx265', // Transcodage Video H.265 (HEVC) + '-preset', 'fast', // Preset d'encodage + '-c:a', 'aac', // Transcodage Audio AAC + '-b:a', '128k', // Bitrate audio + // Arrêter automatiquement après la durée (sécurité supplémentaire en plus du Timer) + '-t', '${recording.endTime.difference(DateTime.now()).inSeconds}', + filePath + ]; + + print('[RecordingScheduler] Exécution: ffmpeg ${args.join(' ')}'); + + _ffmpegProcess = await Process.start('ffmpeg', args); + + // Enregistrer le chemin du fichier dans la BDD + _db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); + + // Écouter de manière asynchrone la fin du processus FFmpeg + _ffmpegProcess!.exitCode.then((exitCode) { + if (_currentRecording?.id == recording.id) { + if (exitCode == 0 || exitCode == 255) { // 255 est souvent renvoyé lors d'un arrêt forcé (SIGKILL/SIGTERM) qui est attendu + print('[RecordingScheduler] Enregistrement terminé avec succès (${recording.title})'); + _db.updateRecordingStatus(recording.id, 'completed'); + } else { + print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour l\'enregistrement ${recording.title}'); + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode'); + } + _currentRecording = null; + _ffmpegProcess = null; + } + }); + + } catch (e) { + print('[RecordingScheduler] Impossible de démarrer FFmpeg: $e'); + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Impossible de lancer le processus (FFmpeg manquant?)'); + _currentRecording = null; + _ffmpegProcess = null; + } + } + + Future _stopCurrentRecording({String? reason}) async { + if (_ffmpegProcess != null && _currentRecording != null) { + print('[RecordingScheduler] Arrêt forcé du processus FFmpeg. Raison: ${reason ?? "Fin programmée"}'); + _ffmpegProcess!.kill(ProcessSignal.sigterm); // Envoyer un signal d'arrêt propre + _db.updateRecordingStatus(_currentRecording!.id, 'completed'); + _ffmpegProcess = null; + _currentRecording = null; + } + } + + Future _checkDiskSpaceAndRotate(Directory dir) async { + // Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers + // et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint. + // Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers + try { + final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste) + final files = dir.listSync().whereType().toList(); + + if (files.length > maxFiles) { + print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements'); + files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent + + // Supprimer les plus anciens pour revenir sous la limite + final filesToDelete = files.take(files.length - maxFiles); + for (var file in filesToDelete) { + file.deleteSync(); + } + } + } catch (e) { + print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e'); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5ead088..7cbc151 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,20 @@ services: - iptv-web: - build: . + xtremflow: + build: + context: https://github.com/R0m1k3/xtremflow.git + dockerfile: Dockerfile + container_name: xtremflow ports: - "8089:8089" - networks: - - nginx_default volumes: - xtremflow-data:/app/data + # Configuration du répertoire d'enregistrement TV + # Par défaut: sauve dans ./data/recordings (dossier local) + # Pour changer: modifiez le chemin avant les deux points ":" + # Exemple Windows : - D:\MesVideos\TV:/app/recordings + # Exemple Linux : - /mnt/nas/videos:/app/recordings + - ${RECORDINGS_PATH:-./data/recordings}:/app/recordings restart: unless-stopped volumes: xtremflow-data: - -networks: - nginx_default: - external: true diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 0c3c7b7..06a11f3 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -12,6 +12,7 @@ import '../../../core/utils/responsive_layout.dart'; import '../../../core/widgets/themed_loading_screen.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; +import 'recording_modal.dart'; class LiveTVTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -377,12 +378,15 @@ class _LiveTVTabState extends ConsumerState color: AppColors.surfaceVariant, borderRadius: BorderRadius.circular(12), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Stack( + fit: StackFit.expand, children: [ - // Icon Area - Expanded( - child: Container( + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Icon Area + Expanded( + child: Container( decoration: BoxDecoration( color: Colors.white .withOpacity(0.05), // Subtle transparent background @@ -498,6 +502,30 @@ class _LiveTVTabState extends ConsumerState ], ), ), + // Record Button Icon Overlay (Positioned top right) + Positioned( + top: 8, + right: 8, + child: TvFocusableCard( + onTap: () { + RecordingModal.show(context, channel); + }, + borderRadius: 20, + scaleFactor: 1.2, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.fiber_manual_record, + color: Colors.redAccent, + size: 16, + ), + ), + ), + ), ], ), ), diff --git a/lib/features/iptv/widgets/recording_modal.dart b/lib/features/iptv/widgets/recording_modal.dart new file mode 100644 index 0000000..709d9ba --- /dev/null +++ b/lib/features/iptv/widgets/recording_modal.dart @@ -0,0 +1,273 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; +import '../../../core/models/iptv_models.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/glass_container.dart'; +import '../../../core/widgets/tv_focusable_card.dart'; + +class RecordingModal extends StatefulWidget { + final Channel channel; + + const RecordingModal({ + super.key, + required this.channel, + }); + + static Future show(BuildContext context, Channel channel) { + return showDialog( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => RecordingModal(channel: channel), + ); + } + + @override + State createState() => _RecordingModalState(); +} + +class _RecordingModalState extends State { + DateTime _startTime = DateTime.now(); + int _durationMinutes = 60; + bool _isLoading = false; + + Future _scheduleRecording() async { + setState(() => _isLoading = true); + + final endTime = _startTime.add(Duration(minutes: _durationMinutes)); + + try { + // Dans une implémentation réelle le port/URL serait géré par une configuration + final response = await http.post( + Uri.parse('http://localhost:8089/api/recordings'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'channel_id': widget.channel.streamId, + 'stream_url': widget.channel.streamIcon, // Hack temp: Utiliser streamId pour générer l'url via player logic plus tard + 'title': widget.channel.name, + 'start_time': _startTime.toIso8601String(), + 'end_time': endTime.toIso8601String(), + }), + ); + + if (response.statusCode == 200) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enregistrement programmé !')), + ); + Navigator.of(context).pop(); + } + } else { + throw Exception('Erreur API'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: Material( + color: Colors.transparent, + child: Container( + width: 450, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppColors.background.withOpacity(0.9), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppColors.primary.withOpacity(0.3)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + const Icon(Icons.fiber_manual_record, color: Colors.red), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Programmer un enregistrement', + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Informations Chaine + Text( + 'Chaîne ciblée :', + style: GoogleFonts.inter(color: Colors.white54, fontSize: 14), + ), + const SizedBox(height: 8), + Text( + widget.channel.name, + style: GoogleFonts.inter(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 24), + + // Heure de début + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Heure de début :', + style: GoogleFonts.inter(color: Colors.white54, fontSize: 14), + ), + const SizedBox(height: 8), + TvFocusableCard( + onTap: () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_startTime), + ); + if (time != null) { + setState(() { + final now = DateTime.now(); + _startTime = DateTime( + now.year, now.month, now.day, + time.hour, time.minute, + ); + // Empêcher l'heure de passé de demain pour simplifier + if (_startTime.isBefore(now)) { + _startTime = _startTime.add(const Duration(days: 1)); + } + }); + } + }, + borderRadius: 8, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.access_time, color: Colors.white, size: 18), + const SizedBox(width: 8), + Text( + '${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}', + style: GoogleFonts.inter(color: Colors.white), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + + // Durée + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Durée (minutes) :', + style: GoogleFonts.inter(color: Colors.white54, fontSize: 14), + ), + const SizedBox(height: 8), + GlassContainer( + borderRadius: 8, + opacity: 0.1, + padding: EdgeInsets.zero, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.remove, color: Colors.white), + onPressed: () { + if (_durationMinutes > 15) { + setState(() => _durationMinutes -= 15); + } + }, + ), + Expanded( + child: Text( + '$_durationMinutes m', + textAlign: TextAlign.center, + style: GoogleFonts.inter(color: Colors.white), + ), + ), + IconButton( + icon: const Icon(Icons.add, color: Colors.white), + onPressed: () { + if (_durationMinutes < 300) { + setState(() => _durationMinutes += 15); + } + }, + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: Text( + 'Annuler', + style: GoogleFonts.inter(color: Colors.white54), + ), + ), + const SizedBox(width: 16), + TvFocusableCard( + onTap: _isLoading ? () {} : _scheduleRecording, + borderRadius: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: _isLoading + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + 'Enregistrer', + style: GoogleFonts.inter( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +}