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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-09-26 22:21:56 +02:00
1 parent 9ce707a702
commit 60c7f4cece
6 files changed
+396 -241

No files matched your search

@@ -98,6 +98,14 @@ class XtreamServiceMobile {
/// changing any call site.
final Map<String, model.Channel> _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<String, List<model.Channel>> _lastLiveChannels = {};
List<model.Category>? _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<List<model.Channel>> getLiveChannels(String categoryId) async {
final channels = await _fetchLiveChannels(categoryId);
if (channels.isNotEmpty) {
_lastLiveChannels[categoryId] = channels;
return channels;
}
return _lastLiveChannels[categoryId] ?? channels;
}
Future<List<model.Channel>> _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<List<model.Category>> getLiveCategories() async {
final categories = await _fetchLiveCategories();
if (categories.isNotEmpty) {
_lastLiveCategories = categories;
return categories;
}
return _lastLiveCategories ?? categories;
}
Future<List<model.Category>> _fetchLiveCategories() async {
try {
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
+244 -171
View File
@@ -27,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
}
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
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<MobileLiveTVTab>
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<MobileLiveTVTab>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
@@ -72,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@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<void> _refreshChannels() async {
if (_isRefreshing) return;
setState(() => _isRefreshing = true);
refreshLiveCatalog(ref);
final categoryId = ref.read(mobileLiveTvUiStateProvider).selectedCategoryId;
try {
await Future.wait<Object>([
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<MobileLiveTVTab>
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,
),
),
),
],
],
),
),
);
},
@@ -225,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
final settings = ref.watch(mobileSettingsProvider);
final watchHistory = ref.watch(mobileWatchHistoryProvider);
@@ -216,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
@override
Widget build(BuildContext context) {
super.build(context);
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
final settings = ref.watch(mobileSettingsProvider);
if (_categories.isEmpty && !_isLoading) {
@@ -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<MobileSettingsTab> {
),
),
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<MobileSettingsTab> {
_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<MobileSettingsTab> {
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<MobileSettingsTab> {
_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<MobileSettingsTab> {
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<MobileSettingsTab> {
} 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<MobileSettingsTab> {
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<MobileSettingsTab> {
),
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<MobileSettingsTab> {
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<MobileSettingsTab> {
),
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<MobileSettingsTab> {
],
),
),
Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20),
Icon(Icons.edit,
color: AppDecorations.textSecondary(context), size: 20),
],
),
),
@@ -30,10 +30,44 @@ final mobileXtreamServiceProvider =
}
});
// A full catalogue refresh from the settings also drops the cached EPG.
ref.listen<int>(
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<DateTime>((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<int>((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<List<model.Category>, PlaylistConfig>(
@@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider =
});
/// Mobile-specific live channels provider (loads channels for a specific category)
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
List<model.Channel>,
(PlaylistConfig, String)>((ref, params) async {
final mobileLiveChannelsByCategoryProvider =
FutureProvider.family<List<model.Channel>, (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<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
FutureProvider.family<List<model.VodItem>, 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<List<model.Series>,
(PlaylistConfig, String?)>((ref, params) async {
final mobileSeriesProvider =
FutureProvider.family<List<model.Series>, (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<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
(ref, request) async {
final service =
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
return service.getSeriesInfo(request.seriesId);