feat: implement live tv recording system

This commit is contained in:
Michael committed 2026-03-08 08:40:55 +01:00
1 parent 758cbe4c43
commit 2180572b92
9 files changed
+806 -14

No files matched your search

+1 -1
View File
@@ -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
+90
View File
@@ -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('/<id>', (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;
}
}
+105
View File
@@ -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<Recording> 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<Recording> 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();
+100
View File
@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}
+14
View File
@@ -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<String> args) async {
// Parse command line arguments
@@ -36,6 +38,10 @@ void main(List<String> 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<String> 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<String> 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
+179
View File
@@ -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<void> _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<void> _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<void> _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<void> _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<File>().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');
}
}
}
+11 -8
View File
@@ -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
+33 -5
View File
@@ -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<LiveTVTab>
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<LiveTVTab>
],
),
),
// 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,
),
),
),
),
],
),
),
@@ -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<void> show(BuildContext context, Channel channel) {
return showDialog(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => RecordingModal(channel: channel),
);
}
@override
State<RecordingModal> createState() => _RecordingModalState();
}
class _RecordingModalState extends State<RecordingModal> {
DateTime _startTime = DateTime.now();
int _durationMinutes = 60;
bool _isLoading = false;
Future<void> _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,
),
),
),
),
],
),
],
),
),
),
);
}
}