feat: implement IPTV player screen with iframe-based stream support and recordings tab widget

This commit is contained in:
Michael committed 2026-04-01 20:19:40 +02:00
1 parent 77dc9053bb
commit 6a4a642611
13 files changed
+218 -120

No files matched your search

+9 -4
View File
@@ -511,8 +511,12 @@ class AppDatabase {
} }
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement /// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
void updateRecordingStatus(String id, String status, void updateRecordingStatus(
{String? filePath, String? errorReason}) { String id,
String status, {
String? filePath,
String? errorReason,
}) {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
_db.execute( _db.execute(
''' '''
@@ -551,14 +555,15 @@ class AppDatabase {
'channel_id': channelId, 'channel_id': channelId,
'stream_url': streamUrl, 'stream_url': streamUrl,
'enabled': 1, 'enabled': 1,
'created_at': now 'created_at': now,
}; };
} }
/// Lister tous les Season Passes /// Lister tous les Season Passes
List<Map<String, dynamic>> getAllSeasonPasses() { List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select( final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC'); 'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
);
return result.map((r) => Map<String, dynamic>.from(r)).toList(); return result.map((r) => Map<String, dynamic>.from(r)).toList();
} }
+70 -34
View File
@@ -26,10 +26,13 @@ class RecordingScheduler {
void start() { void start() {
print( print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV'); '[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV',
);
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat) // Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
_timer = Timer.periodic( _timer = Timer.periodic(
const Duration(seconds: 10), (_) => _checkAndRunRecordings()); const Duration(seconds: 10),
(_) => _checkAndRunRecordings(),
);
// Season Passes : vérifier toutes les 4 heures // Season Passes : vérifier toutes les 4 heures
_seasonPassTimer = _seasonPassTimer =
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses()); Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
@@ -56,13 +59,15 @@ class RecordingScheduler {
final recordings = _db.getAllRecordings(); final recordings = _db.getAllRecordings();
print( print(
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}'); '[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}',
);
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter // Si un enregistrement est en cours, vérifier s'il doit s'arrêter
if (_currentRecording != null) { if (_currentRecording != null) {
if (now.isAfter(_currentRecording!.endTime.toUtc())) { if (now.isAfter(_currentRecording!.endTime.toUtc())) {
print( print(
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}'); '[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}',
);
await _stopCurrentRecording(); await _stopCurrentRecording();
} }
} }
@@ -73,9 +78,13 @@ class RecordingScheduler {
if (recording.status == 'recording' && if (recording.status == 'recording' &&
_currentRecording?.id != recording.id) { _currentRecording?.id != recording.id) {
print( print(
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}'); '[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}',
_db.updateRecordingStatus(recording.id, 'failed', );
errorReason: 'Interruption inattendue du serveur'); _db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Interruption inattendue du serveur',
);
continue; continue;
} }
@@ -83,28 +92,38 @@ class RecordingScheduler {
final startUtc = recording.startTime.toUtc(); final startUtc = recording.startTime.toUtc();
final endUtc = recording.endTime.toUtc(); final endUtc = recording.endTime.toUtc();
print( print(
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}'); '[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}',
);
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini // Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
if (now.isAfter(startUtc) && now.isBefore(endUtc)) { if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
if (_currentRecording != null) { if (_currentRecording != null) {
print( print(
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.'); '[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.',
_db.updateRecordingStatus(recording.id, 'failed', );
errorReason: 'Un autre enregistrement était déjà en cours.'); _db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Un autre enregistrement était déjà en cours.',
);
continue; continue;
} }
print( print(
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***'); '[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***',
);
await _startRecording(recording); await _startRecording(recording);
} }
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
if (now.isAfter(endUtc)) { if (now.isAfter(endUtc)) {
print( print(
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).'); '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).',
_db.updateRecordingStatus(recording.id, 'failed', );
errorReason: 'Heure de fin dépassée avant le démarrage'); _db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Heure de fin dépassée avant le démarrage',
);
} }
} }
} }
@@ -126,7 +145,8 @@ class RecordingScheduler {
if (dns == null || username == null || password == null) { if (dns == null || username == null || password == null) {
print( print(
'[SeasonPass] Config playlist non disponible, vérification annulée'); '[SeasonPass] Config playlist non disponible, vérification annulée',
);
return; return;
} }
@@ -193,7 +213,8 @@ class RecordingScheduler {
endTime: endTime, endTime: endTime,
); );
print( print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}'); '[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
);
} }
} catch (e) { } catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e'); print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
@@ -257,10 +278,14 @@ class RecordingScheduler {
} catch (logError) { } catch (logError) {
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire) // Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
print( print(
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError'); '[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError',
);
// On note la filePath dans la DB quand même pour pouvoir retourner le statut // On note la filePath dans la DB quand même pour pouvoir retourner le statut
_db.updateRecordingStatus(recording.id, 'recording', _db.updateRecordingStatus(
filePath: filePath); recording.id,
'recording',
filePath: filePath,
);
} }
// Trouver ffmpeg // Trouver ffmpeg
@@ -285,18 +310,24 @@ class RecordingScheduler {
_ffmpegProcess!.exitCode.then((exitCode) async { _ffmpegProcess!.exitCode.then((exitCode) async {
if (_currentRecording?.id == recording.id) { if (_currentRecording?.id == recording.id) {
logSink?.writeln( logSink?.writeln(
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode'); '\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode',
);
await logSink?.close(); await logSink?.close();
if (exitCode == 0 || exitCode == 255) { if (exitCode == 0 || exitCode == 255) {
print( print(
'[RecordingScheduler] Enregistrement terminé: ${recording.title}'); '[RecordingScheduler] Enregistrement terminé: ${recording.title}',
);
_db.updateRecordingStatus(recording.id, 'completed'); _db.updateRecordingStatus(recording.id, 'completed');
} else { } else {
print( print(
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}'); '[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}',
_db.updateRecordingStatus(recording.id, 'failed', );
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); _db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.',
);
} }
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -307,8 +338,11 @@ class RecordingScheduler {
} catch (e, st) { } catch (e, st) {
// Attraper TOUTES les exceptions pour éviter de crasher le serveur // Attraper TOUTES les exceptions pour éviter de crasher le serveur
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st'); print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
_db.updateRecordingStatus(recording.id, 'failed', _db.updateRecordingStatus(
errorReason: 'Erreur au lancement: $e'); recording.id,
'failed',
errorReason: 'Erreur au lancement: $e',
);
_currentRecording = null; _currentRecording = null;
_ffmpegProcess = null; _ffmpegProcess = null;
} }
@@ -318,7 +352,8 @@ class RecordingScheduler {
Future<bool> stopRecording(String id) async { Future<bool> stopRecording(String id) async {
if (_ffmpegProcess != null && _currentRecording?.id == id) { if (_ffmpegProcess != null && _currentRecording?.id == id) {
print( print(
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}'); '[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}',
);
_ffmpegProcess!.kill(ProcessSignal.sigterm); _ffmpegProcess!.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed'); _db.updateRecordingStatus(id, 'completed');
_ffmpegProcess = null; _ffmpegProcess = null;
@@ -348,11 +383,11 @@ class RecordingScheduler {
if (files.length > maxFiles) { if (files.length > maxFiles) {
print( print(
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements'); '[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements',
files.sort((a, b) => a );
.statSync() files.sort(
.modified (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent ); // Du plus vieux au plus récent
// Supprimer les plus anciens pour revenir sous la limite // Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles); final filesToDelete = files.take(files.length - maxFiles);
@@ -362,7 +397,8 @@ class RecordingScheduler {
} }
} catch (e) { } catch (e) {
print( print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e'); '[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
);
} }
} }
} }
+5 -2
View File
@@ -80,8 +80,11 @@ class SimpleRecorder {
_active.remove(channelId); _active.remove(channelId);
print('✅ Recording done: $title'); print('✅ Recording done: $title');
}).catchError((e) { }).catchError((e) {
db.updateRecordingStatus(recordingId, 'failed', db.updateRecordingStatus(
errorReason: 'FFmpeg error'); recordingId,
'failed',
errorReason: 'FFmpeg error',
);
_active.remove(channelId); _active.remove(channelId);
}); });
+2 -1
View File
@@ -295,7 +295,8 @@ class RuntimeOptimizations {
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000; hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
print( print(
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB'); '[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB',
);
} }
/// Get dynamic cache size based on available memory /// Get dynamic cache size based on available memory
+7 -3
View File
@@ -58,7 +58,9 @@ class GlassContainer extends StatelessWidget {
// BackdropFilter is extremely expensive in Flutter Web on mobile Safari. // BackdropFilter is extremely expensive in Flutter Web on mobile Safari.
// On mobile web (width < 600), skip the blur entirely. // On mobile web (width < 600), skip the blur entirely.
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final useBlur = blur > 0 && !(Theme.of(context).platform == TargetPlatform.iOS && screenWidth < 600); final useBlur = blur > 0 &&
!(Theme.of(context).platform == TargetPlatform.iOS &&
screenWidth < 600);
final inner = Container( final inner = Container(
padding: padding, padding: padding,
@@ -157,10 +159,12 @@ class _GlassCardState extends State<GlassCard>
); );
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate( _scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault),
);
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate( _opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault)); CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault),
);
} }
@override @override
+4 -2
View File
@@ -285,8 +285,10 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.play_arrow, const Icon(
color: AppColors.textPrimary), Icons.play_arrow,
color: AppColors.textPrimary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Play', 'Play',
@@ -29,7 +29,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'Movies', 'Movies',
'Series', 'Series',
'Recordings', 'Recordings',
'Settings' 'Settings',
]; ];
final List<IconData> _icons = [ final List<IconData> _icons = [
Icons.live_tv_rounded, Icons.live_tv_rounded,
+10 -4
View File
@@ -196,9 +196,13 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(day, style: const TextStyle(fontSize: 11)), Text(day, style: const TextStyle(fontSize: 11)),
Text(dateNum, Text(
style: const TextStyle( dateNum,
fontSize: 13, fontWeight: FontWeight.bold)), style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
], ],
), ),
); );
@@ -257,7 +261,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
child: Text( child: Text(
'$hour:00', '$hour:00',
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.bold), fontSize: 11,
fontWeight: FontWeight.bold,
),
), ),
), ),
); );
+2 -1
View File
@@ -506,7 +506,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_buildInlineEpg(), _buildInlineEpg(),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider( const Divider(
color: Colors.white10), color: Colors.white10,
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Control Buttons // Control Buttons
Row( Row(
@@ -183,8 +183,9 @@ class XtreamService {
/// Get all live TV channels grouped by category /// Get all live TV channels grouped by category
/// ///
/// Returns a Map where key is category name and value is list of channels /// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels( Future<Map<String, List<Channel>>> getLiveChannels({
{bool refresh = false}) async { bool refresh = false,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh final options = refresh
+44 -13
View File
@@ -7,9 +7,10 @@ import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ENTRÉE — Onglet "TV & Enregistrements" avec 3 sous-onglets // ENTRÉE — Onglet "Enregistrements"
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
class RecordingsTab extends StatefulWidget { class RecordingsTab extends StatefulWidget {
@@ -27,7 +28,7 @@ class _RecordingsTabState extends State<RecordingsTab>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 1, vsync: this);
} }
@override @override
@@ -73,15 +74,10 @@ class _RecordingsTabState extends State<RecordingsTab>
), ),
unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14), unselectedLabelStyle: GoogleFonts.outfit(fontSize: 14),
tabs: const [ tabs: const [
Tab(icon: Icon(Icons.grid_view, size: 18), text: 'Guide TV'),
Tab( Tab(
icon: Icon(Icons.fiber_manual_record, size: 18), icon: Icon(Icons.fiber_manual_record, size: 18),
text: 'Enregistrements', text: 'Enregistrements',
), ),
Tab(
icon: Icon(Icons.repeat, size: 18),
text: 'Season Passes',
),
], ],
), ),
], ],
@@ -89,11 +85,8 @@ class _RecordingsTabState extends State<RecordingsTab>
), ),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController,
children: [ children: [
_EpgGuideView(playlist: widget.playlist), _RecordingsListView(playlist: widget.playlist),
const _RecordingsListView(),
const _SeasonPassesView(),
], ],
), ),
), ),
@@ -298,7 +291,9 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: const TextStyle( hintStyle: const TextStyle(
color: Colors.white38, fontSize: 13), color: Colors.white38,
fontSize: 13,
),
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.search, Icons.search,
color: Colors.white38, color: Colors.white38,
@@ -765,7 +760,8 @@ class _ProgrammeCard extends StatelessWidget {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
class _RecordingsListView extends StatefulWidget { class _RecordingsListView extends StatefulWidget {
const _RecordingsListView(); final PlaylistConfig playlist;
const _RecordingsListView({required this.playlist});
@override @override
State<_RecordingsListView> createState() => _RecordingsListViewState(); State<_RecordingsListView> createState() => _RecordingsListViewState();
} }
@@ -821,6 +817,31 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
_fetchRecordings(); _fetchRecordings();
} }
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
final streamUrl = rec['stream_url'] as String? ?? '';
final title = rec['title'] as String? ?? 'Enregistrement';
if (streamUrl.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL du flux indisponible')),
);
return;
}
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) => PlayerScreen(
streamId: streamUrl,
title: title,
playlist: widget.playlist,
streamType: StreamType.vod,
containerExtension: 'ts',
),
),
);
}
Future<void> _showLogs(String id, String title) async { Future<void> _showLogs(String id, String title) async {
try { try {
final response = await http.get(Uri.parse('/api/recordings/logs/$id')); final response = await http.get(Uri.parse('/api/recordings/logs/$id'));
@@ -1033,6 +1054,16 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
rec['title'] ?? '', rec['title'] ?? '',
), ),
), ),
if (status == 'completed')
IconButton(
icon: const Icon(
Icons.play_circle_outline,
color: Colors.greenAccent,
size: 20,
),
tooltip: 'Lecture',
onPressed: () => _playRecording(context, rec),
),
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.description_outlined, Icons.description_outlined,
@@ -48,8 +48,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Loading channels...', const Text(
style: TextStyle(color: Colors.white70)), 'Loading channels...',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 24), const SizedBox(height: 24),
FutureBuilder( FutureBuilder(
future: Future.delayed(const Duration(seconds: 8)), future: Future.delayed(const Duration(seconds: 8)),
@@ -57,7 +59,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
return TextButton( return TextButton(
onPressed: () => ref.refresh( onPressed: () => ref.refresh(
liveChannelsByPlaylistProvider(widget.playlist)), liveChannelsByPlaylistProvider(widget.playlist),
),
child: const Text('Taking too long? Tap to retry'), child: const Text('Taking too long? Tap to retry'),
); );
} }
@@ -75,15 +78,20 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48), const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Failed to load channels', Text(
style: GoogleFonts.inter( 'Failed to load channels',
color: Colors.white, style: GoogleFonts.inter(
fontSize: 18, color: Colors.white,
fontWeight: FontWeight.bold)), fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('$e', Text(
style: const TextStyle(color: Colors.white54), '$e',
textAlign: TextAlign.center), style: const TextStyle(color: Colors.white54),
textAlign: TextAlign.center,
),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () => ref onPressed: () => ref
+42 -42
View File
@@ -42,50 +42,50 @@ class MobileScaffold extends ConsumerWidget {
], ],
), ),
bottomNavigationBar: Container( bottomNavigationBar: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
border: const Border( border: Border(
top: BorderSide( top: BorderSide(
color: AppColors.border, color: AppColors.border,
width: 0.5, width: 0.5,
),
),
),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onIndexChanged,
backgroundColor: Colors.transparent,
type: BottomNavigationBarType.fixed,
elevation: 0,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
unselectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded),
label: 'Live',
),
BottomNavigationBarItem(
icon: Icon(Icons.movie_outlined),
activeIcon: Icon(Icons.movie_rounded),
label: 'Movies',
),
BottomNavigationBarItem(
icon: Icon(Icons.video_library_outlined),
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
), ),
), ),
),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onIndexChanged,
backgroundColor: Colors.transparent,
type: BottomNavigationBarType.fixed,
elevation: 0,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
unselectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded),
label: 'Live',
),
BottomNavigationBarItem(
icon: Icon(Icons.movie_outlined),
activeIcon: Icon(Icons.movie_rounded),
label: 'Movies',
),
BottomNavigationBarItem(
icon: Icon(Icons.video_library_outlined),
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
),
); );
} }
} }