Merge pull request #9 from R0m1k3/claude/fonctionnalites-frontend

Fonctionnalités : guide TV + Season Passes rebranchés, favoris, reprise de lecture, enregistrements mobile
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-28 08:53:40 +02:00
commit dfc8a33916
10 files changed
+294 -47

No files matched your search

+8
View File
@@ -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)
@@ -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<DashboardScreen> {
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<String>(
tooltip: 'Profil',
color: AppColors.surfaceContainerHigh,
offset: const Offset(56, -12),
itemBuilder: (context) => [
PopupMenuItem<String>(
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<String>(
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,
+4 -2
View File
@@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
});
}
// 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,
@@ -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<SeriesDetailScreen> {
}
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<SeriesDetailScreen> {
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episodeDuration,
startTime: resumeAt,
),
),
);
+54 -2
View File
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
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<LiveTVTab>
),
],
),
// 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,
@@ -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<MoviesTab> {
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<MoviesTab> {
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
duration: movieDuration,
startTime: resumeAt,
),
),
);
+119 -19
View File
@@ -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<RecordingsTab> createState() => _RecordingsTabState();
}
class _RecordingsTabState extends State<RecordingsTab> {
class _RecordingsTabState extends State<RecordingsTab>
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<RecordingsTab> {
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<void> _deleteRecording(String id) async {
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
/// immédiatement, sans retour ni possibilité d'annuler.
Future<void> _deleteRecording(String id, String title) async {
final confirmed = await showDialog<bool>(
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'] ?? '',
),
),
],
),
@@ -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<MobileDashboardScreen> {
_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);
}
}
}
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
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(
+5
View File
@@ -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),