feat: enforce player strategy (vod=ultra, live=lite), clean UI and settings

This commit is contained in:
Michael committed 2025-12-16 22:37:47 +01:00
1 parent 5fedee7ec3
commit a2b8d5fb09
5 files changed
+417 -364

No files matched your search

@@ -21,10 +21,12 @@ class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
});
@override
ConsumerState<MobileSeriesDetailScreen> createState() => _MobileSeriesDetailScreenState();
ConsumerState<MobileSeriesDetailScreen> createState() =>
_MobileSeriesDetailScreenState();
}
class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScreen> {
class _MobileSeriesDetailScreenState
extends ConsumerState<MobileSeriesDetailScreen> {
SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
@@ -52,8 +54,10 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
_error = null;
});
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final info = await service.getSeriesInfo(widget.series.seriesId.toString());
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final info =
await service.getSeriesInfo(widget.series.seriesId.toString());
if (mounted) {
setState(() {
@@ -85,10 +89,15 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const Icon(Icons.error_outline,
size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error: $_error', style: const TextStyle(color: AppColors.textSecondary)),
TextButton(onPressed: _loadSeriesInfo, child: const Text('Retry')),
Text('Error: $_error',
style:
const TextStyle(color: AppColors.textSecondary)),
TextButton(
onPressed: _loadSeriesInfo,
child: const Text('Retry')),
],
),
)
@@ -128,7 +137,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
CachedNetworkImage(
imageUrl: _seriesInfo!.cover!,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(color: Colors.grey.shade900),
errorWidget: (_, __, ___) =>
Container(color: Colors.grey.shade900),
),
// Gradient Overlay
Container(
@@ -166,17 +176,20 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
Row(
children: [
if (_seriesInfo!.rating != null) ...[
const Icon(Icons.star, size: 16, color: Colors.amber),
const Icon(Icons.star,
size: 16, color: Colors.amber),
const SizedBox(width: 4),
Text(
_formatRating(_seriesInfo!.rating!)!,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(width: 16),
],
Text(
'${_seriesInfo!.episodes.keys.length} Seasons',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
],
),
@@ -195,7 +208,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
padding: const EdgeInsets.all(16.0),
child: Text(
_seriesInfo!.plot!,
style: const TextStyle(color: AppColors.textSecondary, height: 1.4),
style: const TextStyle(
color: AppColors.textSecondary, height: 1.4),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
@@ -215,23 +229,31 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
itemCount: _seriesInfo!.episodes.keys.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final seasonNum = _seriesInfo!.episodes.keys.elementAt(index);
final seasonNum =
_seriesInfo!.episodes.keys.elementAt(index);
final isSelected = seasonNum == _selectedSeason;
return ChoiceChip(
label: Text('Season $seasonNum'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _selectedSeason = seasonNum);
if (selected)
setState(() => _selectedSeason = seasonNum);
},
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.grey.shade300,
backgroundColor: isDark
? const Color(0xFF1C1C1E)
: Colors.grey.shade300,
selectedColor: isDark ? Colors.white : Colors.blue,
labelStyle: TextStyle(
color: isSelected
color: isSelected
? (isDark ? Colors.black : Colors.white)
: (isDark ? Colors.grey.shade400 : Colors.grey.shade700),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
: (isDark
? Colors.grey.shade400
: Colors.grey.shade700),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)),
showCheckmark: false,
side: BorderSide.none,
);
@@ -269,58 +291,45 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
// Use theme colors for proper light/dark mode support
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final textColor = isDark ? Colors.white : Colors.black;
final secondaryTextColor = isDark ? Colors.grey.shade400 : Colors.grey.shade600;
final surfaceColor = isDark ? const Color(0xFF1C1C1E) : Colors.grey.shade200;
final secondaryTextColor =
isDark ? Colors.grey.shade400 : Colors.grey.shade600;
final surfaceColor =
isDark ? const Color(0xFF1C1C1E) : Colors.grey.shade200;
final primaryColor = isDark ? Colors.white : Colors.blue;
return InkWell(
onTap: () {
// Watch progress is tracked in player at 80% completion
final engine = ref.read(mobileSettingsProvider).playerEngine;
if (engine == 'lite') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
streamId: episode.streamId,
title: "${widget.series.name} S${episode.seasonNum}E${episode.episodeNum}",
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mp4',
seriesId: widget.series.seriesId,
season: episode.seasonNum,
episodeNum: episode.episodeNum,
),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: episode.streamId,
title: "${widget.series.name} S${episode.seasonNum}E${episode.episodeNum}",
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mp4',
seriesId: widget.series.seriesId,
season: episode.seasonNum,
episodeNum: episode.episodeNum,
),
),
);
}
// Enforce Native Player for Series (VOD)
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: episode.streamId,
title:
"${widget.series.name} S${episode.seasonNum}E${episode.episodeNum}",
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mp4',
seriesId: widget.series.seriesId,
season: episode.seasonNum,
episodeNum: episode.episodeNum,
),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: isDark ? Colors.white10 : Colors.black12)),
border: Border(
bottom:
BorderSide(color: isDark ? Colors.white10 : Colors.black12)),
),
child: Row(
children: [
@@ -331,11 +340,15 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
decoration: BoxDecoration(
color: isWatched ? primaryColor : surfaceColor,
shape: BoxShape.circle,
border: isWatched ? null : Border.all(color: primaryColor, width: 2),
border: isWatched
? null
: Border.all(color: primaryColor, width: 2),
),
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched ? (isDark ? Colors.black : Colors.white) : primaryColor,
color: isWatched
? (isDark ? Colors.black : Colors.white)
: primaryColor,
size: 20,
),
),
@@ -968,20 +968,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// FORCE AUDIO BUTTON (First item)
TVFocusable(
focusNode: _audioFocusNode,
onPressed: _cycleAudioTrack,
onFocus: _resetControlsTimer,
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(Icons.audiotrack,
color: Colors.greenAccent, size: 32),
onPressed: _cycleAudioTrack,
),
),
const SizedBox(width: 16),
// Prev Channel / Replay 10s
if (widget.streamType == StreamType.live &&
widget.channels != null &&
@@ -23,14 +23,15 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
ConsumerState<MobileLiveTVTab> createState() => _MobileLiveTVTabState();
}
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with AutomaticKeepAliveClientMixin {
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
with AutomaticKeepAliveClientMixin {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
bool _showFavoritesOnly = false;
bool _isSearchEditing = false;
bool _justReturnedFromPlayer = false; // Flag to prevent PopScope interception after player
bool _justReturnedFromPlayer =
false; // Flag to prevent PopScope interception after player
@override
void initState() {
@@ -55,7 +56,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
@override
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 settings = ref.watch(mobileSettingsProvider);
final uiState = ref.watch(mobileLiveTvUiStateProvider);
@@ -66,193 +68,228 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
child: Scaffold(
backgroundColor: Colors.transparent,
body: PopScope(
// Only allow full pop if we're at category view with no filters AND not just returned from player
canPop: uiState.isCategoryView && _searchQuery.isEmpty && !_showFavoritesOnly,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If just returned from player, reset the flag and do nothing (stay on channel list)
if (_justReturnedFromPlayer) {
_justReturnedFromPlayer = false;
return;
}
if (_searchQuery.isNotEmpty) {
setState(() {
_searchController.clear();
_searchQuery = '';
});
} else if (_showFavoritesOnly) {
setState(() => _showFavoritesOnly = false);
} else if (!uiState.isCategoryView) {
uiNotifier.state = uiState.copyWith(isCategoryView: true);
}
},
child: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
),
data: (groupedChannels) {
// Prepare categories
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
// Only allow full pop if we're at category view with no filters AND not just returned from player
canPop: uiState.isCategoryView &&
_searchQuery.isEmpty &&
!_showFavoritesOnly,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// Prepare channels
List<Channel> displayedChannels = [];
// Determine mode based on search/favorites/selection
bool showGrid = uiState.isCategoryView && _searchQuery.isEmpty && !_showFavoritesOnly;
if (!showGrid) {
if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (uiState.selectedCategory != null) {
displayedChannels = groupedChannels[uiState.selectedCategory] ?? [];
// If just returned from player, reset the flag and do nothing (stay on channel list)
if (_justReturnedFromPlayer) {
_justReturnedFromPlayer = false;
return;
}
}
return SafeArea(
bottom: false,
child: Column(
children: [
// Header (Search + Title/Back)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
// Search Bar - Wrapped in TVFocusable for remote access
TVFocusable(
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: _isSearchEditing ? Border.all(color: AppColors.primary) : null,
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Rechercher une chaîne...',
border: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close, color: AppColors.textSecondary),
),
],
),
),
),
const SizedBox(height: 12),
// Title / Navigation Row
Row(
if (_searchQuery.isNotEmpty) {
setState(() {
_searchController.clear();
_searchQuery = '';
});
} else if (_showFavoritesOnly) {
setState(() => _showFavoritesOnly = false);
} else if (!uiState.isCategoryView) {
uiNotifier.state = uiState.copyWith(isCategoryView: true);
}
},
child: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
),
data: (groupedChannels) {
// Prepare categories
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
}
categories.sort();
// Prepare channels
List<Channel> displayedChannels = [];
// Determine mode based on search/favorites/selection
bool showGrid = uiState.isCategoryView &&
_searchQuery.isEmpty &&
!_showFavoritesOnly;
if (!showGrid) {
if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (uiState.selectedCategory != null) {
displayedChannels =
groupedChannels[uiState.selectedCategory] ?? [];
}
}
return SafeArea(
bottom: false,
child: Column(
children: [
// Header (Search + Title/Back)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Column(
children: [
if (!showGrid || _showFavoritesOnly || _searchQuery.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(() => _showFavoritesOnly = false);
} else {
uiNotifier.state = uiState.copyWith(isCategoryView: true);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty ? 'Résultats de recherche' :
_showFavoritesOnly ? 'Favoris' :
showGrid ? 'Catégories' :
uiState.selectedCategory ?? 'Chaînes',
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 18,
// Search Bar - Wrapped in TVFocusable for remote access
TVFocusable(
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput
.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: _isSearchEditing
? Border.all(color: AppColors.primary)
: null,
),
padding:
const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search,
color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: const TextStyle(
color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Rechercher une chaîne...',
border: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(
() => _isSearchEditing = false),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close,
color: AppColors.textSecondary),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
),
onPressed: () => setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
// Reset category view if entering favorites
if (_showFavoritesOnly) uiNotifier.state = uiState.copyWith(isCategoryView: false);
// If exiting favorites, default depends on logic (here back to category grid if was previously)
if (!_showFavoritesOnly) uiNotifier.state = uiState.copyWith(isCategoryView: true);
}),
const SizedBox(height: 12),
// Title / Navigation Row
Row(
children: [
if (!showGrid ||
_showFavoritesOnly ||
_searchQuery.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.arrow_back,
color: AppColors.textPrimary),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(
() => _showFavoritesOnly = false);
} else {
uiNotifier.state = uiState.copyWith(
isCategoryView: true);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty
? 'Résultats de recherche'
: _showFavoritesOnly
? 'Favoris'
: showGrid
? 'Catégories'
: uiState.selectedCategory ??
'Chaînes',
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.error
: AppColors.textSecondary,
),
onPressed: () => setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
// Reset category view if entering favorites
if (_showFavoritesOnly)
uiNotifier.state =
uiState.copyWith(isCategoryView: false);
// If exiting favorites, default depends on logic (here back to category grid if was previously)
if (!_showFavoritesOnly)
uiNotifier.state =
uiState.copyWith(isCategoryView: true);
}),
),
],
),
],
),
],
),
),
),
// Main Content
Expanded(
child: showGrid
? _buildCategoryGrid(categories)
: _buildChannelList(displayedChannels),
// Main Content
Expanded(
child: showGrid
? _buildCategoryGrid(categories)
: _buildChannelList(displayedChannels),
),
],
),
],
),
);
},
),
),
);
},
),
),
),
);
}
@@ -280,7 +317,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
final category = categories[index];
return TVFocusable(
onPressed: () {
ref.read(mobileLiveTvUiStateProvider.notifier).state = LiveTvUiState(
ref.read(mobileLiveTvUiStateProvider.notifier).state =
LiveTvUiState(
selectedCategory: category,
isCategoryView: false,
);
@@ -310,9 +348,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.tv,
color: AppColors.primary,
const Icon(
Icons.tv,
color: AppColors.primary,
size: 32,
),
const SizedBox(height: 12),
@@ -359,45 +397,30 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
return _MobileChannelCard(
channel: channel,
playlist: widget.playlist,
onTap: () => _playChannel(context, channel, channels, widget.playlist, index),
onTap: () =>
_playChannel(context, channel, channels, widget.playlist, index),
);
},
);
}
void _playChannel(BuildContext context, Channel channel, List<Channel> channels, PlaylistConfig playlist, int index) async {
final engine = ref.read(mobileSettingsProvider).playerEngine;
if (engine == 'lite') {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
initialIndex: index,
),
void _playChannel(BuildContext context, Channel channel,
List<Channel> channels, PlaylistConfig playlist, int index) async {
// ENFORCE LITE PLAYER FOR LIVE TV
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
initialIndex: index,
),
);
} else {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
initialIndex: index,
),
),
);
}
),
);
// When returning from player, set flag to prevent PopScope from going back to categories
if (mounted) {
setState(() => _justReturnedFromPlayer = true);
@@ -411,7 +434,7 @@ class _MobileChannelCard extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const _MobileChannelCard({
required this.channel,
required this.channel,
required this.onTap,
required this.playlist,
});
@@ -422,16 +445,17 @@ class _MobileChannelCard extends ConsumerStatefulWidget {
class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
String? _epgTitle;
@override
void initState() {
super.initState();
_loadEpg();
}
Future<void> _loadEpg() async {
try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final epg = await service.getShortEPG(widget.channel.streamId);
if (mounted && epg.nowPlaying != null && epg.nowPlaying!.isNotEmpty) {
setState(() {
@@ -445,17 +469,20 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
@override
Widget build(BuildContext context) {
final iconUrl = widget.channel.streamIcon.isNotEmpty && widget.channel.streamIcon.startsWith('http')
? widget.channel.streamIcon
final iconUrl = widget.channel.streamIcon.isNotEmpty &&
widget.channel.streamIcon.startsWith('http')
? widget.channel.streamIcon
: null;
final favorites = ref.watch(mobileFavoritesProvider);
final isFavorite = favorites.contains(widget.channel.streamId);
return TVFocusable(
onPressed: widget.onTap,
onLongPress: () {
ref.read(mobileFavoritesProvider.notifier).toggle(widget.channel.streamId);
ref
.read(mobileFavoritesProvider.notifier)
.toggle(widget.channel.streamId);
},
child: Container(
decoration: BoxDecoration(
@@ -489,10 +516,13 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white38, size: 40),
placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24, size: 40),
errorWidget: (_, __, ___) => const Icon(Icons.tv,
color: Colors.white38, size: 40),
placeholder: (_, __) => const Icon(Icons.tv,
color: Colors.white24, size: 40),
)
: const Icon(Icons.tv, color: Colors.white38, size: 40),
: const Icon(Icons.tv,
color: Colors.white38, size: 40),
),
// Favorite badge
if (isFavorite)
@@ -505,7 +535,8 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.favorite, color: Colors.white, size: 12),
child: const Icon(Icons.favorite,
color: Colors.white, size: 12),
),
),
],
@@ -181,7 +181,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
void _playMovie(BuildContext context, Movie movie) {
if (widget.playlist == null) return;
// DEBUG: FORCE NATIVE PLAYER (Ignoring settings)
// Enforce Native Player for Movies (VOD)
Navigator.push(
context,
MaterialPageRoute(
@@ -311,7 +311,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
style: const TextStyle(
fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Rechercher (DEBUG V3)...',
hintText: 'Rechercher un film...',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
@@ -51,7 +51,11 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
}
List<String> _parseKeywords(String text) {
return text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
return text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
@override
@@ -59,7 +63,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
final settings = ref.watch(mobileSettingsProvider);
final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings);
});
@@ -80,8 +84,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 28),
child: const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 28),
),
const SizedBox(width: 16),
Column(
@@ -107,50 +111,43 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
],
),
),
const SizedBox(height: 16),
// === APPARENCE ===
_buildSectionHeader('Apparence'),
_buildSettingItem(
icon: isDark ? Icons.dark_mode : Icons.light_mode,
title: 'Mode Sombre',
value: isDark ? 'Activé' : 'Désactivé',
onTap: () => themeNotifier.toggleTheme(),
),
_buildSettingItem(
icon: Icons.access_time,
title: 'Afficher l\'heure',
value: settings.showClock ? 'Activé' : 'Désactivé',
onTap: () => ref.read(mobileSettingsProvider.notifier).toggleShowClock(!settings.showClock),
onTap: () => ref
.read(mobileSettingsProvider.notifier)
.toggleShowClock(!settings.showClock),
),
const SizedBox(height: 24),
// === LECTURE VIDEO ===
_buildSectionHeader('Lecture Vidéo'),
_buildSettingItem(
icon: Icons.rocket_launch,
title: 'Moteur Vidéo',
subtitle: settings.playerEngine == 'ultra' ? 'Ultra (MPV) - Puissant' : 'Lite (ExoPlayer) - Léger',
value: settings.playerEngine == 'ultra' ? 'Ultra' : 'Lite',
onTap: () {
final newValue = settings.playerEngine == 'ultra' ? 'lite' : 'ultra';
ref.read(mobileSettingsProvider.notifier).setPlayerEngine(newValue);
},
),
_buildSettingItem(
icon: Icons.bug_report,
title: 'Stats pour Nerds',
subtitle: 'Affiche FPS, Buffer, Bitrate',
value: settings.showDebugStats ? 'Activé' : 'Désactivé',
onTap: () => ref.read(mobileSettingsProvider.notifier).toggleShowDebugStats(!settings.showDebugStats),
onTap: () => ref
.read(mobileSettingsProvider.notifier)
.toggleShowDebugStats(!settings.showDebugStats),
),
_buildSettingItem(
icon: Icons.memory,
title: 'Décodeur Vidéo',
@@ -160,63 +157,77 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
final modes = ['auto', 'mediacodec', 'no'];
final currentIndex = modes.indexOf(settings.decoderMode);
final nextIndex = (currentIndex + 1) % modes.length;
ref.read(mobileSettingsProvider.notifier).setDecoderMode(modes[nextIndex]);
ref
.read(mobileSettingsProvider.notifier)
.setDecoderMode(modes[nextIndex]);
},
),
_buildSettingItem(
icon: Icons.speed,
title: 'Buffer (Cache)',
subtitle: settings.bufferDuration == 0 ? 'Automatique' : '${settings.bufferDuration} secondes',
value: settings.bufferDuration == 0 ? 'Auto' : '${settings.bufferDuration}s',
subtitle: settings.bufferDuration == 0
? 'Automatique'
: '${settings.bufferDuration} secondes',
value: settings.bufferDuration == 0
? 'Auto'
: '${settings.bufferDuration}s',
onTap: () {
final buffers = [0, 15, 30, 60];
final currentIndex = buffers.indexOf(settings.bufferDuration);
final nextIndex = (currentIndex + 1) % buffers.length;
ref.read(mobileSettingsProvider.notifier).setBufferDuration(buffers[nextIndex]);
ref
.read(mobileSettingsProvider.notifier)
.setBufferDuration(buffers[nextIndex]);
},
),
const SizedBox(height: 24),
// === FILTRES ===
_buildSectionHeader('Filtres de Contenu'),
_buildFilterInput(
label: 'TV en Direct',
hint: 'Ex: FR, HD, SPORT',
icon: Icons.live_tv,
controller: _liveTvController,
onChanged: (val) {
ref.read(mobileSettingsProvider.notifier).setLiveTvKeywords(_parseKeywords(val));
ref
.read(mobileSettingsProvider.notifier)
.setLiveTvKeywords(_parseKeywords(val));
},
),
_buildFilterInput(
label: 'Films',
hint: 'Ex: FR, VF, 4K',
icon: Icons.movie,
controller: _moviesController,
onChanged: (val) {
ref.read(mobileSettingsProvider.notifier).setMoviesKeywords(_parseKeywords(val));
ref
.read(mobileSettingsProvider.notifier)
.setMoviesKeywords(_parseKeywords(val));
},
),
_buildFilterInput(
label: 'Séries',
hint: 'Ex: FR, VF',
icon: Icons.tv,
controller: _seriesController,
onChanged: (val) {
ref.read(mobileSettingsProvider.notifier).setSeriesKeywords(_parseKeywords(val));
ref
.read(mobileSettingsProvider.notifier)
.setSeriesKeywords(_parseKeywords(val));
},
),
const SizedBox(height: 24),
// === CACHE ===
_buildSectionHeader('Cache'),
_buildSettingItem(
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
title: 'Actualiser le cache',
@@ -224,12 +235,12 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
value: _isRefreshingCache ? '...' : 'Appuyer',
onTap: _isRefreshingCache ? null : _refreshCache,
),
const SizedBox(height: 24),
// === PLAYLIST ===
_buildSectionHeader('Playlist'),
_buildSettingItem(
icon: Icons.playlist_play,
title: 'Gérer les Playlists',
@@ -239,12 +250,13 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const MobilePlaylistSelectionScreen(manageMode: true),
builder: (_) =>
const MobilePlaylistSelectionScreen(manageMode: true),
),
);
},
),
const SizedBox(height: 100),
],
),
@@ -252,33 +264,41 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
}
// === HELPER METHODS ===
String _getDecoderSubtitle(String mode) {
switch (mode) {
case 'auto': return 'Automatique (Recommandé)';
case 'mediacodec': return 'Matériel (Hardware)';
case 'no': return 'Logiciel (Software)';
default: return 'Automatique';
case 'auto':
return 'Automatique (Recommandé)';
case 'mediacodec':
return 'Matériel (Hardware)';
case 'no':
return 'Logiciel (Software)';
default:
return 'Automatique';
}
}
String _getDecoderValue(String mode) {
switch (mode) {
case 'auto': return 'Auto';
case 'mediacodec': return 'HW';
case 'no': return 'SW';
default: return 'Auto';
case 'auto':
return 'Auto';
case 'mediacodec':
return 'HW';
case 'no':
return 'SW';
default:
return 'Auto';
}
}
Future<void> _refreshCache() async {
setState(() => _isRefreshingCache = true);
try {
final dir = await getApplicationDocumentsDirectory();
final cacheStore = HiveCacheStore(dir.path);
await cacheStore.clean();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -300,7 +320,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
}
// === UI BUILDERS ===
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
@@ -362,7 +382,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
@@ -423,8 +444,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
Text(
controller.text.isEmpty ? hint : controller.text,
style: TextStyle(
color: controller.text.isEmpty
? AppColors.textSecondary
color: controller.text.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
fontSize: 12,
),
@@ -442,14 +463,16 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
);
}
void _showKeyboardDialog(String label, TextEditingController controller, ValueChanged<String> onChanged) {
void _showKeyboardDialog(String label, TextEditingController controller,
ValueChanged<String> onChanged) {
final tempController = TextEditingController(text: controller.text);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text('Filtres $label', style: const TextStyle(color: AppColors.textPrimary)),
title: Text('Filtres $label',
style: const TextStyle(color: AppColors.textPrimary)),
content: TextField(
controller: tempController,
autofocus: true,