From 60c7f4cecef48ec5b3d793e1e00ce90ad883a724 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 26 Sep 2026 22:21:45 +0200 Subject: [PATCH] feat(live): refresh the channel list on demand and on resume Live categories and channels were fetched once per app session and kept in memory until the process died, so a TV box left running never picked up provider changes. - Live tab: pull-to-refresh plus a remote-friendly refresh button, and an automatic refetch when the app returns to the foreground after 4 hours. - Settings: the "refresh cache" entry was a no-op delay; it now forces a reload of live channels, movies, series and EPG. - The Xtream service keeps the last non-empty live lists and serves them when a refetch fails, so a refresh on a not-yet-connected network does not blank the screen. Co-Authored-By: Claude Opus 5.5 --- .../iptv/services/xtream_service_mobile.dart | 30 ++ .../iptv/widgets/mobile_live_tv_tab.dart | 415 ++++++++++-------- .../iptv/widgets/mobile_movies_tab.dart | 1 + .../iptv/widgets/mobile_series_tab.dart | 1 + .../iptv/widgets/mobile_settings_tab.dart | 134 +++--- .../providers/mobile_xtream_providers.dart | 56 ++- 6 files changed, 396 insertions(+), 241 deletions(-) diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 2ad1c94..8958a49 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -98,6 +98,14 @@ class XtreamServiceMobile { /// changing any call site. final Map _channelIndex = {}; + /// Last non-empty live lists, keyed by category id ('' = all channels). + /// + /// The fetchers swallow errors and return `[]`, so a refresh attempted while + /// the network is down (typically right after a TV wakes from standby) would + /// otherwise replace a good list with an empty screen. + final Map> _lastLiveChannels = {}; + List? _lastLiveCategories; + late final XmltvService _xmltv = XmltvService(cacheDir); /// User-provided XMLTV URL (empty = not configured). @@ -200,7 +208,18 @@ class XtreamServiceMobile { /// Get live channels for a category (with batch EPG support) /// If categoryId is empty, fetches ALL channels from all categories + /// + /// Falls back to the last good list for this category when the fetch fails. Future> getLiveChannels(String categoryId) async { + final channels = await _fetchLiveChannels(categoryId); + if (channels.isNotEmpty) { + _lastLiveChannels[categoryId] = channels; + return channels; + } + return _lastLiveChannels[categoryId] ?? channels; + } + + Future> _fetchLiveChannels(String categoryId) async { try { final queryParams = { 'username': _username, @@ -463,7 +482,18 @@ class XtreamServiceMobile { } /// Get live TV categories + /// + /// Falls back to the last good list when the fetch fails. Future> getLiveCategories() async { + final categories = await _fetchLiveCategories(); + if (categories.isNotEmpty) { + _lastLiveCategories = categories; + return categories; + } + return _lastLiveCategories ?? categories; + } + + Future> _fetchLiveCategories() async { try { if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...'); diff --git a/lib/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/features/iptv/widgets/mobile_live_tv_tab.dart index 859c829..af970b1 100644 --- a/lib/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/features/iptv/widgets/mobile_live_tv_tab.dart @@ -27,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget { } class _MobileLiveTVTabState extends ConsumerState - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { + /// Coming back to the foreground after this long refetches the channel list, + /// so an app left running for days on a TV box picks up provider changes. + static const _autoRefreshAfter = Duration(hours: 4); + final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); @@ -36,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState bool _isSearchEditing = false; bool _justReturnedFromPlayer = false; // Flag to prevent PopScope interception after player + bool _isRefreshing = false; Timer? _searchTimer; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + // Anchor the auto-refresh clock to this first load. + ref.read(liveCatalogRefreshedAtProvider); // [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds _searchTimer = null; _searchController.addListener(() { @@ -62,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _scrollController.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); @@ -72,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState @override bool get wantKeepAlive => true; + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + final lastRefresh = ref.read(liveCatalogRefreshedAtProvider); + if (DateTime.now().difference(lastRefresh) >= _autoRefreshAfter) { + _refreshChannels(); + } + } + + /// Refetch categories and the open category's channels. The current lists + /// stay on screen until the new ones arrive. + Future _refreshChannels() async { + if (_isRefreshing) return; + setState(() => _isRefreshing = true); + + refreshLiveCatalog(ref); + final categoryId = ref.read(mobileLiveTvUiStateProvider).selectedCategoryId; + try { + await Future.wait([ + ref.read(mobileLiveCategoriesProvider(widget.playlist).future), + if (categoryId != null) + ref.read( + mobileLiveChannelsByCategoryProvider((widget.playlist, categoryId)) + .future, + ), + ]); + } catch (_) { + // Errors surface through the providers' own error states. + } finally { + if (mounted) setState(() => _isRefreshing = false); + } + } + @override Widget build(BuildContext context) { super.build(context); @@ -210,197 +252,228 @@ class _MobileLiveTVTabState extends ConsumerState return SafeArea( bottom: false, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - // Floating Header (Search + Navigation) - SliverAppBar( - floating: false, - pinned: true, - snap: false, - backgroundColor: const Color(0xFF000000), // Opaque black - elevation: 0, - scrolledUnderElevation: 0, - automaticallyImplyLeading: false, - toolbarHeight: _searchQuery.isNotEmpty || - _showFavoritesOnly || - !uiState.isCategoryView - ? 120.0 - : 110.0, - flexibleSpace: FlexibleSpaceBar( - background: Container( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Search Bar - TVFocusable( - scale: 1.0, - focusColor: AppColors.onSurface, - 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: [ - Icon( - Icons.search, - color: AppDecorations.textSecondary( - context), - ), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: AppColors.onSurface, - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle( - color: AppDecorations.textPrimary( - context), - ), - decoration: const InputDecoration( - hintText: - 'Rechercher une chaîne...', - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - isDense: true, - ), - onSubmitted: (_) => setState( - () => _isSearchEditing = false, + child: RefreshIndicator( + onRefresh: _refreshChannels, + color: AppColors.primary, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Floating Header (Search + Navigation) + SliverAppBar( + floating: false, + pinned: true, + snap: false, + backgroundColor: + const Color(0xFF000000), // Opaque black + elevation: 0, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + toolbarHeight: _searchQuery.isNotEmpty || + _showFavoritesOnly || + !uiState.isCategoryView + ? 120.0 + : 110.0, + flexibleSpace: FlexibleSpaceBar( + background: Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Search Bar + TVFocusable( + scale: 1.0, + focusColor: AppColors.onSurface, + 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: [ + Icon( + Icons.search, + color: AppDecorations.textSecondary( + context), + ), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: AppColors.onSurface, + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle( + color: + AppDecorations.textPrimary( + context), + ), + decoration: const InputDecoration( + hintText: + 'Rechercher une chaîne...', + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + ), + onSubmitted: (_) => setState( + () => _isSearchEditing = false, + ), ), ), ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () => - _searchController.clear(), - child: Icon( - Icons.close, - color: AppDecorations.textSecondary( - context), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => + _searchController.clear(), + child: Icon( + Icons.close, + color: + AppDecorations.textSecondary( + context), + ), ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: 12), + const SizedBox(height: 12), - // Title / Navigation Row - Row( - children: [ - if (!showGrid || - _showFavoritesOnly || - _searchQuery.isNotEmpty) ...[ + // Title / Navigation Row + Row( + children: [ + if (!showGrid || + _showFavoritesOnly || + _searchQuery.isNotEmpty) ...[ + IconButton( + icon: Icon( + Icons.arrow_back, + color: AppDecorations.textPrimary( + context), + ), + onPressed: () { + if (_searchQuery.isNotEmpty) { + _searchController.clear(); + } else if (_showFavoritesOnly) { + setState( + () => _showFavoritesOnly = false, + ); + } else { + uiNotifier.state = uiState.copyWith( + isCategoryView: true, + ); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + _searchQuery.isNotEmpty + ? 'Résultats de recherche' + : _showFavoritesOnly + ? 'Favoris' + : showGrid + ? 'Catégories' + : uiState + .selectedCategory ?? + 'Chaînes', + style: TextStyle( + color: AppDecorations.textPrimary( + context), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Actualiser les chaînes', + icon: _isRefreshing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + Icons.refresh, + color: + AppDecorations.textSecondary( + context), + ), + onPressed: _isRefreshing + ? null + : _refreshChannels, + ), IconButton( icon: Icon( - Icons.arrow_back, - color: - AppDecorations.textPrimary(context), + _showFavoritesOnly + ? Icons.favorite + : Icons.favorite_border, + color: _showFavoritesOnly + ? AppColors.live + : AppDecorations.textSecondary( + context), ), - onPressed: () { - if (_searchQuery.isNotEmpty) { - _searchController.clear(); - } else if (_showFavoritesOnly) { - setState( - () => _showFavoritesOnly = false, - ); - } else { + onPressed: () => setState(() { + _showFavoritesOnly = + !_showFavoritesOnly; + if (_showFavoritesOnly) { uiNotifier.state = uiState.copyWith( - isCategoryView: true, + isCategoryView: false, ); } - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), + if (!_showFavoritesOnly) { + uiNotifier.state = uiState.copyWith( + isCategoryView: true); + } + }), ), - const SizedBox(width: 12), ], - Expanded( - child: Text( - _searchQuery.isNotEmpty - ? 'Résultats de recherche' - : _showFavoritesOnly - ? 'Favoris' - : showGrid - ? 'Catégories' - : uiState.selectedCategory ?? - 'Chaînes', - style: TextStyle( - color: - AppDecorations.textPrimary(context), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: Icon( - _showFavoritesOnly - ? Icons.favorite - : Icons.favorite_border, - color: _showFavoritesOnly - ? AppColors.live - : AppDecorations.textSecondary( - context), - ), - onPressed: () => setState(() { - _showFavoritesOnly = !_showFavoritesOnly; - if (_showFavoritesOnly) { - uiNotifier.state = uiState.copyWith( - isCategoryView: false, - ); - } - if (!_showFavoritesOnly) { - uiNotifier.state = uiState.copyWith( - isCategoryView: true); - } - }), - ), - ], - ), - ], + ), + ], + ), ), ), ), - ), - // Main Content Sliver - if (showGrid) - _buildCategorySliver(categories, uiNotifier, uiState) - else - _ChannelListView( - playlist: widget.playlist, - uiState: uiState, - searchQuery: _searchQuery, - showFavoritesOnly: _showFavoritesOnly, - onPlay: (channel, channels, index) => _playChannel( - context, - channel, - channels, - widget.playlist, - index, + // Main Content Sliver + if (showGrid) + _buildCategorySliver(categories, uiNotifier, uiState) + else + _ChannelListView( + playlist: widget.playlist, + uiState: uiState, + searchQuery: _searchQuery, + showFavoritesOnly: _showFavoritesOnly, + onPlay: (channel, channels, index) => _playChannel( + context, + channel, + channels, + widget.playlist, + index, + ), ), - ), - ], + ], + ), ), ); }, diff --git a/lib/features/iptv/widgets/mobile_movies_tab.dart b/lib/features/iptv/widgets/mobile_movies_tab.dart index 48641d0..0923ee2 100644 --- a/lib/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/features/iptv/widgets/mobile_movies_tab.dart @@ -225,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin + ref.listen(fullCatalogRefreshTickProvider, (_, __) => _refresh()); final settings = ref.watch(mobileSettingsProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider); diff --git a/lib/features/iptv/widgets/mobile_series_tab.dart b/lib/features/iptv/widgets/mobile_series_tab.dart index 18a0ac3..1775cfb 100644 --- a/lib/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/features/iptv/widgets/mobile_series_tab.dart @@ -216,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState @override Widget build(BuildContext context) { super.build(context); + ref.listen(fullCatalogRefreshTickProvider, (_, __) => _refresh()); final settings = ref.watch(mobileSettingsProvider); if (_categories.isEmpty && !_isLoading) { diff --git a/lib/features/iptv/widgets/mobile_settings_tab.dart b/lib/features/iptv/widgets/mobile_settings_tab.dart index 9a19695..8d36abe 100644 --- a/lib/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/features/iptv/widgets/mobile_settings_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; // import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; +import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/core/theme/app_theme.dart'; @@ -106,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState { ), ), Text( - 'Version 1.6.1', + 'Version 1.7.2', style: GoogleFonts.inter( fontSize: 13, color: AppDecorations.textSecondary(context), @@ -179,8 +180,7 @@ class _MobileSettingsTabState extends ConsumerState { _buildSettingItem( icon: Icons.public, title: 'EPG communautaire', - subtitle: - 'Repli quand le fournisseur ne renvoie aucun programme', + subtitle: 'Repli quand le fournisseur ne renvoie aucun programme', value: settings.useCommunityEpg ? 'Activé' : 'Désactivé', onTap: () => ref .read(mobileSettingsProvider.notifier) @@ -197,9 +197,8 @@ class _MobileSettingsTabState extends ConsumerState { onTap: () => _showKeyboardDialog( 'URL XMLTV', _xmltvController, - (val) => ref - .read(mobileSettingsProvider.notifier) - .setXmltvUrl(val), + (val) => + ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val), ), ), @@ -254,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState { _buildSettingItem( icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh, - title: 'Actualiser le cache', - subtitle: 'Recharge films, séries et EPG', + title: 'Forcer l\'actualisation', + subtitle: 'Recharge chaînes, films, séries et EPG', value: _isRefreshingCache ? '...' : 'Appuyer', onTap: _isRefreshingCache ? null : _refreshCache, ), @@ -319,15 +318,15 @@ class _MobileSettingsTabState extends ConsumerState { setState(() => _isRefreshingCache = true); try { - // final dir = await getApplicationDocumentsDirectory(); - // final cacheStore = HiveCacheStore(dir.path); - // await cacheStore.clean(); - await Future.delayed(const Duration(seconds: 1)); + refreshFullCatalog(ref); + // Brief pause so the tap visibly registers on a TV remote. + await Future.delayed(const Duration(milliseconds: 600)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Cache actualisé ! Relancez l\'app pour recharger.'), + content: + Text('Chaînes, films, séries et EPG en cours de rechargement'), backgroundColor: AppColors.success, duration: Duration(seconds: 3), ), @@ -336,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error), + SnackBar( + content: Text('Erreur: $e'), backgroundColor: AppColors.error), ); } } finally { @@ -390,54 +390,60 @@ class _MobileSettingsTabState extends ConsumerState { builder: (ctx, constraints) { return Row( children: segments.map((seg) { - final (mode, icon, label) = seg; - final selected = themeState.appThemeMode == mode; - final accentColor = Theme.of(context).colorScheme.primary; - return Expanded( - child: TVFocusable( - onPressed: () => notifier.setThemeMode(mode), - borderRadius: BorderRadius.circular(8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 2), - padding: const EdgeInsets.symmetric( - vertical: 7, - ), - decoration: BoxDecoration( - color: selected - ? accentColor - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 16, - color: selected - ? AppColors.onSurface - : AppDecorations.textSecondary(context), - ), - const SizedBox(height: 3), - Text( - label, - style: TextStyle( - fontSize: 10, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, + final (mode, icon, label) = seg; + final selected = themeState.appThemeMode == mode; + final accentColor = + Theme.of(context).colorScheme.primary; + return Expanded( + child: TVFocusable( + onPressed: () => notifier.setThemeMode(mode), + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: + const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric( + vertical: 7, + ), + decoration: BoxDecoration( + color: selected + ? accentColor + : Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, color: selected ? AppColors.onSurface - : AppDecorations.textSecondary(context), + : AppDecorations.textSecondary( + context), ), - ), - ], + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: selected + ? AppColors.onSurface + : AppDecorations.textSecondary( + context), + ), + ), + ], + ), ), ), - ), - ); - }).toList(), + ); + }).toList(), ); }, ), @@ -483,7 +489,8 @@ class _MobileSettingsTabState extends ConsumerState { ), child: Row( children: [ - Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24), + Icon(icon, + color: Theme.of(context).colorScheme.primary, size: 24), const SizedBox(width: 16), Expanded( child: Column( @@ -514,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState { vertical: 6, ), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -562,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState { ), child: Row( children: [ - Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24), + Icon(icon, + color: Theme.of(context).colorScheme.primary, size: 24), const SizedBox(width: 16), Expanded( child: Column( @@ -592,7 +603,8 @@ class _MobileSettingsTabState extends ConsumerState { ], ), ), - Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20), + Icon(Icons.edit, + color: AppDecorations.textSecondary(context), size: 20), ], ), ), diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index 8600d84..076d0a2 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -30,10 +30,44 @@ final mobileXtreamServiceProvider = } }); + // A full catalogue refresh from the settings also drops the cached EPG. + ref.listen( + fullCatalogRefreshTickProvider, (_, __) => service.clearCache()); + ref.onDispose(service.dispose); return service; }); +/// When the live catalogue was last fetched or explicitly refreshed. +/// Drives the auto-refresh when the app comes back to the foreground. +final liveCatalogRefreshedAtProvider = + StateProvider((ref) => DateTime.now()); + +/// Bumped by the settings "force refresh". The movies and series tabs, which +/// hold their lists in local state, listen to it to reload. +final fullCatalogRefreshTickProvider = StateProvider((ref) => 0); + +/// Forces the live categories and channels to be refetched from the panel. +/// +/// The Xtream service is kept: it falls back to its last good lists if the +/// refetch fails, so a flaky network never empties the screen. +void refreshLiveCatalog(WidgetRef ref) { + ref.invalidate(mobileLiveCategoriesProvider); + ref.invalidate(mobileLiveChannelsByCategoryProvider); + ref.invalidate(mobileLiveChannelsProvider); + ref.read(liveCatalogRefreshedAtProvider.notifier).state = DateTime.now(); +} + +/// Forces the whole catalogue (live, movies, series) and the EPG to reload. +void refreshFullCatalog(WidgetRef ref) { + refreshLiveCatalog(ref); + ref.invalidate(mobileMoviesProvider); + ref.invalidate(mobileSeriesCategoriesProvider); + ref.invalidate(mobileSeriesProvider); + ref.invalidate(mobileSeriesInfoByPlaylistProvider); + ref.read(fullCatalogRefreshTickProvider.notifier).state++; +} + /// Mobile-specific live categories provider (for Live TV category grid) final mobileLiveCategoriesProvider = FutureProvider.family, PlaylistConfig>( @@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider = }); /// Mobile-specific live channels provider (loads channels for a specific category) -final mobileLiveChannelsByCategoryProvider = FutureProvider.family< - List, - (PlaylistConfig, String)>((ref, params) async { +final mobileLiveChannelsByCategoryProvider = + FutureProvider.family, (PlaylistConfig, String)>( + (ref, params) async { final (playlist, categoryId) = params; final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getLiveChannels(categoryId); @@ -79,8 +113,9 @@ final mobileLiveChannelsProvider = for (int batchNum = 0; batchNum < totalBatches; batchNum++) { int startIdx = batchNum * batchSize; - int endIdx = - (startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length; + int endIdx = (startIdx + batchSize < categories.length) + ? startIdx + batchSize + : categories.length; final batch = categories.sublist(startIdx, endIdx); print( @@ -120,7 +155,8 @@ final mobileLiveChannelsProvider = /// Mobile-specific movies provider final mobileMoviesProvider = - FutureProvider.family, PlaylistConfig>((ref, playlist) async { + FutureProvider.family, PlaylistConfig>( + (ref, playlist) async { final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getMoviesByCategory(""); // Fetch all or default }); @@ -134,8 +170,9 @@ final mobileSeriesCategoriesProvider = }); /// Mobile-specific series pagination provider -final mobileSeriesProvider = FutureProvider.family, - (PlaylistConfig, String?)>((ref, params) async { +final mobileSeriesProvider = + FutureProvider.family, (PlaylistConfig, String?)>( + (ref, params) async { final (playlist, categoryId) = params; final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getSeriesPaginated(categoryId: categoryId); @@ -161,7 +198,8 @@ class SeriesInfoRequest { } final mobileSeriesInfoByPlaylistProvider = - FutureProvider.family((ref, request) async { + FutureProvider.family( + (ref, request) async { final service = await ref.watch(mobileXtreamServiceProvider(request.playlist).future); return service.getSeriesInfo(request.seriesId);