fix: restore EPG display in live TV player and channel cards

- Fix EPG not showing in NativePlayerScreen by reusing the shared
  mobileXtreamServiceProvider (cache already populated by channel cards)
  instead of creating a fresh service instance per player session
- Add periodic EPG refresh timer (every 5 min) matching LitePlayerScreen
- Fix empty-title fallback in _buildEPGBox
- Force dark theme, invert default live TV player (NativePlayer default),
  remove next-program display from OSD
- Fix back button exit dialog firing from wrong tab (add tab index guards)
- Halve card grid count for Movies/Series (6 categories, 8 items)
- Revamp Movies/Series search bars to match Live TV aesthetic
- Fix first channel row clipping when entering a category (scroll-to-top)
- Fix playlist edit dialog TV remote navigation
- Fix empty playlist screen button readability
- Remove theme switcher from settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-29 10:04:47 +02:00
1 parent f07a6e91f9
commit 6bb0d73f38
8 files changed
+564 -350

No files matched your search

@@ -27,6 +27,7 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
@@ -60,6 +61,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchTimer?.cancel();
@@ -80,13 +82,24 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
final uiState = ref.watch(mobileLiveTvUiStateProvider);
final uiNotifier = ref.read(mobileLiveTvUiStateProvider.notifier);
// Scroll to top when entering a category so first row is fully visible
ref.listen(mobileLiveTvUiStateProvider, (previous, next) {
if (previous?.isCategoryView == true && !next.isCategoryView) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
});
}
});
// Determine if we should show the category grid
final bool showGrid = uiState.isCategoryView &&
_searchQuery.isEmpty &&
!_showFavoritesOnly;
return Container(
color: const Color(0xFF000000), // Pure black background
color: const Color(0xFF000000),
child: Scaffold(
backgroundColor: Colors.transparent,
body: PopScope(
@@ -95,6 +108,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 0)
if (ref.read(mobileDashboardIndexProvider) != 0) return;
// If just returned from player, reset the flag and do nothing (stay on channel list)
if (_justReturnedFromPlayer) {
_justReturnedFromPlayer = false;
@@ -194,6 +210,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
return SafeArea(
bottom: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Floating Header (Search + Navigation)
SliverAppBar(
@@ -447,14 +464,13 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
builder: (context) => LitePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
channels: channels,
initialIndex: index,
forceDeinterlace: true,
),
),
);
@@ -462,13 +478,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
builder: (context) => NativePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
channels: channels,
initialIndex: index,
forceDeinterlace: false,
),
),
);
@@ -264,6 +264,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 1)
if (ref.read(mobileDashboardIndexProvider) != 1) return;
// Handle Search Exit
if (_searchQuery.isNotEmpty || _isSearching) {
_onSearchChanged(''); // clear search
@@ -327,94 +330,101 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
floating: false,
snap: false,
backgroundColor: const Color(0xFF000000),
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
expandedHeight: showingMovies ? 110 : 110,
collapsedHeight: showingMovies ? 110 : 110,
toolbarHeight: showingMovies ? 120.0 : 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showingMovies)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Icon(Icons.search, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Films',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); _onSearchChanged(''); },
child: Icon(Icons.close, color: AppDecorations.textSecondary(context)),
),
],
),
)
else
const SizedBox(height: 10),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
const SizedBox(height: 12),
// Navigation Row
Row(
children: [
if (showingMovies) ...[
IconButton(
icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)),
onPressed: _onBack,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
showingMovies
? (_selectedCategoryName ?? 'Films')
: showingSearch
? 'Résultats de recherche'
: 'Catégories',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
@@ -427,7 +437,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 3 : 4,
crossAxisCount: showingCategories ? 6 : 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
@@ -251,6 +251,9 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 2)
if (ref.read(mobileDashboardIndexProvider) != 2) return;
if (_searchQuery.isNotEmpty || _isSearching) {
_onSearchChanged('');
_searchController.clear();
@@ -297,94 +300,101 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
floating: false,
snap: false,
backgroundColor: const Color(0xFF000000),
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
expandedHeight: showingSeries ? 110 : 110,
collapsedHeight: showingSeries ? 110 : 110,
toolbarHeight: showingSeries ? 120.0 : 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showingSeries)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Icon(Icons.search, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Séries',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); _onSearchChanged(''); },
child: Icon(Icons.close, color: AppDecorations.textSecondary(context)),
),
],
),
)
else
const SizedBox(height: 10),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
const SizedBox(height: 12),
// Navigation Row
Row(
children: [
if (showingSeries) ...[
IconButton(
icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)),
onPressed: _onBack,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
showingSeries
? (_selectedCategoryName ?? 'Séries')
: showingSearch
? 'Résultats de recherche'
: 'Catégories',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
@@ -396,7 +406,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 3 : 4,
crossAxisCount: showingCategories ? 6 : 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
@@ -117,10 +117,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
// === APPARENCE ===
_buildSectionHeader('Apparence'),
_buildThemeSwitcher(),
const SizedBox(height: 8),
_buildSettingItem(
icon: Icons.access_time,
title: 'Afficher l\'heure',
@@ -360,8 +356,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
final selected = themeState.appThemeMode == mode;
final accentColor = Theme.of(context).colorScheme.primary;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setThemeMode(mode),
child: TVFocusable(
onPressed: () => notifier.setThemeMode(mode),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 2),