Fix: Movie infinite scroll + Player controls auto-hide

- Added in-memory caching for movies/series pagination in XtreamServiceMobile
- Fixed player controls appearing then instantly disappearing on touch
- Updated refresh methods to clear cache on pull-to-refresh
- Added releases/ to gitignore (APKs too large for GitHub)
This commit is contained in:
Michael committed 2025-12-10 10:23:04 +01:00
1 parent 3888a21990
commit ccda497236
8 files changed
+330 -83

No files matched your search

+2
View File
@@ -49,3 +49,5 @@ app.*.map.json
# Hive data (local development) # Hive data (local development)
*.hive *.hive
*.lock *.lock
releases/
@@ -17,6 +17,12 @@ class XtreamServiceMobile {
String? _resolvedIp; // Cached resolved IP for the playlist server String? _resolvedIp; // Cached resolved IP for the playlist server
String? _originalHost; // Original hostname for Host header String? _originalHost; // Original hostname for Host header
// In-memory cache for pagination performance
List<dynamic>? _cachedMoviesRaw;
List<dynamic>? _cachedSeriesRaw;
Map<String, String>? _cachedVodCategories;
Map<String, String>? _cachedSeriesCategories;
XtreamServiceMobile(String cachePath) { XtreamServiceMobile(String cachePath) {
_dio = Dio(BaseOptions( _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 15),
@@ -282,25 +288,31 @@ class XtreamServiceMobile {
} }
} }
/// Get movies with pagination support /// Get movies with pagination support (uses in-memory cache for performance)
Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
// Load categories for mapping // Load from cache or fetch from API
final categoryMap = await _getVodCategories(); if (_cachedMoviesRaw == null) {
// First load: fetch from API and cache
_cachedVodCategories ??= await _getVodCategories();
final response = await _dio.get( final response = await _dio.get(
_effectiveApiBaseUrl, _effectiveApiBaseUrl,
queryParameters: { queryParameters: {
'username': _currentPlaylist!.username, 'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password, 'password': _currentPlaylist!.password,
'action': 'get_vod_streams', 'action': 'get_vod_streams',
}, },
options: _getOptions(), options: _getOptions(),
); );
final List<dynamic> allMovies = response.data as List<dynamic>; _cachedMoviesRaw = response.data as List<dynamic>;
}
final allMovies = _cachedMoviesRaw!;
final categoryMap = _cachedVodCategories!;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit; final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit;
@@ -309,7 +321,7 @@ class XtreamServiceMobile {
final paginatedMovies = allMovies.sublist(offset, endIndex); final paginatedMovies = allMovies.sublist(offset, endIndex);
return paginatedMovies.map((movieData) { return paginatedMovies.map((movieData) {
final data = movieData as Map<String, dynamic>; final data = Map<String, dynamic>.from(movieData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data); return xm.Movie.fromJson(data);
@@ -359,25 +371,31 @@ class XtreamServiceMobile {
} }
} }
/// Get series with pagination support (returns flat list) /// Get series with pagination support (uses in-memory cache for performance)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
// Load categories for mapping // Load from cache or fetch from API
final categoryMap = await _getSeriesCategories(); if (_cachedSeriesRaw == null) {
// First load: fetch from API and cache
_cachedSeriesCategories ??= await _getSeriesCategories();
final response = await _dio.get( final response = await _dio.get(
_effectiveApiBaseUrl, _effectiveApiBaseUrl,
queryParameters: { queryParameters: {
'username': _currentPlaylist!.username, 'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password, 'password': _currentPlaylist!.password,
'action': 'get_series', 'action': 'get_series',
}, },
options: _getOptions(), options: _getOptions(),
); );
final List<dynamic> allSeries = response.data as List<dynamic>; _cachedSeriesRaw = response.data as List<dynamic>;
}
final allSeries = _cachedSeriesRaw!;
final categoryMap = _cachedSeriesCategories!;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit; final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit;
@@ -386,7 +404,7 @@ class XtreamServiceMobile {
final paginatedSeries = allSeries.sublist(offset, endIndex); final paginatedSeries = allSeries.sublist(offset, endIndex);
return paginatedSeries.map((seriesData) { return paginatedSeries.map((seriesData) {
final data = seriesData as Map<String, dynamic>; final data = Map<String, dynamic>.from(seriesData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data); return xm.Series.fromJson(data);
@@ -530,8 +548,17 @@ class XtreamServiceMobile {
} }
} }
/// Clear in-memory cache (call on refresh)
void clearCache() {
_cachedMoviesRaw = null;
_cachedSeriesRaw = null;
_cachedVodCategories = null;
_cachedSeriesCategories = null;
}
/// Dispose resources /// Dispose resources
void dispose() { void dispose() {
clearCache();
_dio.close(); _dio.close();
} }
} }
@@ -55,10 +55,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
Duration _position = Duration.zero; Duration _position = Duration.zero;
Duration _duration = Duration.zero; Duration _duration = Duration.zero;
bool _isSeeking = false; bool _isSeeking = false;
bool _useSoftwareDecoder = false;
Timer? _clockTimer; Timer? _clockTimer;
Timer? _controlsTimer; // Auto-hide timer
String _currentTime = ''; String _currentTime = '';
void _resetControlsTimer() {
_controlsTimer?.cancel();
if (_showControls && _isPlaying) {
_controlsTimer = Timer(const Duration(seconds: 4), () {
if (mounted) {
setState(() => _showControls = false);
}
});
}
}
void _onUserInteraction() {
setState(() => _showControls = true);
_resetControlsTimer();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -79,12 +97,40 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
// Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps) // Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps)
(_player.platform as dynamic)?.setProperty('hwdec', 'auto'); (_player.platform as dynamic)?.setProperty('hwdec', 'auto');
// Reverted to Basic Configuration (Stock MPV) for maximum stability
// The previous advanced caching was causing streams to cut off after ~30s.
// We only enable basic cache and let MPV handle the rest automatically.
// Reliable Network Configuration (Fix for 30s cutoff)
// - Increase timeout significantly
// - Allow large cache duration (not just bytes)
// - Disable SSL verification to prevent handshake drops
// - Enable reconnect
// Reliable Network Configuration (Fix for 30s cutoff)
(_player.platform as dynamic)?.setProperty('cache', 'yes');
(_player.platform as dynamic)?.setProperty('cache-secs', '120'); // Cache up to 2 minutes
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '50000000'); // 50MB max buffer
(_player.platform as dynamic)?.setProperty('network-timeout', '120'); // 2 minutes timeout
(_player.platform as dynamic)?.setProperty('tls-verify', 'no'); // Ignore SSL errors
(_player.platform as dynamic)?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0');
_controller = VideoController(_player); _controller = VideoController(_player);
// Listen to player state // Listen to player state
_player.stream.playing.listen((playing) { _player.stream.playing.listen((playing) {
if (mounted) { if (mounted) {
setState(() => _isPlaying = playing); setState(() => _isPlaying = playing);
if (playing) {
// Smooth Loading: Hide loading screen 1s AFTER playback actually starts
// This masks initial glitches/re-buffering
Future.delayed(const Duration(seconds: 1), () {
if (mounted && _isPlaying) {
setState(() => _isLoading = false);
}
});
_resetControlsTimer(); // Start auto-hide timer
}
} }
}); });
@@ -100,13 +146,31 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
} }
}); });
_player.stream.error.listen((error) { _player.stream.error.listen((error) async {
if (mounted && error.isNotEmpty) { if (!mounted) return;
setState(() {
_errorMessage = error; // Filter out non-errors or non-fatal messages if necessary
_isLoading = false; if (error.isEmpty) return;
});
debugPrint('MediaKitPlayer Error Stream: $error');
// Smart Retry for Codec/Decoder errors
// If we haven't tried SW decoding yet, retry with it enabled.
if (!_useSoftwareDecoder) {
debugPrint('MediaKitPlayer: Error encountered. Auto-retrying with Software Decoding...');
setState(() => _useSoftwareDecoder = true);
// Short delay to allow player cleanup
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _loadStream(); // Retry connection
return;
} }
// If SW decoding was already on or failed too, show error
setState(() {
_errorMessage = error;
_isLoading = false;
});
}); });
// Lock to landscape for video playback // Lock to landscape for video playback
@@ -137,6 +201,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
@override @override
void dispose() { void dispose() {
_clockTimer?.cancel(); _clockTimer?.cancel();
_controlsTimer?.cancel();
_player.dispose(); _player.dispose();
_xtreamService?.dispose(); _xtreamService?.dispose();
@@ -211,6 +276,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
debugPrint('MediaKitPlayer: Loading stream: $streamUrl'); debugPrint('MediaKitPlayer: Loading stream: $streamUrl');
// Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps)
// If we are in fallback mode, force software ('no')
(_player.platform as dynamic)?.setProperty('hwdec', _useSoftwareDecoder ? 'no' : 'auto');
await _player.open( await _player.open(
Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}), Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}),
play: true, play: true,
@@ -218,9 +287,24 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
// Reset fallback flag on success? No, keep it if it works to avoid future crashes on same stream
}); });
} catch (e) { } catch (e) {
debugPrint('MediaKitPlayer error: $e'); debugPrint('MediaKitPlayer error: $e');
// Automatic Retry with Software Decoding if it fails the first time
if (!_useSoftwareDecoder) {
debugPrint('MediaKitPlayer: Retrying with Software Decoding forced...');
setState(() {
_useSoftwareDecoder = true;
// _isLoading remains true
});
// Delay slightly to let player reset state if needed
await Future.delayed(const Duration(milliseconds: 500));
_loadStream(); // Recursive retry
return;
}
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_errorMessage = e.toString(); _errorMessage = e.toString();
@@ -264,12 +348,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
} else { } else {
_player.play(); _player.play();
} }
_onUserInteraction();
} }
void _toggleControls() { void _toggleControls() {
setState(() { // Always show controls on tap, then start auto-hide timer
_showControls = !_showControls; if (!_showControls) {
}); setState(() => _showControls = true);
}
_resetControlsTimer();
} }
@@ -286,14 +373,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: GestureDetector( body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleControls, onTap: _toggleControls,
onPanDown: (_) => _onUserInteraction(),
onPanUpdate: (_) => _onUserInteraction(),
child: Stack( child: Stack(
children: [ children: [
// Video Player // Video Player
if (_errorMessage != null) if (_errorMessage != null)
_buildErrorView() _buildErrorView()
else if (_isLoading)
_buildLoadingView()
else else
Center( Center(
child: Video( child: Video(
@@ -302,6 +390,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
), ),
), ),
// Loading Overlay (Stays on top until smooth playback starts)
// We moved _buildLoadingView out of the if/else chain above to overlay it
if (_isLoading && _errorMessage == null)
Container(
color: Colors.black,
child: _buildLoadingView(),
),
// Custom controls overlay // Custom controls overlay
if (_showControls && _errorMessage == null && !_isLoading) if (_showControls && _errorMessage == null && !_isLoading)
_buildControlsOverlay(title), _buildControlsOverlay(title),
@@ -637,7 +733,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
label: const Text('Réessayer'), label: const Text('Réessayer'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.black,
), ),
), ),
], ],
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:io'; import 'dart:io';
import '../../../providers/mobile_xtream_providers.dart'; import '../../../providers/mobile_xtream_providers.dart';
import '../../../providers/mobile_settings_providers.dart'; import '../../../providers/mobile_settings_providers.dart';
@@ -19,7 +20,7 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
ConsumerState<MobileLiveTVTab> createState() => _MobileLiveTVTabState(); ConsumerState<MobileLiveTVTab> createState() => _MobileLiveTVTabState();
} }
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> { class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with AutomaticKeepAliveClientMixin {
String? _selectedCategory; String? _selectedCategory;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
@@ -43,8 +44,12 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
bool _isCategoryView = true; bool _isCategoryView = true;
@override
bool get wantKeepAlive => true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
final channelsAsync = ref.watch(mobileLiveChannelsProvider(widget.playlist)); final channelsAsync = ref.watch(mobileLiveChannelsProvider(widget.playlist));
final favorites = ref.watch(mobileFavoritesProvider); final favorites = ref.watch(mobileFavoritesProvider);
final settings = ref.watch(mobileSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
@@ -214,7 +219,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8, // Drastically reduced size as requested crossAxisCount: 6, // Adjusted to 6 columns as requested
childAspectRatio: 1.0, childAspectRatio: 1.0,
crossAxisSpacing: 8, crossAxisSpacing: 8,
mainAxisSpacing: 8, mainAxisSpacing: 8,
@@ -386,10 +391,11 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: iconUrl != null child: iconUrl != null
? Image.network( ? CachedNetworkImage(
iconUrl, imageUrl: iconUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24), errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24),
placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24),
) )
: const Icon(Icons.tv, color: Colors.white24), : const Icon(Icons.tv, color: Colors.white24),
), ),
@@ -18,7 +18,7 @@ class MobileMoviesTab extends ConsumerStatefulWidget {
ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState(); ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState();
} }
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> { class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = []; final List<Movie> _movies = [];
@@ -28,7 +28,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
bool _isSearching = false; bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 500; // Large page size to prevent infinite scroll bugs if offset logic is flawed static const int _pageSize = 50;
Timer? _searchDebounce; Timer? _searchDebounce;
@override @override
@@ -99,36 +99,51 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
// Add timeout to prevent infinite loading state
final newMovies = await service.getMoviesPaginated( final newMovies = await service.getMoviesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, limit: _pageSize,
); ).timeout(const Duration(seconds: 15));
if (mounted) { if (mounted) {
setState(() { setState(() {
if (newMovies.isEmpty) { _movies.addAll(newMovies);
_hasMore = false; _currentOffset += _pageSize;
} else { _hasMore = newMovies.length == _pageSize;
_movies.addAll(newMovies);
_currentOffset += _pageSize;
// If we got fewer movies than requested, we reached the end
if (newMovies.length < _pageSize) {
_hasMore = false;
}
}
_isLoading = false; _isLoading = false;
}); });
} }
} catch (e) { } catch (e) {
debugPrint('Error loading movies: $e');
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_hasMore = false; // Stop trying on error to prevent infinite loop // Do NOT set _hasMore to false on error
}); });
} }
} }
} }
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(() {
_movies.clear();
_currentOffset = 0;
_hasMore = true;
_searchResults = null;
_searchQuery = '';
_searchController.clear();
_isLoading = false;
});
await _loadMoreMovies();
}
String? _formatRating(String? rating) { String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null; if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating); final value = double.tryParse(rating);
@@ -161,8 +176,12 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
); );
} }
@override
bool get wantKeepAlive => true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
final settings = ref.watch(mobileSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
final watchHistory = ref.watch(mobileWatchHistoryProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider);
@@ -186,8 +205,13 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: CustomScrollView( child: RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
backgroundColor: AppColors.surface,
child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
// Header & Search // Header & Search
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -234,18 +258,22 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
), ),
// Hero Section (Only if not searching) // Hero Section
if (_searchQuery.isEmpty && heroItems.isNotEmpty) if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: SizedBox( child: SizedBox(
height: 250, // Reduced height for mobile hero height: 250,
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id); try {
_playMovie(movie); final movie = _movies.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
} catch (e) {
// Fallback ignored
}
}, },
), ),
), ),
@@ -253,18 +281,17 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
// Grid // Grid
if (displayMovies.isNotEmpty) SliverPadding(
SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid(
sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 8,
crossAxisCount: 8, crossAxisSpacing: 8,
childAspectRatio: 0.7, mainAxisSpacing: 8,
crossAxisSpacing: 8, childAspectRatio: 0.7,
mainAxisSpacing: 8, ),
), delegate: SliverChildBuilderDelegate(
delegate: SliverChildBuilderDelegate( (context, index) {
(context, index) {
if (index >= displayMovies.length) return null; if (index >= displayMovies.length) return null;
final movie = displayMovies[index]; final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId); final isWatched = watchHistory.isMovieWatched(movie.streamId);
@@ -296,6 +323,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
], ],
), ),
),
); );
} }
} }
@@ -18,7 +18,7 @@ class MobileSeriesTab extends ConsumerStatefulWidget {
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState(); ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
} }
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> { class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final List<Series> _series = []; final List<Series> _series = [];
@@ -99,10 +99,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
// Add timeout to prevent infinite loading state
final newSeries = await service.getSeriesPaginated( final newSeries = await service.getSeriesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, limit: _pageSize,
); ).timeout(const Duration(seconds: 15));
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -113,12 +115,35 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
}); });
} }
} catch (e) { } catch (e) {
debugPrint('Error loading series: $e');
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() {
_isLoading = false;
// Do NOT set _hasMore to false on error
});
} }
} }
} }
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(() {
_series.clear();
_currentOffset = 0;
_hasMore = true;
_searchResults = null;
_searchQuery = '';
_searchController.clear();
_isLoading = false;
});
await _loadMoreSeries();
}
String? _formatRating(String? rating) { String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null; if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating); final value = double.tryParse(rating);
@@ -146,8 +171,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
); );
} }
@override
bool get wantKeepAlive => true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
final settings = ref.watch(mobileSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
List<Series> displaySeries; List<Series> displaySeries;
@@ -170,8 +199,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: CustomScrollView( child: RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
backgroundColor: AppColors.surface,
child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
// Header & Search // Header & Search
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -283,6 +317,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
), ),
], ],
), ),
),
); );
} }
} }
@@ -157,6 +157,48 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Playback Settings
const _SectionHeader(title: 'Lecture Vidéo'),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.speed, color: AppColors.primary),
title: const Text('Mise en mémoire tampon (Cache)',
style: TextStyle(color: AppColors.textPrimary)),
subtitle: Text(
settings.bufferDuration == 0
? 'Auto (Défaut)'
: '${settings.bufferDuration} secondes',
style: const TextStyle(color: AppColors.textSecondary),
),
trailing: DropdownButton<int>(
value: settings.bufferDuration,
dropdownColor: AppColors.surface,
underline: const SizedBox(),
icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary),
items: const [
DropdownMenuItem(value: 0, child: Text('Auto', style: TextStyle(color: AppColors.textPrimary))),
DropdownMenuItem(value: 15, child: Text('15s', style: TextStyle(color: AppColors.textPrimary))),
DropdownMenuItem(value: 30, child: Text('30s', style: TextStyle(color: AppColors.textPrimary))),
DropdownMenuItem(value: 60, child: Text('60s', style: TextStyle(color: AppColors.textPrimary))),
],
onChanged: (val) {
if (val != null) {
ref.read(mobileSettingsProvider.notifier).setBufferDuration(val);
}
},
),
),
],
),
),
const SizedBox(height: 24),
// Content Filters // Content Filters
const _SectionHeader(title: 'Filtres de Contenu'), const _SectionHeader(title: 'Filtres de Contenu'),
Container( Container(
@@ -8,12 +8,14 @@ class MobileSettings {
final List<String> moviesKeywords; final List<String> moviesKeywords;
final List<String> seriesKeywords; final List<String> seriesKeywords;
final bool showClock; final bool showClock;
final int bufferDuration; // seconds, 0 = auto
const MobileSettings({ const MobileSettings({
this.liveTvKeywords = const [], this.liveTvKeywords = const [],
this.moviesKeywords = const [], this.moviesKeywords = const [],
this.seriesKeywords = const [], this.seriesKeywords = const [],
this.showClock = false, this.showClock = false,
this.bufferDuration = 0,
}); });
MobileSettings copyWith({ MobileSettings copyWith({
@@ -21,12 +23,14 @@ class MobileSettings {
List<String>? moviesKeywords, List<String>? moviesKeywords,
List<String>? seriesKeywords, List<String>? seriesKeywords,
bool? showClock, bool? showClock,
int? bufferDuration,
}) { }) {
return MobileSettings( return MobileSettings(
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords, liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
moviesKeywords: moviesKeywords ?? this.moviesKeywords, moviesKeywords: moviesKeywords ?? this.moviesKeywords,
seriesKeywords: seriesKeywords ?? this.seriesKeywords, seriesKeywords: seriesKeywords ?? this.seriesKeywords,
showClock: showClock ?? this.showClock, showClock: showClock ?? this.showClock,
bufferDuration: bufferDuration ?? this.bufferDuration,
); );
} }
@@ -73,12 +77,14 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?; final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
final series = _box?.get('seriesKeywords', defaultValue: <String>[]) as List?; final series = _box?.get('seriesKeywords', defaultValue: <String>[]) as List?;
final showClock = _box?.get('showClock', defaultValue: false) as bool?; final showClock = _box?.get('showClock', defaultValue: false) as bool?;
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
state = MobileSettings( state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [], liveTvKeywords: liveTv?.cast<String>() ?? [],
moviesKeywords: movies?.cast<String>() ?? [], moviesKeywords: movies?.cast<String>() ?? [],
seriesKeywords: series?.cast<String>() ?? [], seriesKeywords: series?.cast<String>() ?? [],
showClock: showClock ?? false, showClock: showClock ?? false,
bufferDuration: buffer ?? 0,
); );
} }
@@ -101,6 +107,11 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
state = state.copyWith(showClock: value); state = state.copyWith(showClock: value);
_box?.put('showClock', value); _box?.put('showClock', value);
} }
void setBufferDuration(int seconds) {
state = state.copyWith(bufferDuration: seconds);
_box?.put('bufferDuration', seconds);
}
} }
final mobileSettingsProvider = StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) { final mobileSettingsProvider = StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) {