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

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../mobile/widgets/tv_focusable.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../mobile/theme/mobile_theme.dart';
@@ -45,155 +46,37 @@ class _MobilePlaylistSelectionScreenState
Future<void> _showPlaylistDialog({PlaylistConfig? playlist}) async {
final isEditing = playlist != null;
final nameController = TextEditingController(text: playlist?.name ?? '');
final dnsController = TextEditingController(text: playlist?.dns ?? '');
final userController =
TextEditingController(text: playlist?.username ?? '');
final passController =
TextEditingController(text: playlist?.password ?? '');
await showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(
isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Nom',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: dnsController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'URL (DNS)',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: userController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Utilisateur',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: passController,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Mot de passe',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
],
),
),
actions: [
if (isEditing)
TextButton(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text(
'Confirmer',
style: TextStyle(color: Colors.white),
),
content: const Text(
'Supprimer cette playlist ?',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style:
TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
await PlaylistApiService().deletePlaylist(playlist.id);
if (context.mounted) {
Navigator.pop(context);
ref.invalidate(playlistsProvider);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
if (nameController.text.isEmpty || dnsController.text.isEmpty) {
return;
}
final service = PlaylistApiService();
if (isEditing) {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
} else {
await service.createPlaylist(
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
}
if (context.mounted) {
Navigator.pop(context);
builder: (context) => _PlaylistEditDialog(
isEditing: isEditing,
playlist: playlist,
onSave: (name, dns, username, password) async {
final service = PlaylistApiService();
if (isEditing) {
await service.updatePlaylist(
id: playlist.id,
name: name,
dns: dns,
username: username,
password: password,
);
} else {
await service.createPlaylist(
name: name,
dns: dns,
username: username,
password: password,
);
}
ref.invalidate(playlistsProvider);
},
onDelete: isEditing
? () async {
await PlaylistApiService().deletePlaylist(playlist.id);
ref.invalidate(playlistsProvider);
}
},
child: const Text('Enregistrer'),
),
],
: null,
),
);
}
@@ -271,16 +154,32 @@ class _MobilePlaylistSelectionScreenState
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
TVFocusable(
onPressed: () => _showPlaylistDialog(),
icon: const Icon(Icons.add),
label: const Text('Ajouter une Playlist'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
horizontal: 28,
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white, size: 20),
SizedBox(width: 10),
Text(
'Ajouter une Playlist',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
],
),
),
),
@@ -322,6 +221,285 @@ class _MobilePlaylistSelectionScreenState
}
}
/// TV-friendly playlist edit dialog.
/// Each field is a focusable row; pressing select opens a single-field input dialog.
class _PlaylistEditDialog extends StatefulWidget {
final bool isEditing;
final PlaylistConfig? playlist;
final Future<void> Function(String name, String dns, String username, String password) onSave;
final Future<void> Function()? onDelete;
const _PlaylistEditDialog({
required this.isEditing,
required this.playlist,
required this.onSave,
this.onDelete,
});
@override
State<_PlaylistEditDialog> createState() => _PlaylistEditDialogState();
}
class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
late String _name;
late String _dns;
late String _username;
late String _password;
bool _isSaving = false;
@override
void initState() {
super.initState();
_name = widget.playlist?.name ?? '';
_dns = widget.playlist?.dns ?? '';
_username = widget.playlist?.username ?? '';
_password = widget.playlist?.password ?? '';
}
Future<void> _editField({
required String label,
required String currentValue,
required void Function(String) onSaved,
bool obscure = false,
TextInputType keyboardType = TextInputType.text,
}) async {
final controller = TextEditingController(text: currentValue);
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(label, style: const TextStyle(color: Colors.white)),
content: TextField(
controller: controller,
autofocus: true,
obscureText: obscure,
keyboardType: keyboardType,
textInputAction: TextInputAction.done,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
),
onSubmitted: (val) => Navigator.pop(ctx, val),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
autofocus: false,
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('OK'),
),
],
),
);
controller.dispose();
if (result != null) {
onSaved(result);
}
}
Widget _buildFieldRow({
required IconData icon,
required String label,
required String value,
required VoidCallback onTap,
bool obscure = false,
}) {
final displayValue = obscure && value.isNotEmpty
? '•' * value.length.clamp(1, 12)
: value.isEmpty
? '—'
: value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TVFocusable(
onPressed: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(icon, color: AppColors.primary, size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
displayValue,
style: TextStyle(
color: value.isEmpty ? Colors.white38 : Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.edit, color: Colors.white38, size: 18),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: AppColors.surface,
title: Text(
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
),
content: SizedBox(
width: 480,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFieldRow(
icon: Icons.label_outline,
label: 'Nom',
value: _name,
onTap: () => _editField(
label: 'Nom',
currentValue: _name,
onSaved: (v) => setState(() => _name = v),
),
),
_buildFieldRow(
icon: Icons.link,
label: 'URL (DNS)',
value: _dns,
onTap: () => _editField(
label: 'URL (DNS)',
currentValue: _dns,
keyboardType: TextInputType.url,
onSaved: (v) => setState(() => _dns = v),
),
),
_buildFieldRow(
icon: Icons.person_outline,
label: 'Utilisateur',
value: _username,
onTap: () => _editField(
label: 'Utilisateur',
currentValue: _username,
onSaved: (v) => setState(() => _username = v),
),
),
_buildFieldRow(
icon: Icons.lock_outline,
label: 'Mot de passe',
value: _password,
obscure: true,
onTap: () => _editField(
label: 'Mot de passe',
currentValue: _password,
obscure: true,
onSaved: (v) => setState(() => _password = v),
),
),
],
),
),
actions: [
if (widget.onDelete != null)
TVFocusable(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
await widget.onDelete!();
if (context.mounted) Navigator.pop(context);
}
},
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
),
),
TVFocusable(
onPressed: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
),
),
TVFocusable(
onPressed: _isSaving || _name.isEmpty || _dns.isEmpty
? null
: () async {
setState(() => _isSaving = true);
await widget.onSave(_name, _dns, _username, _password);
if (context.mounted) Navigator.pop(context);
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _name.isEmpty || _dns.isEmpty
? Colors.white12
: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_isSaving ? '...' : 'Enregistrer',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
class _MobilePlaylistCard extends StatelessWidget {
final PlaylistConfig playlist;
final VoidCallback onTap;
@@ -11,6 +11,7 @@ import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -86,6 +87,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Timer? _clockTimer;
Timer? _controlsTimer; // Auto-hide timer
Timer? _epgTimer; // Periodic EPG refresh for live TV
Timer? _liveWatchdog; // Watchdog for live stream reconnection
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
@@ -464,6 +466,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_clockTimer?.cancel();
_controlsTimer?.cancel();
_epgTimer?.cancel();
_liveWatchdog?.cancel();
// Stop playback first to prevent audio continuing in background
@@ -702,9 +705,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
? widget.channels![_currentIndex].streamId
: widget.streamId;
// Load EPG if Live TV
// Load EPG if Live TV (with periodic refresh every 5 min)
if (widget.streamType == model.StreamType.live) {
_loadEpg(currentStreamId);
_epgTimer?.cancel();
_epgTimer = Timer.periodic(const Duration(minutes: 5), (_) {
_loadEpg(currentStreamId);
});
}
// ... (rest of _loadStream logic: get url, open player)
@@ -806,7 +813,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future<void> _loadEpg(String streamId) async {
try {
final epg = await _xtreamService!.getShortEPG(streamId);
// Use shared provider service so EPG cached by channel cards is reused instantly
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final epg = await service.getShortEPG(streamId);
if (mounted) {
setState(() => _epg = epg);
}
@@ -1323,7 +1332,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
Widget _buildEPGBox(String title) {
String nowPlaying = _epg?.nowPlaying ?? "Pas d'infos EPG";
final rawNow = _epg?.nowPlaying ?? '';
String nowPlaying = rawNow.isNotEmpty ? rawNow : "Pas d'infos EPG";
String nextPlaying =
_epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : "";
@@ -1375,19 +1385,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
),
],
),
if (nextPlaying.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
nextPlaying,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}