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

@@ -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(