mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-12 01:39:36 +02:00
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:
1 parent
3888a21990
commit
ccda497236
8 files changed
+330
-83
No files matched your search
@@ -49,3 +49,5 @@ app.*.map.json
|
||||
# Hive data (local development)
|
||||
*.hive
|
||||
*.lock
|
||||
|
||||
releases/
|
||||
@@ -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<dynamic>? _cachedMoviesRaw;
|
||||
List<dynamic>? _cachedSeriesRaw;
|
||||
Map<String, String>? _cachedVodCategories;
|
||||
Map<String, String>? _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<List<xm.Movie>> 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<dynamic> allMovies = response.data as List<dynamic>;
|
||||
_cachedMoviesRaw = response.data as List<dynamic>;
|
||||
}
|
||||
|
||||
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<String, dynamic>;
|
||||
final data = Map<String, dynamic>.from(movieData as Map<String, dynamic>);
|
||||
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<List<xm.Series>> 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<dynamic> allSeries = response.data as List<dynamic>;
|
||||
_cachedSeriesRaw = response.data as List<dynamic>;
|
||||
}
|
||||
|
||||
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<String, dynamic>;
|
||||
final data = Map<String, dynamic>.from(seriesData as Map<String, dynamic>);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
|
||||
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<NativePlayerScreen> {
|
||||
// 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<NativePlayerScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
_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<NativePlayerScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_clockTimer?.cancel();
|
||||
_controlsTimer?.cancel();
|
||||
_player.dispose();
|
||||
_xtreamService?.dispose();
|
||||
|
||||
@@ -211,6 +276,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
|
||||
|
||||
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<NativePlayerScreen> {
|
||||
|
||||
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<NativePlayerScreen> {
|
||||
} 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<NativePlayerScreen> {
|
||||
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<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
|
||||
if (_showControls && _errorMessage == null && !_isLoading)
|
||||
_buildControlsOverlay(title),
|
||||
@@ -637,7 +733,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen> {
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<MobileLiveTVTab> createState() => _MobileLiveTVTabState();
|
||||
}
|
||||
|
||||
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
||||
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with AutomaticKeepAliveClientMixin {
|
||||
String? _selectedCategory;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
@@ -43,8 +44,12 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
||||
|
||||
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<MobileLiveTVTab> {
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ class MobileMoviesTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState();
|
||||
}
|
||||
|
||||
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
|
||||
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> with AutomaticKeepAliveClientMixin {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final List<Movie> _movies = [];
|
||||
@@ -28,7 +28,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
|
||||
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<MobileMoviesTab> {
|
||||
|
||||
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<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) {
|
||||
if (rating == null || rating.isEmpty) return null;
|
||||
final value = double.tryParse(rating);
|
||||
@@ -161,8 +176,12 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<MobileMoviesTab> {
|
||||
|
||||
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<MobileMoviesTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// 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<MobileMoviesTab> {
|
||||
),
|
||||
|
||||
// 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<MobileMoviesTab> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class MobileSeriesTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
|
||||
}
|
||||
|
||||
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
|
||||
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with AutomaticKeepAliveClientMixin {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final List<Series> _series = [];
|
||||
@@ -99,10 +99,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
|
||||
|
||||
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<MobileSeriesTab> {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading series: $e');
|
||||
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) {
|
||||
if (rating == null || rating.isEmpty) return null;
|
||||
final value = double.tryParse(rating);
|
||||
@@ -146,8 +171,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final settings = ref.watch(mobileSettingsProvider);
|
||||
|
||||
List<Series> displaySeries;
|
||||
@@ -170,8 +199,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
|
||||
|
||||
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<MobileSeriesTab> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,48 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
),
|
||||
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
|
||||
const _SectionHeader(title: 'Filtres de Contenu'),
|
||||
Container(
|
||||
|
||||
@@ -8,12 +8,14 @@ class MobileSettings {
|
||||
final List<String> moviesKeywords;
|
||||
final List<String> 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<String>? moviesKeywords,
|
||||
List<String>? 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<MobileSettings> {
|
||||
final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
|
||||
final series = _box?.get('seriesKeywords', defaultValue: <String>[]) 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<String>() ?? [],
|
||||
moviesKeywords: movies?.cast<String>() ?? [],
|
||||
seriesKeywords: series?.cast<String>() ?? [],
|
||||
showClock: showClock ?? false,
|
||||
bufferDuration: buffer ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +107,11 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
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<MobileSettingsNotifier, MobileSettings>((ref) {
|
||||
|
||||
Reference in new issue
Block a user