Rafraîchissement automatique de la page Enregistrements

- Nouveau bus de notification (recordingsRefreshBus) : la liste se recharge
  immédiatement dès qu'un enregistrement est créé, planifié ou arrêté depuis
  n'importe où dans l'app (guide EPG, modal, widget rapide)
- Polling en arrière-plan : 5 s quand un enregistrement est en cours ou
  planifié, 20 s sinon, pour suivre les changements de statut sans clic
- Rafraîchissement silencieux (pas de spinner ni de vidage de liste) pour
  éviter le clignotement ; les erreurs transitoires ne remplacent pas la liste
- Indicateur « Suivi auto » avec l'heure de dernière actualisation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
This commit is contained in:
Claude committed 2026-08-27 11:15:48 +00:00
1 parent f798b43a1e
commit 488e354bf0
5 files changed
+103 -11

No files matched your search

+6
View File
@@ -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é
@@ -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<int> recordingsRefreshBus = ValueNotifier<int>(0);
void notifyRecordingsChanged() {
recordingsRefreshBus.value++;
}
@@ -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<RecordingModal> {
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enregistrement programmé !')),
+77 -11
View File
@@ -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<dynamic> _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<void> _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<void> _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',
),
],
),
@@ -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<SimpleRecordingWidget> {
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
setState(() {
_isRecording = true;
_status = '🔴 Recording for $minutes min';
@@ -79,6 +81,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}');
if (mounted) {
@@ -101,6 +104,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
headers: {'Content-Type': 'application/json'},
);
notifyRecordingsChanged();
setState(() {
_isRecording = false;
_status = 'Stopped';