Files
xtremobile/lib/features/iptv/widgets/mobile_movies_tab.dart
T
MichaelandClaude Opus 5.5 60c7f4cece 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>
2026-09-26 22:21:56 +02:00

517 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart';
import 'package:xtremobile/mobile/widgets/mobile_category_card.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileMoviesTab({super.key, required this.playlist});
@override
ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState();
}
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
// Data
List<MapEntry<String, String>> _categories = [];
List<model.VodItem> _categoryMovies = [];
String? _selectedCategoryId;
String? _selectedCategoryName;
// Search
List<model.VodItem>? _searchResults;
String _searchQuery = '';
bool _isSearchEditing = false;
bool _isSearching = false;
Timer? _searchDebounce;
// State
bool _isLoading = false;
@override
void initState() {
super.initState();
// Defer initial load
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _categories.isEmpty) {
_loadCategories();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final movies = await service.getMovies();
final results = movies
.where((m) => m.name.toLowerCase().contains(query.toLowerCase()))
.toList();
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) setState(() => _isSearching = false);
}
}
Future<void> _loadCategories() async {
if (_isLoading || !mounted) return;
setState(() => _isLoading = true);
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final categories = await service.getVodCategories();
if (mounted) {
setState(() {
_categories = categories
.map((c) => MapEntry(c.categoryId, c.categoryName))
.toList()
..sort((a, b) => a.value.compareTo(b.value));
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _selectCategory(String id, String name) async {
setState(() {
_selectedCategoryId = id;
_selectedCategoryName = name;
_categoryMovies = [];
_isLoading = true;
});
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final movies = await service.getMoviesByCategory(id);
if (mounted) {
setState(() {
_categoryMovies = movies;
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
void _onBack() {
if (_searchQuery.isNotEmpty) {
_onSearchChanged('');
_searchController.clear();
setState(() => _isSearchEditing = false);
return;
}
if (_selectedCategoryId != null) {
setState(() {
_selectedCategoryId = null;
_selectedCategoryName = null;
_categoryMovies = [];
});
return;
}
}
Future<void> _refresh() async {
// Clear service cache to force fresh data
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
service.clearCache();
} catch (_) {}
setState(() {
_categories.clear();
_selectedCategoryId = null;
_selectedCategoryName = null;
_categoryMovies.clear();
_searchResults = null;
_searchQuery = '';
_searchController.clear();
_isLoading = false;
});
await _loadCategories();
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
/// On mobile, use direct URLs (no proxy needed)
String _getImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return '';
return originalUrl;
}
void _playMovie(BuildContext context, model.VodItem movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: movie.streamId,
title: movie.name,
playlist: widget.playlist,
streamType: model.StreamType.vod,
containerExtension: movie.containerExtension,
),
),
);
}
@override
bool get wantKeepAlive => true;
@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);
// Initial load retry
if (_categories.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _categories.isEmpty) {
_loadCategories();
}
});
}
// Determine what to display
final bool showingCategories =
_searchQuery.isEmpty && _selectedCategoryId == null;
final bool showingMovies =
_searchQuery.isEmpty && _selectedCategoryId != null;
final bool showingSearch = _searchQuery.isNotEmpty;
// Filter categories based on settings
final filteredCategories = _categories
.where((entry) => settings.matchesMoviesFilter(entry.value))
.toList();
int itemCount = 0;
if (showingCategories) {
itemCount = filteredCategories.length;
} else if (showingMovies) {
itemCount = _categoryMovies.length;
} else if (showingSearch) {
itemCount = _searchResults?.length ?? 0;
}
return Container(
decoration: AppDecorations.background(context),
child: PopScope(
canPop: false,
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
_searchController.clear();
setState(() => _isSearchEditing = false);
return;
}
// Handle Category Back
if (_selectedCategoryId != null) {
_onBack();
return;
}
// Root View -> Show Exit Dialog
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text(
"Quitter l'application",
style: TextStyle(color: AppColors.onSurface),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: AppColors.onSurface70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: AppColors.primary),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
if (shouldExit == true) {
SystemNavigator.pop();
}
},
child: SafeArea(
bottom: false,
child: RefreshIndicator(
onRefresh: _refresh,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Pinned Header
SliverAppBar(
pinned: true,
floating: false,
snap: false,
backgroundColor: const Color(0xFF000000),
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: showingMovies ? 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: 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)),
),
],
),
),
),
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,
),
),
],
),
],
),
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 6 : 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (showingCategories) {
if (index >= filteredCategories.length) return null;
final cat = filteredCategories[index];
return MobileCategoryCard(
title: cat.value,
icon: Icons.movie_filter_rounded,
onTap: () => _selectCategory(cat.key, cat.value),
);
} else if (showingMovies) {
if (index >= _categoryMovies.length) return null;
final movie = _categoryMovies[index];
final isWatched =
watchHistory.isMovieWatched(movie.streamId);
return MobilePosterCard(
title: movie.name,
imageUrl: _getImageUrl(movie.streamIcon),
rating: _formatRating(movie.rating),
isWatched: isWatched,
onTap: () => _playMovie(context, movie),
onLongPress: () {
ref
.read(mobileWatchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
},
);
} else if (showingSearch) {
if (_searchResults == null ||
index >= _searchResults!.length) {
return null;
}
final movie = _searchResults![index];
final isWatched =
watchHistory.isMovieWatched(movie.streamId);
return MobilePosterCard(
title: movie.name,
imageUrl: _getImageUrl(movie.streamIcon),
rating: _formatRating(movie.rating),
isWatched: isWatched,
onTap: () => _playMovie(context, movie),
onLongPress: () {
ref
.read(mobileWatchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
},
);
}
return null;
},
childCount: itemCount,
),
),
),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
),
),
),
);
}
}