diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f57cd..b1dfa75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Non publié +### ✨ Fonctionnalités +- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte +- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter +- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions +- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule +- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard) +- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes + ### 🔧 Fiabilité des enregistrements - **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé - **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin) diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 288dc31..49a73b8 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../auth/providers/auth_provider.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/glass_container.dart'; @@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState { const Spacer(), - // Settings / Profile + // Profil : nom d'utilisateur + déconnexion. L'avatar était + // un bouton mort (onTap vide) — aucune déconnexion possible + // depuis le dashboard desktop. Padding( padding: const EdgeInsets.only(bottom: 24), - child: TvFocusableCard( - onTap: () {}, - borderRadius: 50, - scaleFactor: 1.1, + child: PopupMenuButton( + tooltip: 'Profil', + color: AppColors.surfaceContainerHigh, + offset: const Offset(56, -12), + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + child: Row( + children: [ + const Icon( + Icons.person, + size: 18, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + ref.read(authProvider).currentUser?.username ?? + 'Utilisateur', + style: const TextStyle( + color: AppColors.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon( + Icons.logout, + size: 18, + color: AppColors.textSecondary, + ), + SizedBox(width: 8), + Text( + 'Déconnexion', + style: TextStyle(color: AppColors.onSurface), + ), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'logout') { + ref.read(authProvider.notifier).logout(); + } + }, child: const CircleAvatar( radius: 20, backgroundColor: AppColors.surfaceContainerHigh, diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 786ff87..dad525e 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState { }); } - // Update watch history if relevant - if (currentTime > 0) { + // Update watch history if relevant. Jamais en live : la « position » + // d'un flux continu n'a pas de sens et polluait le stockage avec une + // entrée bidon par chaîne zappée. + if (currentTime > 0 && widget.streamType != StreamType.live) { ref.read(playbackPositionsProvider.notifier).savePosition( widget.streamId, currentTime, diff --git a/lib/features/iptv/screens/series_detail_screen.dart b/lib/features/iptv/screens/series_detail_screen.dart index db2deef..0e934e3 100644 --- a/lib/features/iptv/screens/series_detail_screen.dart +++ b/lib/features/iptv/screens/series_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; +import '../providers/playback_positions_provider.dart'; import '../providers/watch_history_provider.dart'; import '../../../core/theme/app_colors.dart'; import 'player_screen.dart'; @@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState { } if (!context.mounted) return; - + + // Reprise de lecture : le player sauvegarde la position sous + // episode.id, on la relit ici pour reprendre où on s'était arrêté. + final positions = ref.read(playbackPositionsProvider); + final resumeAt = positions.hasPosition(episode.id) + ? positions.getPosition(episode.id) + : null; + Navigator.push( context, MaterialPageRoute( @@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState { streamType: StreamType.series, containerExtension: episode.containerExtension ?? 'mkv', duration: episodeDuration, + startTime: resumeAt, ), ), ); diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 25999ad..11083d4 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState body: channelsAsync.when( loading: () => const ThemedLoading(), error: (e, s) => Center( - child: Text('Error: $e', - style: const TextStyle(color: AppColors.onSurface)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Impossible de charger les chaînes', + style: TextStyle(color: AppColors.onSurface), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Réessayer'), + onPressed: () => ref.invalidate( + liveChannelsByPlaylistProvider(widget.playlist), + ), + ), + ], + ), ), data: (groupedChannels) { var categories = groupedChannels.keys.toList(); @@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState ), ], ), + // Favori (Positioned top left) — toggleFavorite n'était appelé + // nulle part : le filtre « Favoris » affichait toujours vide. + Positioned( + top: 8, + left: 8, + child: Consumer( + builder: (context, ref, _) { + final isFav = ref + .watch(favoritesProvider) + .contains(channel.streamId); + return TvFocusableCard( + onTap: () => ref + .read(favoritesProvider.notifier) + .toggleFavorite(channel.streamId), + borderRadius: 20, + scaleFactor: 1.2, + semanticLabel: isFav + ? 'Retirer ${channel.name} des favoris' + : 'Ajouter ${channel.name} aux favoris', + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: Icon( + isFav ? Icons.favorite : Icons.favorite_border, + color: isFav + ? AppColors.primary + : AppColors.onSurface54, + size: 16, + ), + ), + ); + }, + ), + ), // Record Button Icon Overlay (Positioned top right) Positioned( top: 8, diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index d9f2507..792d925 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart'; import '../../../core/widgets/hero_carousel.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; +import '../providers/playback_positions_provider.dart'; import '../providers/watch_history_provider.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; @@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState { if (!mounted) return; + // Reprise de lecture : les positions étaient sauvegardées par le player + // mais jamais relues — le film repartait systématiquement de zéro. + final positions = ref.read(playbackPositionsProvider); + final resumeAt = positions.hasPosition(movie.streamId) + ? positions.getPosition(movie.streamId) + : null; + Navigator.push( context, MaterialPageRoute( @@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState { streamType: StreamType.vod, containerExtension: movie.containerExtension ?? 'mp4', duration: movieDuration, + startTime: resumeAt, ), ), ); diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index dd05c84..8225235 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -15,6 +15,10 @@ import '../screens/player_screen.dart'; // ═══════════════════════════════════════════════════════════════════════════ // ENTRÉE — Onglet "Enregistrements" +// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste), +// Season Passes (enregistrements récurrents). _EpgGuideView et +// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis +// une refonte : les fonctions étaient codées mais inaccessibles. // ═══════════════════════════════════════════════════════════════════════════ class RecordingsTab extends StatefulWidget { @@ -25,7 +29,24 @@ class RecordingsTab extends StatefulWidget { State createState() => _RecordingsTabState(); } -class _RecordingsTabState extends State { +class _RecordingsTabState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + // Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le + // plus fréquent ; le guide sert à en programmer de nouveaux. + _tabController = TabController(length: 3, vsync: this, initialIndex: 1); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Container( @@ -33,25 +54,59 @@ class _RecordingsTabState extends State { child: Column( children: [ Container( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), color: Colors.grey[900], - child: const Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.videocam, color: AppColors.onSurface, size: 28), - SizedBox(width: 12), - Text( - 'Enregistrements', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: AppColors.onSurface, - ), + const Row( + children: [ + Icon(Icons.videocam, color: AppColors.onSurface, size: 28), + SizedBox(width: 12), + Text( + 'Enregistrements', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: AppColors.onSurface, + ), + ), + ], + ), + const SizedBox(height: 8), + TabBar( + controller: _tabController, + isScrollable: true, + indicatorColor: AppColors.primary, + labelColor: AppColors.onSurface, + unselectedLabelColor: AppColors.onSurface54, + tabs: const [ + Tab( + icon: Icon(Icons.calendar_month, size: 18), + text: 'Guide TV', + ), + Tab( + icon: Icon(Icons.fiber_manual_record, size: 18), + text: 'Enregistrements', + ), + Tab( + icon: Icon(Icons.repeat, size: 18), + text: 'Season Passes', + ), + ], ), ], ), ), Expanded( - child: _RecordingsListView(playlist: widget.playlist), + child: TabBarView( + controller: _tabController, + children: [ + _EpgGuideView(playlist: widget.playlist), + _RecordingsListView(playlist: widget.playlist), + const _SeasonPassesView(), + ], + ), ), ], ), @@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> { } } - Future _deleteRecording(String id) async { + /// Demande confirmation avant suppression : l'ancienne corbeille supprimait + /// immédiatement, sans retour ni possibilité d'annuler. + Future _deleteRecording(String id, String title) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surfaceContainer, + title: Text( + 'Supprimer l\'enregistrement ?', + style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18), + ), + content: Text( + '« $title » et son fichier seront définitivement supprimés.', + style: const TextStyle(color: AppColors.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.errorContainer, + foregroundColor: AppColors.onErrorContainer, + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true) return; + await AuthedHttp.delete(Uri.parse('/api/recordings/$id')); _fetchRecordings(); } @@ -986,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> { ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( - child: Text( - _error ?? 'Erreur', - style: const TextStyle(color: AppColors.live), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Impossible de charger les enregistrements', + style: TextStyle(color: AppColors.live), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Réessayer'), + onPressed: _fetchRecordings, + ), + ], ), ) : _recordings.isEmpty @@ -1157,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> { size: 20, ), tooltip: 'Supprimer', - onPressed: () => - _deleteRecording(rec['id']), + onPressed: () => _deleteRecording( + rec['id'], + rec['title'] ?? '', + ), ), ], ), diff --git a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart index d8c1b64..9753bc9 100644 --- a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../widgets/mobile_scaffold.dart'; import '../../../theme/mobile_theme.dart'; +import '../../../../features/iptv/widgets/recordings_tab.dart'; import '../widgets/mobile_live_tv_tab.dart'; import '../widgets/mobile_movies_tab.dart'; import '../widgets/mobile_series_tab.dart'; @@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState { _currentIndex = index; }); }, - child: _buildActiveTab(), + // IndexedStack conserve l'état des onglets (position de scroll, + // catalogues chargés) : l'ancien switch reconstruisait tout à chaque + // changement d'onglet. Même approche que le dashboard desktop. + child: IndexedStack( + index: _currentIndex, + children: [ + MobileLiveTVTab(playlist: widget.playlist), + MobileMoviesTab(playlist: widget.playlist), + MobileSeriesTab(playlist: widget.playlist), + RecordingsTab(playlist: widget.playlist), + const MobileSettingsTab(), + ], + ), ), ); } - - Widget _buildActiveTab() { - switch (_currentIndex) { - case 0: - return MobileLiveTVTab(playlist: widget.playlist); - case 1: - return MobileMoviesTab(playlist: widget.playlist); - case 2: - return MobileSeriesTab(playlist: widget.playlist); - case 3: - return const MobileSettingsTab(); - default: - return MobileLiveTVTab(playlist: widget.playlist); - } - } } diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index ccce9a1..0a76605 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState { } } -class _MobileChannelTile extends StatelessWidget { +class _MobileChannelTile extends ConsumerWidget { final Channel channel; final VoidCallback onTap; @@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris » + // de l'en-tête affichait toujours une liste vide. + final isFav = ref.watch(favoritesProvider).contains(channel.streamId); final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') ? '/api/xtream/${channel.streamIcon}' @@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), + IconButton( + visualDensity: VisualDensity.compact, + tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris', + icon: Icon( + isFav ? Icons.favorite : Icons.favorite_border, + color: isFav ? AppColors.primary : AppColors.onSurface54, + size: 20, + ), + onPressed: () => ref + .read(favoritesProvider.notifier) + .toggleFavorite(channel.streamId), + ), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 0d2a71e..2ee2256 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget { activeIcon: Icon(Icons.video_library_rounded), label: 'Series', ), + BottomNavigationBarItem( + icon: Icon(Icons.videocam_outlined), + activeIcon: Icon(Icons.videocam_rounded), + label: 'REC', + ), BottomNavigationBarItem( icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings_rounded),