mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-24 07:28:05 +02:00
feat: implement IPTV player screen with iframe-based stream support and recordings tab widget
This commit is contained in:
1 parent
77dc9053bb
commit
6a4a642611
13 files changed
+218
-120
No files matched your search
@@ -511,8 +511,12 @@ class AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
||||||
void updateRecordingStatus(String id, String status,
|
void updateRecordingStatus(
|
||||||
{String? filePath, String? errorReason}) {
|
String id,
|
||||||
|
String status, {
|
||||||
|
String? filePath,
|
||||||
|
String? errorReason,
|
||||||
|
}) {
|
||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
_db.execute(
|
_db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -551,14 +555,15 @@ class AppDatabase {
|
|||||||
'channel_id': channelId,
|
'channel_id': channelId,
|
||||||
'stream_url': streamUrl,
|
'stream_url': streamUrl,
|
||||||
'enabled': 1,
|
'enabled': 1,
|
||||||
'created_at': now
|
'created_at': now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lister tous les Season Passes
|
/// Lister tous les Season Passes
|
||||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||||
final result = _db.select(
|
final result = _db.select(
|
||||||
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
|
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
||||||
|
);
|
||||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ class RecordingScheduler {
|
|||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV',
|
||||||
|
);
|
||||||
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
||||||
_timer = Timer.periodic(
|
_timer = Timer.periodic(
|
||||||
const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
const Duration(seconds: 10),
|
||||||
|
(_) => _checkAndRunRecordings(),
|
||||||
|
);
|
||||||
// Season Passes : vérifier toutes les 4 heures
|
// Season Passes : vérifier toutes les 4 heures
|
||||||
_seasonPassTimer =
|
_seasonPassTimer =
|
||||||
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
||||||
@@ -56,13 +59,15 @@ class RecordingScheduler {
|
|||||||
|
|
||||||
final recordings = _db.getAllRecordings();
|
final recordings = _db.getAllRecordings();
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
|
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}',
|
||||||
|
);
|
||||||
|
|
||||||
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
||||||
if (_currentRecording != null) {
|
if (_currentRecording != null) {
|
||||||
if (now.isAfter(_currentRecording!.endTime.toUtc())) {
|
if (now.isAfter(_currentRecording!.endTime.toUtc())) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}',
|
||||||
|
);
|
||||||
await _stopCurrentRecording();
|
await _stopCurrentRecording();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +78,13 @@ class RecordingScheduler {
|
|||||||
if (recording.status == 'recording' &&
|
if (recording.status == 'recording' &&
|
||||||
_currentRecording?.id != recording.id) {
|
_currentRecording?.id != recording.id) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
|
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}',
|
||||||
_db.updateRecordingStatus(recording.id, 'failed',
|
);
|
||||||
errorReason: 'Interruption inattendue du serveur');
|
_db.updateRecordingStatus(
|
||||||
|
recording.id,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'Interruption inattendue du serveur',
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,28 +92,38 @@ class RecordingScheduler {
|
|||||||
final startUtc = recording.startTime.toUtc();
|
final startUtc = recording.startTime.toUtc();
|
||||||
final endUtc = recording.endTime.toUtc();
|
final endUtc = recording.endTime.toUtc();
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
|
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}',
|
||||||
|
);
|
||||||
|
|
||||||
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
||||||
if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
|
if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
|
||||||
if (_currentRecording != null) {
|
if (_currentRecording != null) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
|
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.',
|
||||||
_db.updateRecordingStatus(recording.id, 'failed',
|
);
|
||||||
errorReason: 'Un autre enregistrement était déjà en cours.');
|
_db.updateRecordingStatus(
|
||||||
|
recording.id,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'Un autre enregistrement était déjà en cours.',
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
|
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***',
|
||||||
|
);
|
||||||
await _startRecording(recording);
|
await _startRecording(recording);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
||||||
if (now.isAfter(endUtc)) {
|
if (now.isAfter(endUtc)) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).');
|
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).',
|
||||||
_db.updateRecordingStatus(recording.id, 'failed',
|
);
|
||||||
errorReason: 'Heure de fin dépassée avant le démarrage');
|
_db.updateRecordingStatus(
|
||||||
|
recording.id,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'Heure de fin dépassée avant le démarrage',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +145,8 @@ class RecordingScheduler {
|
|||||||
|
|
||||||
if (dns == null || username == null || password == null) {
|
if (dns == null || username == null || password == null) {
|
||||||
print(
|
print(
|
||||||
'[SeasonPass] Config playlist non disponible, vérification annulée');
|
'[SeasonPass] Config playlist non disponible, vérification annulée',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +213,8 @@ class RecordingScheduler {
|
|||||||
endTime: endTime,
|
endTime: endTime,
|
||||||
);
|
);
|
||||||
print(
|
print(
|
||||||
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
||||||
@@ -257,10 +278,14 @@ class RecordingScheduler {
|
|||||||
} catch (logError) {
|
} catch (logError) {
|
||||||
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
|
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
|
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError',
|
||||||
|
);
|
||||||
// On note la filePath dans la DB quand même pour pouvoir retourner le statut
|
// On note la filePath dans la DB quand même pour pouvoir retourner le statut
|
||||||
_db.updateRecordingStatus(recording.id, 'recording',
|
_db.updateRecordingStatus(
|
||||||
filePath: filePath);
|
recording.id,
|
||||||
|
'recording',
|
||||||
|
filePath: filePath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trouver ffmpeg
|
// Trouver ffmpeg
|
||||||
@@ -285,18 +310,24 @@ class RecordingScheduler {
|
|||||||
_ffmpegProcess!.exitCode.then((exitCode) async {
|
_ffmpegProcess!.exitCode.then((exitCode) async {
|
||||||
if (_currentRecording?.id == recording.id) {
|
if (_currentRecording?.id == recording.id) {
|
||||||
logSink?.writeln(
|
logSink?.writeln(
|
||||||
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
|
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode',
|
||||||
|
);
|
||||||
await logSink?.close();
|
await logSink?.close();
|
||||||
|
|
||||||
if (exitCode == 0 || exitCode == 255) {
|
if (exitCode == 0 || exitCode == 255) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Enregistrement terminé: ${recording.title}');
|
'[RecordingScheduler] Enregistrement terminé: ${recording.title}',
|
||||||
|
);
|
||||||
_db.updateRecordingStatus(recording.id, 'completed');
|
_db.updateRecordingStatus(recording.id, 'completed');
|
||||||
} else {
|
} else {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
|
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}',
|
||||||
_db.updateRecordingStatus(recording.id, 'failed',
|
);
|
||||||
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
|
_db.updateRecordingStatus(
|
||||||
|
recording.id,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_currentRecording = null;
|
_currentRecording = null;
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
@@ -307,8 +338,11 @@ class RecordingScheduler {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
||||||
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
||||||
_db.updateRecordingStatus(recording.id, 'failed',
|
_db.updateRecordingStatus(
|
||||||
errorReason: 'Erreur au lancement: $e');
|
recording.id,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'Erreur au lancement: $e',
|
||||||
|
);
|
||||||
_currentRecording = null;
|
_currentRecording = null;
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
}
|
}
|
||||||
@@ -318,7 +352,8 @@ class RecordingScheduler {
|
|||||||
Future<bool> stopRecording(String id) async {
|
Future<bool> stopRecording(String id) async {
|
||||||
if (_ffmpegProcess != null && _currentRecording?.id == id) {
|
if (_ffmpegProcess != null && _currentRecording?.id == id) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
|
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}',
|
||||||
|
);
|
||||||
_ffmpegProcess!.kill(ProcessSignal.sigterm);
|
_ffmpegProcess!.kill(ProcessSignal.sigterm);
|
||||||
_db.updateRecordingStatus(id, 'completed');
|
_db.updateRecordingStatus(id, 'completed');
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
@@ -348,11 +383,11 @@ class RecordingScheduler {
|
|||||||
|
|
||||||
if (files.length > maxFiles) {
|
if (files.length > maxFiles) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements',
|
||||||
files.sort((a, b) => a
|
);
|
||||||
.statSync()
|
files.sort(
|
||||||
.modified
|
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
|
||||||
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
); // Du plus vieux au plus récent
|
||||||
|
|
||||||
// Supprimer les plus anciens pour revenir sous la limite
|
// Supprimer les plus anciens pour revenir sous la limite
|
||||||
final filesToDelete = files.take(files.length - maxFiles);
|
final filesToDelete = files.take(files.length - maxFiles);
|
||||||
@@ -362,7 +397,8 @@ class RecordingScheduler {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,11 @@ class SimpleRecorder {
|
|||||||
_active.remove(channelId);
|
_active.remove(channelId);
|
||||||
print('✅ Recording done: $title');
|
print('✅ Recording done: $title');
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
db.updateRecordingStatus(recordingId, 'failed',
|
db.updateRecordingStatus(
|
||||||
errorReason: 'FFmpeg error');
|
recordingId,
|
||||||
|
'failed',
|
||||||
|
errorReason: 'FFmpeg error',
|
||||||
|
);
|
||||||
_active.remove(channelId);
|
_active.remove(channelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -295,7 +295,8 @@ class RuntimeOptimizations {
|
|||||||
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
|
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
|
||||||
|
|
||||||
print(
|
print(
|
||||||
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
|
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get dynamic cache size based on available memory
|
/// Get dynamic cache size based on available memory
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ class GlassContainer extends StatelessWidget {
|
|||||||
// BackdropFilter is extremely expensive in Flutter Web on mobile Safari.
|
// BackdropFilter is extremely expensive in Flutter Web on mobile Safari.
|
||||||
// On mobile web (width < 600), skip the blur entirely.
|
// On mobile web (width < 600), skip the blur entirely.
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final useBlur = blur > 0 && !(Theme.of(context).platform == TargetPlatform.iOS && screenWidth < 600);
|
final useBlur = blur > 0 &&
|
||||||
|
!(Theme.of(context).platform == TargetPlatform.iOS &&
|
||||||
|
screenWidth < 600);
|
||||||
|
|
||||||
final inner = Container(
|
final inner = Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
@@ -157,10 +159,12 @@ class _GlassCardState extends State<GlassCard>
|
|||||||
);
|
);
|
||||||
|
|
||||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
|
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
|
||||||
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault),
|
||||||
|
);
|
||||||
|
|
||||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
|
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
|
||||||
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -285,8 +285,10 @@ class _HeroCarouselState extends State<HeroCarousel>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.play_arrow,
|
const Icon(
|
||||||
color: AppColors.textPrimary),
|
Icons.play_arrow,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Play',
|
'Play',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
'Movies',
|
'Movies',
|
||||||
'Series',
|
'Series',
|
||||||
'Recordings',
|
'Recordings',
|
||||||
'Settings'
|
'Settings',
|
||||||
];
|
];
|
||||||
final List<IconData> _icons = [
|
final List<IconData> _icons = [
|
||||||
Icons.live_tv_rounded,
|
Icons.live_tv_rounded,
|
||||||
|
|||||||
@@ -196,9 +196,13 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(day, style: const TextStyle(fontSize: 11)),
|
Text(day, style: const TextStyle(fontSize: 11)),
|
||||||
Text(dateNum,
|
Text(
|
||||||
style: const TextStyle(
|
dateNum,
|
||||||
fontSize: 13, fontWeight: FontWeight.bold)),
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -257,7 +261,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'$hour:00',
|
'$hour:00',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11, fontWeight: FontWeight.bold),
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -506,7 +506,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
_buildInlineEpg(),
|
_buildInlineEpg(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Divider(
|
const Divider(
|
||||||
color: Colors.white10),
|
color: Colors.white10,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Control Buttons
|
// Control Buttons
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -183,8 +183,9 @@ class XtreamService {
|
|||||||
/// Get all live TV channels grouped by category
|
/// Get all live TV channels grouped by category
|
||||||
///
|
///
|
||||||
/// Returns a Map where key is category name and value is list of channels
|
/// Returns a Map where key is category name and value is list of channels
|
||||||
Future<Map<String, List<Channel>>> getLiveChannels(
|
Future<Map<String, List<Channel>>> getLiveChannels({
|
||||||
{bool refresh = false}) async {
|
bool refresh = false,
|
||||||
|
}) async {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
final options = refresh
|
final options = refresh
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import '../../../core/models/iptv_models.dart';
|
|||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../screens/player_screen.dart';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// ENTRÉE — Onglet "TV & Enregistrements" avec 3 sous-onglets
|
// ENTRÉE — Onglet "Enregistrements"
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class RecordingsTab extends StatefulWidget {
|
class RecordingsTab extends StatefulWidget {
|
||||||
@@ -27,7 +28,7 @@ class _RecordingsTabState extends State<RecordingsTab>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 1, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -73,15 +74,10 @@ class _RecordingsTabState extends State<RecordingsTab>
|
|||||||
),
|
),
|
||||||
unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14),
|
unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14),
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'),
|
|
||||||
Tab(
|
Tab(
|
||||||
icon: Icon(Icons.fiber_manual_record, size: 18),
|
icon: Icon(Icons.fiber_manual_record, size: 18),
|
||||||
text: 'Enregistrements',
|
text: 'Enregistrements',
|
||||||
),
|
),
|
||||||
Tab(
|
|
||||||
icon: Icon(Icons.repeat, size: 18),
|
|
||||||
text: 'Season Passes',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -89,11 +85,8 @@ class _RecordingsTabState extends State<RecordingsTab>
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
children: [
|
||||||
_EpgGuideView(playlist: widget.playlist),
|
_RecordingsListView(playlist: widget.playlist),
|
||||||
const _RecordingsListView(),
|
|
||||||
const _SeasonPassesView(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -298,7 +291,9 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Rechercher...',
|
hintText: 'Rechercher...',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
color: Colors.white38, fontSize: 13),
|
color: Colors.white38,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: const Icon(
|
||||||
Icons.search,
|
Icons.search,
|
||||||
color: Colors.white38,
|
color: Colors.white38,
|
||||||
@@ -765,7 +760,8 @@ class _ProgrammeCard extends StatelessWidget {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class _RecordingsListView extends StatefulWidget {
|
class _RecordingsListView extends StatefulWidget {
|
||||||
const _RecordingsListView();
|
final PlaylistConfig playlist;
|
||||||
|
const _RecordingsListView({required this.playlist});
|
||||||
@override
|
@override
|
||||||
State<_RecordingsListView> createState() => _RecordingsListViewState();
|
State<_RecordingsListView> createState() => _RecordingsListViewState();
|
||||||
}
|
}
|
||||||
@@ -821,6 +817,31 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
_fetchRecordings();
|
_fetchRecordings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
|
||||||
|
final streamUrl = rec['stream_url'] as String? ?? '';
|
||||||
|
final title = rec['title'] as String? ?? 'Enregistrement';
|
||||||
|
|
||||||
|
if (streamUrl.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('URL du flux indisponible')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => PlayerScreen(
|
||||||
|
streamId: streamUrl,
|
||||||
|
title: title,
|
||||||
|
playlist: widget.playlist,
|
||||||
|
streamType: StreamType.vod,
|
||||||
|
containerExtension: 'ts',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showLogs(String id, String title) async {
|
Future<void> _showLogs(String id, String title) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(Uri.parse('/api/recordings/logs/$id'));
|
final response = await http.get(Uri.parse('/api/recordings/logs/$id'));
|
||||||
@@ -1033,6 +1054,16 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
rec['title'] ?? '',
|
rec['title'] ?? '',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (status == 'completed')
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.play_circle_outline,
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: 'Lecture',
|
||||||
|
onPressed: () => _playRecording(context, rec),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.description_outlined,
|
Icons.description_outlined,
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Loading channels...',
|
const Text(
|
||||||
style: TextStyle(color: Colors.white70)),
|
'Loading channels...',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FutureBuilder(
|
FutureBuilder(
|
||||||
future: Future.delayed(const Duration(seconds: 8)),
|
future: Future.delayed(const Duration(seconds: 8)),
|
||||||
@@ -57,7 +59,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
if (snapshot.connectionState == ConnectionState.done) {
|
if (snapshot.connectionState == ConnectionState.done) {
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () => ref.refresh(
|
onPressed: () => ref.refresh(
|
||||||
liveChannelsByPlaylistProvider(widget.playlist)),
|
liveChannelsByPlaylistProvider(widget.playlist),
|
||||||
|
),
|
||||||
child: const Text('Taking too long? Tap to retry'),
|
child: const Text('Taking too long? Tap to retry'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,15 +78,20 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Failed to load channels',
|
Text(
|
||||||
style: GoogleFonts.inter(
|
'Failed to load channels',
|
||||||
color: Colors.white,
|
style: GoogleFonts.inter(
|
||||||
fontSize: 18,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold)),
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('$e',
|
Text(
|
||||||
style: const TextStyle(color: Colors.white54),
|
'$e',
|
||||||
textAlign: TextAlign.center),
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
|
|||||||
@@ -42,50 +42,50 @@ class MobileScaffold extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: Container(
|
bottomNavigationBar: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
border: const Border(
|
border: Border(
|
||||||
top: BorderSide(
|
top: BorderSide(
|
||||||
color: AppColors.border,
|
color: AppColors.border,
|
||||||
width: 0.5,
|
width: 0.5,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: BottomNavigationBar(
|
|
||||||
currentIndex: currentIndex,
|
|
||||||
onTap: onIndexChanged,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
elevation: 0,
|
|
||||||
selectedItemColor: AppColors.primary,
|
|
||||||
unselectedItemColor: AppColors.textSecondary,
|
|
||||||
selectedLabelStyle:
|
|
||||||
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
|
|
||||||
unselectedLabelStyle:
|
|
||||||
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
|
|
||||||
items: const [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.live_tv_rounded),
|
|
||||||
label: 'Live',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.movie_outlined),
|
|
||||||
activeIcon: Icon(Icons.movie_rounded),
|
|
||||||
label: 'Movies',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.video_library_outlined),
|
|
||||||
activeIcon: Icon(Icons.video_library_rounded),
|
|
||||||
label: 'Series',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
|
||||||
label: 'Settings',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
child: BottomNavigationBar(
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
onTap: onIndexChanged,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
elevation: 0,
|
||||||
|
selectedItemColor: AppColors.primary,
|
||||||
|
unselectedItemColor: AppColors.textSecondary,
|
||||||
|
selectedLabelStyle:
|
||||||
|
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
|
||||||
|
unselectedLabelStyle:
|
||||||
|
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.live_tv_rounded),
|
||||||
|
label: 'Live',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.movie_outlined),
|
||||||
|
activeIcon: Icon(Icons.movie_rounded),
|
||||||
|
label: 'Movies',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.video_library_outlined),
|
||||||
|
activeIcon: Icon(Icons.video_library_rounded),
|
||||||
|
label: 'Series',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user