mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 05:58:05 +02:00
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:
10 files changed
+293
-46
No files matched your search
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Non publié
|
## 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
|
### 🔧 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é
|
- **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)
|
- **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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../auth/providers/auth_provider.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
const Spacer(),
|
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(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
child: TvFocusableCard(
|
child: PopupMenuButton<String>(
|
||||||
onTap: () {},
|
tooltip: 'Profil',
|
||||||
borderRadius: 50,
|
color: AppColors.surfaceContainerHigh,
|
||||||
scaleFactor: 1.1,
|
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(
|
child: const CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: AppColors.surfaceContainerHigh,
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
|||||||
@@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update watch history if relevant
|
// Update watch history if relevant. Jamais en live : la « position »
|
||||||
if (currentTime > 0) {
|
// 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(
|
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||||
widget.streamId,
|
widget.streamId,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import 'player_screen.dart';
|
import 'player_screen.dart';
|
||||||
@@ -288,6 +289,13 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
streamType: StreamType.series,
|
streamType: StreamType.series,
|
||||||
containerExtension: episode.containerExtension ?? 'mkv',
|
containerExtension: episode.containerExtension ?? 'mkv',
|
||||||
duration: episodeDuration,
|
duration: episodeDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
body: channelsAsync.when(
|
body: channelsAsync.when(
|
||||||
loading: () => const ThemedLoading(),
|
loading: () => const ThemedLoading(),
|
||||||
error: (e, s) => Center(
|
error: (e, s) => Center(
|
||||||
child: Text('Error: $e',
|
child: Column(
|
||||||
style: const TextStyle(color: AppColors.onSurface)),
|
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) {
|
data: (groupedChannels) {
|
||||||
var categories = groupedChannels.keys.toList();
|
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)
|
// Record Button Icon Overlay (Positioned top right)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
|
|||||||
import '../../../core/widgets/hero_carousel.dart';
|
import '../../../core/widgets/hero_carousel.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
streamType: StreamType.vod,
|
streamType: StreamType.vod,
|
||||||
containerExtension: movie.containerExtension ?? 'mp4',
|
containerExtension: movie.containerExtension ?? 'mp4',
|
||||||
duration: movieDuration,
|
duration: movieDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import '../screens/player_screen.dart';
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// ENTRÉE — Onglet "Enregistrements"
|
// 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 {
|
class RecordingsTab extends StatefulWidget {
|
||||||
@@ -25,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
|
|||||||
State<RecordingsTab> createState() => _RecordingsTabState();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -33,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
color: Colors.grey[900],
|
color: Colors.grey[900],
|
||||||
child: const Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
const Row(
|
||||||
SizedBox(width: 12),
|
children: [
|
||||||
Text(
|
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
||||||
'Enregistrements',
|
SizedBox(width: 12),
|
||||||
style: TextStyle(
|
Text(
|
||||||
fontSize: 26,
|
'Enregistrements',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: AppColors.onSurface,
|
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(
|
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'));
|
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
|
||||||
_fetchRecordings();
|
_fetchRecordings();
|
||||||
}
|
}
|
||||||
@@ -986,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null
|
: _error != null
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Column(
|
||||||
_error ?? 'Erreur',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(color: AppColors.live),
|
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
|
: _recordings.isEmpty
|
||||||
@@ -1157,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
tooltip: 'Supprimer',
|
tooltip: 'Supprimer',
|
||||||
onPressed: () =>
|
onPressed: () => _deleteRecording(
|
||||||
_deleteRecording(rec['id']),
|
rec['id'],
|
||||||
|
rec['title'] ?? '',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/models/playlist_config.dart';
|
import '../../../../core/models/playlist_config.dart';
|
||||||
import '../../../widgets/mobile_scaffold.dart';
|
import '../../../widgets/mobile_scaffold.dart';
|
||||||
import '../../../theme/mobile_theme.dart';
|
import '../../../theme/mobile_theme.dart';
|
||||||
|
import '../../../../features/iptv/widgets/recordings_tab.dart';
|
||||||
import '../widgets/mobile_live_tv_tab.dart';
|
import '../widgets/mobile_live_tv_tab.dart';
|
||||||
import '../widgets/mobile_movies_tab.dart';
|
import '../widgets/mobile_movies_tab.dart';
|
||||||
import '../widgets/mobile_series_tab.dart';
|
import '../widgets/mobile_series_tab.dart';
|
||||||
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
|
|||||||
_currentIndex = index;
|
_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 Channel channel;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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 =
|
final iconUrl =
|
||||||
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
||||||
? '/api/xtream/${channel.streamIcon}'
|
? '/api/xtream/${channel.streamIcon}'
|
||||||
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
|
|||||||
activeIcon: Icon(Icons.video_library_rounded),
|
activeIcon: Icon(Icons.video_library_rounded),
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.videocam_outlined),
|
||||||
|
activeIcon: Icon(Icons.videocam_rounded),
|
||||||
|
label: 'REC',
|
||||||
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: Icon(Icons.settings_outlined),
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
|||||||
Reference in new issue
Block a user