From 6bb0d73f38aa6c28003ab491253ad0a790662aef Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 29 Mar 2026 10:04:47 +0200 Subject: [PATCH] fix: restore EPG display in live TV player and channel cards - Fix EPG not showing in NativePlayerScreen by reusing the shared mobileXtreamServiceProvider (cache already populated by channel cards) instead of creating a fresh service instance per player session - Add periodic EPG refresh timer (every 5 min) matching LitePlayerScreen - Fix empty-title fallback in _buildEPGBox - Force dark theme, invert default live TV player (NativePlayer default), remove next-program display from OSD - Fix back button exit dialog firing from wrong tab (add tab index guards) - Halve card grid count for Movies/Series (6 categories, 8 items) - Revamp Movies/Series search bars to match Live TV aesthetic - Fix first channel row clipping when entering a category (scroll-to-top) - Fix playlist edit dialog TV remote navigation - Fix empty playlist screen button readability - Remove theme switcher from settings Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 25 +- lib/core/providers/theme_provider.dart | 22 +- .../mobile_playlist_selection_screen.dart | 484 ++++++++++++------ .../iptv/screens/native_player_screen.dart | 29 +- .../iptv/widgets/mobile_live_tv_tab.dart | 29 +- .../iptv/widgets/mobile_movies_tab.dart | 158 +++--- .../iptv/widgets/mobile_series_tab.dart | 158 +++--- .../iptv/widgets/mobile_settings_tab.dart | 9 +- 8 files changed, 564 insertions(+), 350 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 90be5a6..33c9950 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,30 @@ "WebFetch(domain:www.color-hex.com)", "WebFetch(domain:iosexample.com)", "WebFetch(domain:blog.karenying.com)", - "WebFetch(domain:gist.github.com)" + "WebFetch(domain:gist.github.com)", + "Bash(~/.flutter/bin/flutter analyze:*)", + "Bash(where flutter:*)", + "Read(//c/Users/Michael/**)", + "Bash(/c/Users/Michael/Git/flutter/bin/flutter analyze:*)", + "Bash(/c/Users/Michael/Git/flutter/bin/flutter build:*)", + "Bash(echo \"ANDROID_HOME=$ANDROID_HOME\")", + "Bash(echo \"ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT\")", + "Bash(java -version)", + "Bash(where java:*)", + "Bash(echo \"JAVA_HOME=$JAVA_HOME\")", + "Bash(winget --version)", + "Bash(winget install:*)", + "Bash(export JAVA_HOME=\"/c/Program Files/Microsoft/jdk-21.0.10.7-hotspot\")", + "Bash(export PATH=\"$JAVA_HOME/bin:$PATH\")", + "Bash(export JAVA_HOME=\"/c/Program Files/Microsoft/jdk-21.0.10.7-hotspot\" export ANDROID_HOME=\"/c/Users/Michael/Android/Sdk\" export PATH=\"$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH\" __NEW_LINE_a84857a0b65daa44__ SDKMANAGER=\"/c/Users/Michael/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat\" __NEW_LINE_a84857a0b65daa44__ yes)", + "Bash(\"$SDKMANAGER\" --licenses)", + "Bash(export ANDROID_HOME=\"/c/Users/Michael/Android/Sdk\")", + "Bash(SDKMANAGER=\"/c/Users/Michael/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat\")", + "Bash(__NEW_LINE_633e55f3dba9c695__ \"$SDKMANAGER\" \"platform-tools\" \"platforms;android-35\" \"build-tools;35.0.0\")", + "Bash(export PATH=\"$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH\")", + "Bash(__NEW_LINE_fa059bc1c566f6ee__ cd:*)", + "Bash(__NEW_LINE_70fecd5c4080fe72__ cd:*)", + "Bash(__NEW_LINE_2a0d1cacdabfd662__ cd:*)" ] } } diff --git a/lib/core/providers/theme_provider.dart b/lib/core/providers/theme_provider.dart index 4a97d09..5c07d1e 100644 --- a/lib/core/providers/theme_provider.dart +++ b/lib/core/providers/theme_provider.dart @@ -41,26 +41,8 @@ class ThemeNotifier extends StateNotifier { _loadTheme(); } - /// Load saved theme preference from SharedPreferences - Future _loadTheme() async { - try { - final prefs = await SharedPreferences.getInstance(); - final savedMode = prefs.getString(_ThemeKeys.themeMode); - - if (savedMode != null) { - final appMode = AppThemeMode.values.firstWhere( - (e) => e.name == savedMode, - orElse: () => AppThemeMode.dark, - ); - state = state.copyWith( - appThemeMode: appMode, - themeMode: _mapToThemeMode(appMode), - ); - } - } catch (e) { - debugPrint('Error loading theme: $e'); - } - } + /// Theme is always forced to dark — no loading from preferences + Future _loadTheme() async {} /// Set theme mode and persist to SharedPreferences Future setThemeMode(AppThemeMode mode) async { diff --git a/lib/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/features/iptv/screens/mobile_playlist_selection_screen.dart index a07031c..05a7661 100644 --- a/lib/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/services/playlist_api_service.dart'; import '../../../../core/models/playlist_config.dart'; +import '../../../../mobile/widgets/tv_focusable.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../mobile/theme/mobile_theme.dart'; @@ -45,155 +46,37 @@ class _MobilePlaylistSelectionScreenState Future _showPlaylistDialog({PlaylistConfig? playlist}) async { final isEditing = playlist != null; - final nameController = TextEditingController(text: playlist?.name ?? ''); - final dnsController = TextEditingController(text: playlist?.dns ?? ''); - final userController = - TextEditingController(text: playlist?.username ?? ''); - final passController = - TextEditingController(text: playlist?.password ?? ''); - await showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppColors.surface, - title: Text( - isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist', - style: GoogleFonts.inter(color: Colors.white), - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Nom', - labelStyle: TextStyle(color: Colors.white70), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24), - ), - ), - ), - const SizedBox(height: 8), - TextField( - controller: dnsController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'URL (DNS)', - labelStyle: TextStyle(color: Colors.white70), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24), - ), - ), - ), - const SizedBox(height: 8), - TextField( - controller: userController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Utilisateur', - labelStyle: TextStyle(color: Colors.white70), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24), - ), - ), - ), - const SizedBox(height: 8), - TextField( - controller: passController, - obscureText: true, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Mot de passe', - labelStyle: TextStyle(color: Colors.white70), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24), - ), - ), - ), - ], - ), - ), - actions: [ - if (isEditing) - TextButton( - onPressed: () async { - final confirm = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.surface, - title: const Text( - 'Confirmer', - style: TextStyle(color: Colors.white), - ), - content: const Text( - 'Supprimer cette playlist ?', - style: TextStyle(color: Colors.white70), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Annuler'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: - TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Supprimer'), - ), - ], - ), - ); - - if (confirm == true) { - await PlaylistApiService().deletePlaylist(playlist.id); - - if (context.mounted) { - Navigator.pop(context); - ref.invalidate(playlistsProvider); - } - } - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Supprimer'), - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () async { - if (nameController.text.isEmpty || dnsController.text.isEmpty) { - return; - } - - final service = PlaylistApiService(); - if (isEditing) { - await service.updatePlaylist( - id: playlist.id, - name: nameController.text, - dns: dnsController.text, - username: userController.text, - password: passController.text, - ); - } else { - await service.createPlaylist( - name: nameController.text, - dns: dnsController.text, - username: userController.text, - password: passController.text, - ); - } - - if (context.mounted) { - Navigator.pop(context); + builder: (context) => _PlaylistEditDialog( + isEditing: isEditing, + playlist: playlist, + onSave: (name, dns, username, password) async { + final service = PlaylistApiService(); + if (isEditing) { + await service.updatePlaylist( + id: playlist.id, + name: name, + dns: dns, + username: username, + password: password, + ); + } else { + await service.createPlaylist( + name: name, + dns: dns, + username: username, + password: password, + ); + } + ref.invalidate(playlistsProvider); + }, + onDelete: isEditing + ? () async { + await PlaylistApiService().deletePlaylist(playlist.id); ref.invalidate(playlistsProvider); } - }, - child: const Text('Enregistrer'), - ), - ], + : null, ), ); } @@ -271,16 +154,32 @@ class _MobilePlaylistSelectionScreenState ), ), const SizedBox(height: 32), - ElevatedButton.icon( + TVFocusable( onPressed: () => _showPlaylistDialog(), - icon: const Icon(Icons.add), - label: const Text('Ajouter une Playlist'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.black, + borderRadius: BorderRadius.circular(12), + child: Container( padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + horizontal: 28, + vertical: 14, + ), + decoration: BoxDecoration( + color: const Color(0xFF0A84FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, color: Colors.white, size: 20), + SizedBox(width: 10), + Text( + 'Ajouter une Playlist', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ], ), ), ), @@ -322,6 +221,285 @@ class _MobilePlaylistSelectionScreenState } } +/// TV-friendly playlist edit dialog. +/// Each field is a focusable row; pressing select opens a single-field input dialog. +class _PlaylistEditDialog extends StatefulWidget { + final bool isEditing; + final PlaylistConfig? playlist; + final Future Function(String name, String dns, String username, String password) onSave; + final Future Function()? onDelete; + + const _PlaylistEditDialog({ + required this.isEditing, + required this.playlist, + required this.onSave, + this.onDelete, + }); + + @override + State<_PlaylistEditDialog> createState() => _PlaylistEditDialogState(); +} + +class _PlaylistEditDialogState extends State<_PlaylistEditDialog> { + late String _name; + late String _dns; + late String _username; + late String _password; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _name = widget.playlist?.name ?? ''; + _dns = widget.playlist?.dns ?? ''; + _username = widget.playlist?.username ?? ''; + _password = widget.playlist?.password ?? ''; + } + + Future _editField({ + required String label, + required String currentValue, + required void Function(String) onSaved, + bool obscure = false, + TextInputType keyboardType = TextInputType.text, + }) async { + final controller = TextEditingController(text: currentValue); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text(label, style: const TextStyle(color: Colors.white)), + content: TextField( + controller: controller, + autofocus: true, + obscureText: obscure, + keyboardType: keyboardType, + textInputAction: TextInputAction.done, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white), + ), + ), + onSubmitted: (val) => Navigator.pop(ctx, val), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + autofocus: false, + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('OK'), + ), + ], + ), + ); + controller.dispose(); + if (result != null) { + onSaved(result); + } + } + + Widget _buildFieldRow({ + required IconData icon, + required String label, + required String value, + required VoidCallback onTap, + bool obscure = false, + }) { + final displayValue = obscure && value.isNotEmpty + ? '•' * value.length.clamp(1, 12) + : value.isEmpty + ? '—' + : value; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TVFocusable( + onPressed: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.primary, size: 22), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + displayValue, + style: TextStyle( + color: value.isEmpty ? Colors.white38 : Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon(Icons.edit, color: Colors.white38, size: 18), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist', + style: GoogleFonts.inter(color: Colors.white), + ), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFieldRow( + icon: Icons.label_outline, + label: 'Nom', + value: _name, + onTap: () => _editField( + label: 'Nom', + currentValue: _name, + onSaved: (v) => setState(() => _name = v), + ), + ), + _buildFieldRow( + icon: Icons.link, + label: 'URL (DNS)', + value: _dns, + onTap: () => _editField( + label: 'URL (DNS)', + currentValue: _dns, + keyboardType: TextInputType.url, + onSaved: (v) => setState(() => _dns = v), + ), + ), + _buildFieldRow( + icon: Icons.person_outline, + label: 'Utilisateur', + value: _username, + onTap: () => _editField( + label: 'Utilisateur', + currentValue: _username, + onSaved: (v) => setState(() => _username = v), + ), + ), + _buildFieldRow( + icon: Icons.lock_outline, + label: 'Mot de passe', + value: _password, + obscure: true, + onTap: () => _editField( + label: 'Mot de passe', + currentValue: _password, + obscure: true, + onSaved: (v) => setState(() => _password = v), + ), + ), + ], + ), + ), + actions: [ + if (widget.onDelete != null) + TVFocusable( + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: const Text('Confirmer', style: TextStyle(color: Colors.white)), + content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirm == true) { + await widget.onDelete!(); + if (context.mounted) Navigator.pop(context); + } + }, + borderRadius: BorderRadius.circular(8), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text('Supprimer', style: TextStyle(color: Colors.red)), + ), + ), + TVFocusable( + onPressed: () => Navigator.pop(context), + borderRadius: BorderRadius.circular(8), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text('Annuler', style: TextStyle(color: Colors.white70)), + ), + ), + TVFocusable( + onPressed: _isSaving || _name.isEmpty || _dns.isEmpty + ? null + : () async { + setState(() => _isSaving = true); + await widget.onSave(_name, _dns, _username, _password); + if (context.mounted) Navigator.pop(context); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: _name.isEmpty || _dns.isEmpty + ? Colors.white12 + : AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _isSaving ? '...' : 'Enregistrer', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + } +} + class _MobilePlaylistCard extends StatelessWidget { final PlaylistConfig playlist; final VoidCallback onTap; diff --git a/lib/features/iptv/screens/native_player_screen.dart b/lib/features/iptv/screens/native_player_screen.dart index 6f3acd4..98a4f5d 100644 --- a/lib/features/iptv/screens/native_player_screen.dart +++ b/lib/features/iptv/screens/native_player_screen.dart @@ -11,6 +11,7 @@ import 'package:xtremobile/core/models/iptv_models.dart' as model; import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; +import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/core/theme/app_colors.dart'; import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -86,6 +87,7 @@ class _NativePlayerScreenState extends ConsumerState Timer? _clockTimer; Timer? _controlsTimer; // Auto-hide timer + Timer? _epgTimer; // Periodic EPG refresh for live TV Timer? _liveWatchdog; // Watchdog for live stream reconnection int _reconnectAttempts = 0; static const int _maxReconnectAttempts = 5; @@ -464,6 +466,7 @@ class _NativePlayerScreenState extends ConsumerState _clockTimer?.cancel(); _controlsTimer?.cancel(); + _epgTimer?.cancel(); _liveWatchdog?.cancel(); // Stop playback first to prevent audio continuing in background @@ -702,9 +705,13 @@ class _NativePlayerScreenState extends ConsumerState ? widget.channels![_currentIndex].streamId : widget.streamId; - // Load EPG if Live TV + // Load EPG if Live TV (with periodic refresh every 5 min) if (widget.streamType == model.StreamType.live) { _loadEpg(currentStreamId); + _epgTimer?.cancel(); + _epgTimer = Timer.periodic(const Duration(minutes: 5), (_) { + _loadEpg(currentStreamId); + }); } // ... (rest of _loadStream logic: get url, open player) @@ -806,7 +813,9 @@ class _NativePlayerScreenState extends ConsumerState Future _loadEpg(String streamId) async { try { - final epg = await _xtreamService!.getShortEPG(streamId); + // Use shared provider service so EPG cached by channel cards is reused instantly + final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + final epg = await service.getShortEPG(streamId); if (mounted) { setState(() => _epg = epg); } @@ -1323,7 +1332,8 @@ class _NativePlayerScreenState extends ConsumerState } Widget _buildEPGBox(String title) { - String nowPlaying = _epg?.nowPlaying ?? "Pas d'infos EPG"; + final rawNow = _epg?.nowPlaying ?? ''; + String nowPlaying = rawNow.isNotEmpty ? rawNow : "Pas d'infos EPG"; String nextPlaying = _epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : ""; @@ -1375,19 +1385,6 @@ class _NativePlayerScreenState extends ConsumerState ), ], ), - if (nextPlaying.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - nextPlaying, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), ], ); } diff --git a/lib/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/features/iptv/widgets/mobile_live_tv_tab.dart index c024c32..0cddc59 100644 --- a/lib/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/features/iptv/widgets/mobile_live_tv_tab.dart @@ -27,6 +27,7 @@ class MobileLiveTVTab extends ConsumerStatefulWidget { class _MobileLiveTVTabState extends ConsumerState with AutomaticKeepAliveClientMixin { + final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String _searchQuery = ''; @@ -60,6 +61,7 @@ class _MobileLiveTVTabState extends ConsumerState @override void dispose() { + _scrollController.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); _searchTimer?.cancel(); @@ -80,13 +82,24 @@ class _MobileLiveTVTabState extends ConsumerState final uiState = ref.watch(mobileLiveTvUiStateProvider); final uiNotifier = ref.read(mobileLiveTvUiStateProvider.notifier); + // Scroll to top when entering a category so first row is fully visible + ref.listen(mobileLiveTvUiStateProvider, (previous, next) { + if (previous?.isCategoryView == true && !next.isCategoryView) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + }); + } + }); + // Determine if we should show the category grid final bool showGrid = uiState.isCategoryView && _searchQuery.isEmpty && !_showFavoritesOnly; return Container( - color: const Color(0xFF000000), // Pure black background + color: const Color(0xFF000000), child: Scaffold( backgroundColor: Colors.transparent, body: PopScope( @@ -95,6 +108,9 @@ class _MobileLiveTVTabState extends ConsumerState onPopInvokedWithResult: (didPop, _) async { if (didPop) return; + // Only handle back if this tab is currently active (tab index 0) + if (ref.read(mobileDashboardIndexProvider) != 0) return; + // If just returned from player, reset the flag and do nothing (stay on channel list) if (_justReturnedFromPlayer) { _justReturnedFromPlayer = false; @@ -194,6 +210,7 @@ class _MobileLiveTVTabState extends ConsumerState return SafeArea( bottom: false, child: CustomScrollView( + controller: _scrollController, slivers: [ // Floating Header (Search + Navigation) SliverAppBar( @@ -447,14 +464,13 @@ class _MobileLiveTVTabState extends ConsumerState await Navigator.push( context, MaterialPageRoute( - builder: (context) => NativePlayerScreen( + builder: (context) => LitePlayerScreen( streamId: channel.streamId, title: channel.name, playlist: playlist, streamType: StreamType.live, - channels: channels, // Pass full channel list for zapping + channels: channels, initialIndex: index, - forceDeinterlace: true, ), ), ); @@ -462,13 +478,14 @@ class _MobileLiveTVTabState extends ConsumerState await Navigator.push( context, MaterialPageRoute( - builder: (context) => LitePlayerScreen( + builder: (context) => NativePlayerScreen( streamId: channel.streamId, title: channel.name, playlist: playlist, streamType: StreamType.live, - channels: channels, // Pass full channel list for zapping + channels: channels, initialIndex: index, + forceDeinterlace: false, ), ), ); diff --git a/lib/features/iptv/widgets/mobile_movies_tab.dart b/lib/features/iptv/widgets/mobile_movies_tab.dart index 6839b98..54b92bc 100644 --- a/lib/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/features/iptv/widgets/mobile_movies_tab.dart @@ -264,6 +264,9 @@ class _MobileMoviesTabState extends ConsumerState onPopInvokedWithResult: (didPop, _) async { if (didPop) return; + // Only handle back if this tab is currently active (tab index 1) + if (ref.read(mobileDashboardIndexProvider) != 1) return; + // Handle Search Exit if (_searchQuery.isNotEmpty || _isSearching) { _onSearchChanged(''); // clear search @@ -327,94 +330,101 @@ class _MobileMoviesTabState extends ConsumerState // Pinned Header SliverAppBar( pinned: true, - floating: true, - backgroundColor: AppDecorations.background(context).gradient is LinearGradient - ? (AppDecorations.background(context).gradient as LinearGradient).colors.first - : Theme.of(context).scaffoldBackgroundColor, - elevation: 4, + floating: false, + snap: false, + backgroundColor: const Color(0xFF000000), + elevation: 0, + scrolledUnderElevation: 0, automaticallyImplyLeading: false, - expandedHeight: showingMovies ? 110 : 110, - collapsedHeight: showingMovies ? 110 : 110, + toolbarHeight: showingMovies ? 120.0 : 110.0, flexibleSpace: FlexibleSpaceBar( - background: Padding( - padding: const EdgeInsets.only(top: 8.0), + background: Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (showingMovies) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + // Search Bar + TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + SystemChannels.textInput.invokeMethod('TextInput.show'); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + decoration: AppDecorations.searchBar(context), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: _onBack, - ), + Icon(Icons.search, color: AppDecorations.textSecondary(context)), const SizedBox(width: 8), Expanded( - child: Text( - _selectedCategoryName ?? 'Films', - style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: Colors.white, + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle(color: AppDecorations.textPrimary(context)), + decoration: InputDecoration( + hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...', + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => setState(() => _isSearchEditing = false), + ), ), ), + if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); _onSearchChanged(''); }, + child: Icon(Icons.close, color: AppDecorations.textSecondary(context)), + ), ], ), - ) - else - const SizedBox(height: 10), + ), + ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: TVFocusable( - scale: 1.0, - focusColor: Colors.white, - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: AppDecorations.searchBar(context), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: AppDecorations.textPrimary(context), - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)), - decoration: InputDecoration( - hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...', - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.only(bottom: 11), - ), - onChanged: _onSearchChanged, - onSubmitted: (_) => setState(() => _isSearchEditing = false), - ), - ), - ), - if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)), - ), - ], + const SizedBox(height: 12), + + // Navigation Row + Row( + children: [ + if (showingMovies) ...[ + IconButton( + icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)), + onPressed: _onBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + showingMovies + ? (_selectedCategoryName ?? 'Films') + : showingSearch + ? 'Résultats de recherche' + : 'Catégories', + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, ), ), - ), + ], ), ], ), @@ -427,7 +437,7 @@ class _MobileMoviesTabState extends ConsumerState padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: showingCategories ? 3 : 4, + crossAxisCount: showingCategories ? 6 : 8, crossAxisSpacing: 10, mainAxisSpacing: 10, childAspectRatio: showingCategories ? 2.0 : 0.67, diff --git a/lib/features/iptv/widgets/mobile_series_tab.dart b/lib/features/iptv/widgets/mobile_series_tab.dart index def8c6f..292d3fb 100644 --- a/lib/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/features/iptv/widgets/mobile_series_tab.dart @@ -251,6 +251,9 @@ class _MobileSeriesTabState extends ConsumerState onPopInvokedWithResult: (didPop, _) async { if (didPop) return; + // Only handle back if this tab is currently active (tab index 2) + if (ref.read(mobileDashboardIndexProvider) != 2) return; + if (_searchQuery.isNotEmpty || _isSearching) { _onSearchChanged(''); _searchController.clear(); @@ -297,94 +300,101 @@ class _MobileSeriesTabState extends ConsumerState // Pinned Header SliverAppBar( pinned: true, - floating: true, - backgroundColor: AppDecorations.background(context).gradient is LinearGradient - ? (AppDecorations.background(context).gradient as LinearGradient).colors.first - : Theme.of(context).scaffoldBackgroundColor, - elevation: 4, + floating: false, + snap: false, + backgroundColor: const Color(0xFF000000), + elevation: 0, + scrolledUnderElevation: 0, automaticallyImplyLeading: false, - expandedHeight: showingSeries ? 110 : 110, - collapsedHeight: showingSeries ? 110 : 110, + toolbarHeight: showingSeries ? 120.0 : 110.0, flexibleSpace: FlexibleSpaceBar( - background: Padding( - padding: const EdgeInsets.only(top: 8.0), + background: Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (showingSeries) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + // Search Bar + TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + SystemChannels.textInput.invokeMethod('TextInput.show'); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + decoration: AppDecorations.searchBar(context), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: _onBack, - ), + Icon(Icons.search, color: AppDecorations.textSecondary(context)), const SizedBox(width: 8), Expanded( - child: Text( - _selectedCategoryName ?? 'Séries', - style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: Colors.white, + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle(color: AppDecorations.textPrimary(context)), + decoration: InputDecoration( + hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...', + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => setState(() => _isSearchEditing = false), + ), ), ), + if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); _onSearchChanged(''); }, + child: Icon(Icons.close, color: AppDecorations.textSecondary(context)), + ), ], ), - ) - else - const SizedBox(height: 10), + ), + ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: TVFocusable( - scale: 1.0, - focusColor: Colors.white, - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: AppDecorations.searchBar(context), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: AppDecorations.textPrimary(context), - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)), - decoration: InputDecoration( - hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...', - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.only(bottom: 11), - ), - onChanged: _onSearchChanged, - onSubmitted: (_) => setState(() => _isSearchEditing = false), - ), - ), - ), - if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)), - ), - ], + const SizedBox(height: 12), + + // Navigation Row + Row( + children: [ + if (showingSeries) ...[ + IconButton( + icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)), + onPressed: _onBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + showingSeries + ? (_selectedCategoryName ?? 'Séries') + : showingSearch + ? 'Résultats de recherche' + : 'Catégories', + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, ), ), - ), + ], ), ], ), @@ -396,7 +406,7 @@ class _MobileSeriesTabState extends ConsumerState padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: showingCategories ? 3 : 4, + crossAxisCount: showingCategories ? 6 : 8, crossAxisSpacing: 10, mainAxisSpacing: 10, childAspectRatio: showingCategories ? 2.0 : 0.67, diff --git a/lib/features/iptv/widgets/mobile_settings_tab.dart b/lib/features/iptv/widgets/mobile_settings_tab.dart index 12662c6..5da25d9 100644 --- a/lib/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/features/iptv/widgets/mobile_settings_tab.dart @@ -117,10 +117,6 @@ class _MobileSettingsTabState extends ConsumerState { // === APPARENCE === _buildSectionHeader('Apparence'), - _buildThemeSwitcher(), - - const SizedBox(height: 8), - _buildSettingItem( icon: Icons.access_time, title: 'Afficher l\'heure', @@ -360,8 +356,9 @@ class _MobileSettingsTabState extends ConsumerState { final selected = themeState.appThemeMode == mode; final accentColor = Theme.of(context).colorScheme.primary; return Expanded( - child: GestureDetector( - onTap: () => notifier.setThemeMode(mode), + child: TVFocusable( + onPressed: () => notifier.setThemeMode(mode), + borderRadius: BorderRadius.circular(8), child: AnimatedContainer( duration: const Duration(milliseconds: 200), margin: const EdgeInsets.symmetric(horizontal: 2),