mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
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:
1 parent
9ce707a702
commit
60c7f4cece
6 files changed
+198
-43
No files matched your search
@@ -98,6 +98,14 @@ class XtreamServiceMobile {
|
|||||||
/// changing any call site.
|
/// changing any call site.
|
||||||
final Map<String, model.Channel> _channelIndex = {};
|
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);
|
late final XmltvService _xmltv = XmltvService(cacheDir);
|
||||||
|
|
||||||
/// User-provided XMLTV URL (empty = not configured).
|
/// User-provided XMLTV URL (empty = not configured).
|
||||||
@@ -200,7 +208,18 @@ class XtreamServiceMobile {
|
|||||||
|
|
||||||
/// Get live channels for a category (with batch EPG support)
|
/// Get live channels for a category (with batch EPG support)
|
||||||
/// If categoryId is empty, fetches ALL channels from all categories
|
/// 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 {
|
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 {
|
try {
|
||||||
final queryParams = {
|
final queryParams = {
|
||||||
'username': _username,
|
'username': _username,
|
||||||
@@ -463,7 +482,18 @@ class XtreamServiceMobile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get live TV categories
|
/// Get live TV categories
|
||||||
|
///
|
||||||
|
/// Falls back to the last good list when the fetch fails.
|
||||||
Future<List<model.Category>> getLiveCategories() async {
|
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 {
|
try {
|
||||||
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
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 ScrollController _scrollController = ScrollController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
@@ -36,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
bool _isSearchEditing = false;
|
bool _isSearchEditing = false;
|
||||||
bool _justReturnedFromPlayer =
|
bool _justReturnedFromPlayer =
|
||||||
false; // Flag to prevent PopScope interception after player
|
false; // Flag to prevent PopScope interception after player
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
|
||||||
Timer? _searchTimer;
|
Timer? _searchTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
|
||||||
_searchTimer = null;
|
_searchTimer = null;
|
||||||
_searchController.addListener(() {
|
_searchController.addListener(() {
|
||||||
@@ -62,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
@@ -72,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -210,15 +252,20 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _refreshChannels,
|
||||||
|
color: AppColors.primary,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
// Floating Header (Search + Navigation)
|
// Floating Header (Search + Navigation)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
snap: false,
|
snap: false,
|
||||||
backgroundColor: const Color(0xFF000000), // Opaque black
|
backgroundColor:
|
||||||
|
const Color(0xFF000000), // Opaque black
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
@@ -249,7 +296,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: AppDecorations.searchBar(context),
|
decoration:
|
||||||
|
AppDecorations.searchBar(context),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12),
|
horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -269,7 +317,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
readOnly: !_isSearchEditing,
|
readOnly: !_isSearchEditing,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppDecorations.textPrimary(
|
color:
|
||||||
|
AppDecorations.textPrimary(
|
||||||
context),
|
context),
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -292,7 +341,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
_searchController.clear(),
|
_searchController.clear(),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
color: AppDecorations.textSecondary(
|
color:
|
||||||
|
AppDecorations.textSecondary(
|
||||||
context),
|
context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -312,8 +362,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back,
|
Icons.arrow_back,
|
||||||
color:
|
color: AppDecorations.textPrimary(
|
||||||
AppDecorations.textPrimary(context),
|
context),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
@@ -341,17 +391,38 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
? 'Favoris'
|
? 'Favoris'
|
||||||
: showGrid
|
: showGrid
|
||||||
? 'Catégories'
|
? 'Catégories'
|
||||||
: uiState.selectedCategory ??
|
: uiState
|
||||||
|
.selectedCategory ??
|
||||||
'Chaînes',
|
'Chaînes',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color: AppDecorations.textPrimary(
|
||||||
AppDecorations.textPrimary(context),
|
context),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_showFavoritesOnly
|
_showFavoritesOnly
|
||||||
@@ -363,7 +434,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
context),
|
context),
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showFavoritesOnly = !_showFavoritesOnly;
|
_showFavoritesOnly =
|
||||||
|
!_showFavoritesOnly;
|
||||||
if (_showFavoritesOnly) {
|
if (_showFavoritesOnly) {
|
||||||
uiNotifier.state = uiState.copyWith(
|
uiNotifier.state = uiState.copyWith(
|
||||||
isCategoryView: false,
|
isCategoryView: false,
|
||||||
@@ -402,6 +474,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
|
|
||||||
if (_categories.isEmpty && !_isLoading) {
|
if (_categories.isEmpty && !_isLoading) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.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_settings_providers.dart';
|
||||||
|
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||||
import 'package:xtremobile/core/theme/app_theme.dart';
|
import 'package:xtremobile/core/theme/app_theme.dart';
|
||||||
@@ -106,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Version 1.6.1',
|
'Version 1.7.2',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppDecorations.textSecondary(context),
|
color: AppDecorations.textSecondary(context),
|
||||||
@@ -179,8 +180,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
icon: Icons.public,
|
icon: Icons.public,
|
||||||
title: 'EPG communautaire',
|
title: 'EPG communautaire',
|
||||||
subtitle:
|
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
|
||||||
'Repli quand le fournisseur ne renvoie aucun programme',
|
|
||||||
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(mobileSettingsProvider.notifier)
|
.read(mobileSettingsProvider.notifier)
|
||||||
@@ -197,9 +197,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
onTap: () => _showKeyboardDialog(
|
onTap: () => _showKeyboardDialog(
|
||||||
'URL XMLTV',
|
'URL XMLTV',
|
||||||
_xmltvController,
|
_xmltvController,
|
||||||
(val) => ref
|
(val) =>
|
||||||
.read(mobileSettingsProvider.notifier)
|
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
|
||||||
.setXmltvUrl(val),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -254,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
|
|
||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
||||||
title: 'Actualiser le cache',
|
title: 'Forcer l\'actualisation',
|
||||||
subtitle: 'Recharge films, séries et EPG',
|
subtitle: 'Recharge chaînes, films, séries et EPG',
|
||||||
value: _isRefreshingCache ? '...' : 'Appuyer',
|
value: _isRefreshingCache ? '...' : 'Appuyer',
|
||||||
onTap: _isRefreshingCache ? null : _refreshCache,
|
onTap: _isRefreshingCache ? null : _refreshCache,
|
||||||
),
|
),
|
||||||
@@ -319,15 +318,15 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
setState(() => _isRefreshingCache = true);
|
setState(() => _isRefreshingCache = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// final dir = await getApplicationDocumentsDirectory();
|
refreshFullCatalog(ref);
|
||||||
// final cacheStore = HiveCacheStore(dir.path);
|
// Brief pause so the tap visibly registers on a TV remote.
|
||||||
// await cacheStore.clean();
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
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,
|
backgroundColor: AppColors.success,
|
||||||
duration: Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
@@ -336,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -392,21 +392,25 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
children: segments.map((seg) {
|
children: segments.map((seg) {
|
||||||
final (mode, icon, label) = seg;
|
final (mode, icon, label) = seg;
|
||||||
final selected = themeState.appThemeMode == mode;
|
final selected = themeState.appThemeMode == mode;
|
||||||
final accentColor = Theme.of(context).colorScheme.primary;
|
final accentColor =
|
||||||
|
Theme.of(context).colorScheme.primary;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: TVFocusable(
|
child: TVFocusable(
|
||||||
onPressed: () => notifier.setThemeMode(mode),
|
onPressed: () => notifier.setThemeMode(mode),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
margin:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 2),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: 7,
|
vertical: 7,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected
|
color: selected
|
||||||
? accentColor
|
? accentColor
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -417,7 +421,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
size: 16,
|
size: 16,
|
||||||
color: selected
|
color: selected
|
||||||
? AppColors.onSurface
|
? AppColors.onSurface
|
||||||
: AppDecorations.textSecondary(context),
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
@@ -429,7 +434,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
: FontWeight.w500,
|
: FontWeight.w500,
|
||||||
color: selected
|
color: selected
|
||||||
? AppColors.onSurface
|
? AppColors.onSurface
|
||||||
: AppDecorations.textSecondary(context),
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -483,7 +489,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -514,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
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),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -562,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
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);
|
ref.onDispose(service.dispose);
|
||||||
return service;
|
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)
|
/// Mobile-specific live categories provider (for Live TV category grid)
|
||||||
final mobileLiveCategoriesProvider =
|
final mobileLiveCategoriesProvider =
|
||||||
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
||||||
@@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific live channels provider (loads channels for a specific category)
|
/// Mobile-specific live channels provider (loads channels for a specific category)
|
||||||
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
|
final mobileLiveChannelsByCategoryProvider =
|
||||||
List<model.Channel>,
|
FutureProvider.family<List<model.Channel>, (PlaylistConfig, String)>(
|
||||||
(PlaylistConfig, String)>((ref, params) async {
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getLiveChannels(categoryId);
|
return service.getLiveChannels(categoryId);
|
||||||
@@ -79,8 +113,9 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||||
int startIdx = batchNum * batchSize;
|
int startIdx = batchNum * batchSize;
|
||||||
int endIdx =
|
int endIdx = (startIdx + batchSize < categories.length)
|
||||||
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
|
? startIdx + batchSize
|
||||||
|
: categories.length;
|
||||||
final batch = categories.sublist(startIdx, endIdx);
|
final batch = categories.sublist(startIdx, endIdx);
|
||||||
|
|
||||||
print(
|
print(
|
||||||
@@ -120,7 +155,8 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
/// Mobile-specific movies provider
|
/// Mobile-specific movies provider
|
||||||
final mobileMoviesProvider =
|
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);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getMoviesByCategory(""); // Fetch all or default
|
return service.getMoviesByCategory(""); // Fetch all or default
|
||||||
});
|
});
|
||||||
@@ -134,8 +170,9 @@ final mobileSeriesCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific series pagination provider
|
/// Mobile-specific series pagination provider
|
||||||
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
|
final mobileSeriesProvider =
|
||||||
(PlaylistConfig, String?)>((ref, params) async {
|
FutureProvider.family<List<model.Series>, (PlaylistConfig, String?)>(
|
||||||
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getSeriesPaginated(categoryId: categoryId);
|
return service.getSeriesPaginated(categoryId: categoryId);
|
||||||
@@ -161,7 +198,8 @@ class SeriesInfoRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final mobileSeriesInfoByPlaylistProvider =
|
final mobileSeriesInfoByPlaylistProvider =
|
||||||
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
|
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
|
||||||
|
(ref, request) async {
|
||||||
final service =
|
final service =
|
||||||
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
||||||
return service.getSeriesInfo(request.seriesId);
|
return service.getSeriesInfo(request.seriesId);
|
||||||
|
|||||||
Reference in new issue
Block a user