From ccda497236abbded52d1be8e9ba9dddf8620b2cf Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 10 Dec 2025 10:23:04 +0100 Subject: [PATCH] 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) --- .gitignore | 2 + .../iptv/services/xtream_service_mobile.dart | 83 ++++++++---- .../iptv/screens/native_player_screen.dart | 120 ++++++++++++++++-- .../iptv/widgets/mobile_live_tv_tab.dart | 16 ++- .../iptv/widgets/mobile_movies_tab.dart | 96 +++++++++----- .../iptv/widgets/mobile_series_tab.dart | 43 ++++++- .../iptv/widgets/mobile_settings_tab.dart | 42 ++++++ .../providers/mobile_settings_providers.dart | 11 ++ 8 files changed, 330 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index 6ede5ac..617b4d4 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ app.*.map.json # Hive data (local development) *.hive *.lock + +releases/ diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 411f991..e77c3c6 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -17,6 +17,12 @@ class XtreamServiceMobile { String? _resolvedIp; // Cached resolved IP for the playlist server String? _originalHost; // Original hostname for Host header + // In-memory cache for pagination performance + List? _cachedMoviesRaw; + List? _cachedSeriesRaw; + Map? _cachedVodCategories; + Map? _cachedSeriesCategories; + XtreamServiceMobile(String cachePath) { _dio = Dio(BaseOptions( 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> getMoviesPaginated({int offset = 0, int limit = 100}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { - // Load categories for mapping - final categoryMap = await _getVodCategories(); + // Load from cache or fetch from API + if (_cachedMoviesRaw == null) { + // First load: fetch from API and cache + _cachedVodCategories ??= await _getVodCategories(); - final response = await _dio.get( - _effectiveApiBaseUrl, - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_streams', - }, - options: _getOptions(), - ); + final response = await _dio.get( + _effectiveApiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_vod_streams', + }, + options: _getOptions(), + ); - final List allMovies = response.data as List; + _cachedMoviesRaw = response.data as List; + } + + final allMovies = _cachedMoviesRaw!; + final categoryMap = _cachedVodCategories!; // Apply pagination final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit; @@ -309,7 +321,7 @@ class XtreamServiceMobile { final paginatedMovies = allMovies.sublist(offset, endIndex); return paginatedMovies.map((movieData) { - final data = movieData as Map; + final data = Map.from(movieData as Map); final categoryId = data['category_id']?.toString() ?? ''; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; 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> getSeriesPaginated({int offset = 0, int limit = 100}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { - // Load categories for mapping - final categoryMap = await _getSeriesCategories(); + // Load from cache or fetch from API + if (_cachedSeriesRaw == null) { + // First load: fetch from API and cache + _cachedSeriesCategories ??= await _getSeriesCategories(); - final response = await _dio.get( - _effectiveApiBaseUrl, - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series', - }, - options: _getOptions(), - ); + final response = await _dio.get( + _effectiveApiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_series', + }, + options: _getOptions(), + ); - final List allSeries = response.data as List; + _cachedSeriesRaw = response.data as List; + } + + final allSeries = _cachedSeriesRaw!; + final categoryMap = _cachedSeriesCategories!; // Apply pagination final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit; @@ -386,7 +404,7 @@ class XtreamServiceMobile { final paginatedSeries = allSeries.sublist(offset, endIndex); return paginatedSeries.map((seriesData) { - final data = seriesData as Map; + final data = Map.from(seriesData as Map); final categoryId = data['category_id']?.toString() ?? ''; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; 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 void dispose() { + clearCache(); _dio.close(); } } diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index 8e3f5e4..36021bd 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -55,10 +55,28 @@ class _NativePlayerScreenState extends ConsumerState { Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _isSeeking = false; + bool _useSoftwareDecoder = false; Timer? _clockTimer; + Timer? _controlsTimer; // Auto-hide timer 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 void initState() { super.initState(); @@ -79,12 +97,40 @@ class _NativePlayerScreenState extends ConsumerState { // Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps) (_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); // Listen to player state _player.stream.playing.listen((playing) { if (mounted) { 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 { } }); - _player.stream.error.listen((error) { - if (mounted && error.isNotEmpty) { - setState(() { - _errorMessage = error; - _isLoading = false; - }); + _player.stream.error.listen((error) async { + if (!mounted) return; + + // Filter out non-errors or non-fatal messages if necessary + 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 @@ -137,6 +201,7 @@ class _NativePlayerScreenState extends ConsumerState { @override void dispose() { _clockTimer?.cancel(); + _controlsTimer?.cancel(); _player.dispose(); _xtreamService?.dispose(); @@ -211,6 +276,10 @@ class _NativePlayerScreenState extends ConsumerState { 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( Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}), play: true, @@ -218,9 +287,24 @@ class _NativePlayerScreenState extends ConsumerState { setState(() { _isLoading = false; + // Reset fallback flag on success? No, keep it if it works to avoid future crashes on same stream }); } catch (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(() { _isLoading = false; _errorMessage = e.toString(); @@ -264,12 +348,15 @@ class _NativePlayerScreenState extends ConsumerState { } else { _player.play(); } + _onUserInteraction(); } void _toggleControls() { - setState(() { - _showControls = !_showControls; - }); + // Always show controls on tap, then start auto-hide timer + if (!_showControls) { + setState(() => _showControls = true); + } + _resetControlsTimer(); } @@ -286,14 +373,15 @@ class _NativePlayerScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.black, body: GestureDetector( + behavior: HitTestBehavior.opaque, onTap: _toggleControls, + onPanDown: (_) => _onUserInteraction(), + onPanUpdate: (_) => _onUserInteraction(), child: Stack( children: [ // Video Player if (_errorMessage != null) _buildErrorView() - else if (_isLoading) - _buildLoadingView() else Center( child: Video( @@ -302,6 +390,14 @@ class _NativePlayerScreenState extends ConsumerState { ), ), + // 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 if (_showControls && _errorMessage == null && !_isLoading) _buildControlsOverlay(title), @@ -637,7 +733,7 @@ class _NativePlayerScreenState extends ConsumerState { label: const Text('Réessayer'), style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + foregroundColor: Colors.black, ), ), ], diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index 2080835..a59eb2e 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'dart:io'; import '../../../providers/mobile_xtream_providers.dart'; import '../../../providers/mobile_settings_providers.dart'; @@ -19,7 +20,7 @@ class MobileLiveTVTab extends ConsumerStatefulWidget { ConsumerState createState() => _MobileLiveTVTabState(); } -class _MobileLiveTVTabState extends ConsumerState { +class _MobileLiveTVTabState extends ConsumerState with AutomaticKeepAliveClientMixin { String? _selectedCategory; final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; @@ -43,8 +44,12 @@ class _MobileLiveTVTabState extends ConsumerState { bool _isCategoryView = true; + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); final channelsAsync = ref.watch(mobileLiveChannelsProvider(widget.playlist)); final favorites = ref.watch(mobileFavoritesProvider); final settings = ref.watch(mobileSettingsProvider); @@ -214,7 +219,7 @@ class _MobileLiveTVTabState extends ConsumerState { return GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, // Drastically reduced size as requested + crossAxisCount: 6, // Adjusted to 6 columns as requested childAspectRatio: 1.0, crossAxisSpacing: 8, mainAxisSpacing: 8, @@ -386,10 +391,11 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { borderRadius: BorderRadius.circular(8), ), child: iconUrl != null - ? Image.network( - iconUrl, + ? CachedNetworkImage( + imageUrl: iconUrl, 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), ), diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index 075ffc4..7c5dcfc 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -18,7 +18,7 @@ class MobileMoviesTab extends ConsumerStatefulWidget { ConsumerState createState() => _MobileMoviesTabState(); } -class _MobileMoviesTabState extends ConsumerState { +class _MobileMoviesTabState extends ConsumerState with AutomaticKeepAliveClientMixin { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _movies = []; @@ -28,7 +28,7 @@ class _MobileMoviesTabState extends ConsumerState { bool _isSearching = false; bool _hasMore = true; 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; @override @@ -99,36 +99,51 @@ class _MobileMoviesTabState extends ConsumerState { try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + + // Add timeout to prevent infinite loading state final newMovies = await service.getMoviesPaginated( offset: _currentOffset, limit: _pageSize, - ); + ).timeout(const Duration(seconds: 15)); if (mounted) { setState(() { - if (newMovies.isEmpty) { - _hasMore = false; - } else { - _movies.addAll(newMovies); - _currentOffset += _pageSize; - // If we got fewer movies than requested, we reached the end - if (newMovies.length < _pageSize) { - _hasMore = false; - } - } + _movies.addAll(newMovies); + _currentOffset += _pageSize; + _hasMore = newMovies.length == _pageSize; _isLoading = false; }); } } catch (e) { + debugPrint('Error loading movies: $e'); if (mounted) { - setState(() { - _isLoading = false; - _hasMore = false; // Stop trying on error to prevent infinite loop - }); + setState(() { + _isLoading = false; + // Do NOT set _hasMore to false on error + }); } } } + Future _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) { if (rating == null || rating.isEmpty) return null; final value = double.tryParse(rating); @@ -161,8 +176,12 @@ class _MobileMoviesTabState extends ConsumerState { ); } + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin final settings = ref.watch(mobileSettingsProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider); @@ -186,8 +205,13 @@ class _MobileMoviesTabState extends ConsumerState { return SafeArea( bottom: false, - child: CustomScrollView( + child: RefreshIndicator( + onRefresh: _refresh, + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: CustomScrollView( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ // Header & Search SliverToBoxAdapter( @@ -234,18 +258,22 @@ class _MobileMoviesTabState extends ConsumerState { ), ), - // Hero Section (Only if not searching) + // Hero Section if (_searchQuery.isEmpty && heroItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: SizedBox( - height: 250, // Reduced height for mobile hero + height: 250, child: HeroCarousel( items: heroItems, onTap: (item) { - final movie = _movies.firstWhere((element) => element.streamId == item.id); - _playMovie(movie); + try { + final movie = _movies.firstWhere((element) => element.streamId == item.id); + _playMovie(movie); + } catch (e) { + // Fallback ignored + } }, ), ), @@ -253,18 +281,17 @@ class _MobileMoviesTabState extends ConsumerState { ), // Grid - if (displayMovies.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - childAspectRatio: 0.7, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { if (index >= displayMovies.length) return null; final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); @@ -296,6 +323,7 @@ class _MobileMoviesTabState extends ConsumerState { ), ], ), + ), ); } } diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index cf0e3d4..75e5ade 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -18,7 +18,7 @@ class MobileSeriesTab extends ConsumerStatefulWidget { ConsumerState createState() => _MobileSeriesTabState(); } -class _MobileSeriesTabState extends ConsumerState { +class _MobileSeriesTabState extends ConsumerState with AutomaticKeepAliveClientMixin { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _series = []; @@ -99,10 +99,12 @@ class _MobileSeriesTabState extends ConsumerState { try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + + // Add timeout to prevent infinite loading state final newSeries = await service.getSeriesPaginated( offset: _currentOffset, limit: _pageSize, - ); + ).timeout(const Duration(seconds: 15)); if (mounted) { setState(() { @@ -113,12 +115,35 @@ class _MobileSeriesTabState extends ConsumerState { }); } } catch (e) { + debugPrint('Error loading series: $e'); if (mounted) { - setState(() => _isLoading = false); + setState(() { + _isLoading = false; + // Do NOT set _hasMore to false on error + }); } } } + Future _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) { if (rating == null || rating.isEmpty) return null; final value = double.tryParse(rating); @@ -146,8 +171,12 @@ class _MobileSeriesTabState extends ConsumerState { ); } + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin final settings = ref.watch(mobileSettingsProvider); List displaySeries; @@ -170,8 +199,13 @@ class _MobileSeriesTabState extends ConsumerState { return SafeArea( bottom: false, - child: CustomScrollView( + child: RefreshIndicator( + onRefresh: _refresh, + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: CustomScrollView( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ // Header & Search SliverToBoxAdapter( @@ -283,6 +317,7 @@ class _MobileSeriesTabState extends ConsumerState { ), ], ), + ), ); } } diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 41686a9..5ac66fa 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -157,6 +157,48 @@ class _MobileSettingsTabState extends ConsumerState { ), 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( + 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 const _SectionHeader(title: 'Filtres de Contenu'), Container( diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 4c8718d..41b5f59 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -8,12 +8,14 @@ class MobileSettings { final List moviesKeywords; final List seriesKeywords; final bool showClock; + final int bufferDuration; // seconds, 0 = auto const MobileSettings({ this.liveTvKeywords = const [], this.moviesKeywords = const [], this.seriesKeywords = const [], this.showClock = false, + this.bufferDuration = 0, }); MobileSettings copyWith({ @@ -21,12 +23,14 @@ class MobileSettings { List? moviesKeywords, List? seriesKeywords, bool? showClock, + int? bufferDuration, }) { return MobileSettings( liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords, moviesKeywords: moviesKeywords ?? this.moviesKeywords, seriesKeywords: seriesKeywords ?? this.seriesKeywords, showClock: showClock ?? this.showClock, + bufferDuration: bufferDuration ?? this.bufferDuration, ); } @@ -73,12 +77,14 @@ class MobileSettingsNotifier extends StateNotifier { final movies = _box?.get('moviesKeywords', defaultValue: []) as List?; final series = _box?.get('seriesKeywords', defaultValue: []) as List?; final showClock = _box?.get('showClock', defaultValue: false) as bool?; + final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; state = MobileSettings( liveTvKeywords: liveTv?.cast() ?? [], moviesKeywords: movies?.cast() ?? [], seriesKeywords: series?.cast() ?? [], showClock: showClock ?? false, + bufferDuration: buffer ?? 0, ); } @@ -101,6 +107,11 @@ class MobileSettingsNotifier extends StateNotifier { state = state.copyWith(showClock: value); _box?.put('showClock', value); } + + void setBufferDuration(int seconds) { + state = state.copyWith(bufferDuration: seconds); + _box?.put('bufferDuration', seconds); + } } final mobileSettingsProvider = StateNotifierProvider((ref) {