diff --git a/CHANGELOG.md b/CHANGELOG.md index ea84bf0..481d259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 📝 Changelog - XtremFlow Optimisations +## Non publié + +### 📺 Enregistrements +- La liste des enregistrements se met à jour automatiquement : rafraîchissement immédiat dès qu'un enregistrement est créé/arrêté n'importe où dans l'app (guide EPG, modal, widget rapide), et polling en arrière-plan (5 s quand un enregistrement est en cours ou planifié, 20 s sinon) pour suivre les statuts sans clic manuel +- Indicateur « Suivi auto » avec heure de dernière actualisation dans l'onglet Enregistrements + ## Version 1.2 - Security, Streaming & Design Overhaul (10 Juin 2026) ### 🔐 Sécurité diff --git a/lib/features/iptv/providers/recordings_refresh.dart b/lib/features/iptv/providers/recordings_refresh.dart new file mode 100644 index 0000000..b7b06e4 --- /dev/null +++ b/lib/features/iptv/providers/recordings_refresh.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; + +/// Bus de notification global pour la liste des enregistrements. +/// +/// L'onglet Enregistrements vit dans un IndexedStack : il n'est construit +/// qu'une seule fois au lancement. Tout code qui crée/modifie un +/// enregistrement (modal d'enregistrement, guide EPG, ...) appelle +/// [notifyRecordingsChanged] pour que la liste se recharge immédiatement, +/// sans attendre le prochain cycle de polling. +final ValueNotifier recordingsRefreshBus = ValueNotifier(0); + +void notifyRecordingsChanged() { + recordingsRefreshBus.value++; +} diff --git a/lib/features/iptv/widgets/recording_modal.dart b/lib/features/iptv/widgets/recording_modal.dart index 0914cb7..1c5352f 100644 --- a/lib/features/iptv/widgets/recording_modal.dart +++ b/lib/features/iptv/widgets/recording_modal.dart @@ -6,6 +6,7 @@ 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'; +import '../providers/recordings_refresh.dart'; class RecordingModal extends StatefulWidget { final Channel channel; @@ -59,6 +60,7 @@ class _RecordingModalState extends State { ); if (response.statusCode == 200) { + notifyRecordingsChanged(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Enregistrement programmé !')), diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index ed44cd9..08de7bb 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +7,7 @@ import '../../../core/api/authed_http.dart'; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; +import '../providers/recordings_refresh.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; import '../screens/player_screen.dart'; @@ -603,6 +605,7 @@ class _ProgrammeCard extends StatelessWidget { 'end_time': end.toIso8601String(), }), ); + if (response.statusCode == 200) notifyRecordingsChanged(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -733,37 +736,83 @@ class _RecordingsListViewState extends State<_RecordingsListView> { List _recordings = []; bool _isLoading = true; String? _error; + Timer? _pollTimer; + DateTime? _lastRefresh; + + // Polling rapproché quand un enregistrement est en cours, plus espacé sinon. + static const _activeInterval = Duration(seconds: 5); + static const _idleInterval = Duration(seconds: 20); @override void initState() { super.initState(); + recordingsRefreshBus.addListener(_onExternalChange); _fetchRecordings(); } - Future _fetchRecordings() async { - setState(() { - _isLoading = true; - _error = null; - }); + @override + void dispose() { + recordingsRefreshBus.removeListener(_onExternalChange); + _pollTimer?.cancel(); + super.dispose(); + } + + /// Un enregistrement vient d'être créé/modifié ailleurs dans l'app. + void _onExternalChange() { + _fetchRecordings(silent: true); + } + + bool get _hasActiveOrPending => _recordings.any((rec) { + final status = rec is Map ? rec['status'] as String? : null; + return status == 'recording' || status == 'scheduled'; + }); + + void _scheduleNextPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer( + _hasActiveOrPending ? _activeInterval : _idleInterval, + () => _fetchRecordings(silent: true), + ); + } + + /// [silent] : recharge en arrière-plan sans spinner ni vidage de la liste, + /// pour que le suivi automatique ne fasse pas clignoter l'écran. + Future _fetchRecordings({bool silent = false}) async { + if (!silent) { + setState(() { + _isLoading = true; + _error = null; + }); + } try { final response = await AuthedHttp.get(Uri.parse('/api/recordings')); + if (!mounted) return; if (response.statusCode == 200) { setState(() { final decoded = json.decode(response.body); _recordings = decoded is List ? decoded : []; _isLoading = false; + _error = null; + _lastRefresh = DateTime.now(); }); - } else { + } else if (!silent) { setState(() { _error = 'Erreur ${response.statusCode}'; _isLoading = false; }); } } catch (e) { - setState(() { - _error = '$e'; - _isLoading = false; - }); + if (!mounted) return; + // En mode silencieux on garde la liste affichée plutôt que + // de remplacer l'écran par une erreur transitoire. + if (!silent) { + setState(() { + _error = '$e'; + _isLoading = false; + }); + } + } finally { + if (mounted) _scheduleNextPoll(); } } @@ -896,10 +945,27 @@ class _RecordingsListViewState extends State<_RecordingsListView> { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + Icon( + Icons.autorenew, + color: _hasActiveOrPending + ? AppColors.success + : AppColors.onSurface38, + size: 14, + ), + const SizedBox(width: 6), + Text( + _lastRefresh == null + ? 'Suivi automatique' + : 'Suivi auto · ${_lastRefresh!.hour.toString().padLeft(2, '0')}:${_lastRefresh!.minute.toString().padLeft(2, '0')}:${_lastRefresh!.second.toString().padLeft(2, '0')}', + style: const TextStyle( + color: AppColors.onSurface38, + fontSize: 11, + ), + ), IconButton( icon: const Icon(Icons.refresh, color: AppColors.onSurfaceVariant), onPressed: _fetchRecordings, - tooltip: 'Rafraîchir', + tooltip: 'Rafraîchir maintenant', ), ], ), diff --git a/lib/features/iptv/widgets/simple_recording_widget.dart b/lib/features/iptv/widgets/simple_recording_widget.dart index aedc42c..6ae87a8 100644 --- a/lib/features/iptv/widgets/simple_recording_widget.dart +++ b/lib/features/iptv/widgets/simple_recording_widget.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/iptv_models.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/glass_container.dart'; +import '../providers/recordings_refresh.dart'; class SimpleRecordingWidget extends StatefulWidget { final Channel channel; @@ -43,6 +44,7 @@ class _SimpleRecordingWidgetState extends State { ); if (response.statusCode == 200) { + notifyRecordingsChanged(); setState(() { _isRecording = true; _status = '🔴 Recording for $minutes min'; @@ -79,6 +81,7 @@ class _SimpleRecordingWidgetState extends State { ); if (response.statusCode == 200) { + notifyRecordingsChanged(); setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}'); if (mounted) { @@ -101,6 +104,7 @@ class _SimpleRecordingWidgetState extends State { headers: {'Content-Type': 'application/json'}, ); + notifyRecordingsChanged(); setState(() { _isRecording = false; _status = 'Stopped';