Files
xtremobile/lib/features/iptv/screens/native_player_screen.dart
T
MichaelandClaude Fable 5 3c37c6a6e6 feat(player): channel list sidebar accessible from the live OSD
Add a visible playlist button in both players (native + lite) that opens
the TiviMate-style channel sidebar (channels of the current category).
The sidebar existed in the native player but was only reachable via an
edge swipe or the M/Menu key; the lite player had none.

- New OSD button next to channel zapping (live only)
- Lite player: full sidebar port (swipe from right edge, M/Menu key)
- D-pad support: focus lands on the current channel when the list opens,
  arrows navigate, OK switches channel, Back/Menu closes
- Auto-hide timer (6s) restarted on D-pad focus and touch scroll
- Bump version to 1.8.0+15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EG1frWE7suYF7hp4q2sDiR
2026-09-01 08:05:34 +02:00

2476 lines
91 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:path_provider/path_provider.dart';
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';
/// Stream type enum for player
// (Moved to global or imported from iptv_models if available)
/// Native video player screen for Android/iOS
/// Uses media_kit (FFmpeg) for full codec support (AC3, EAC3, DTS, etc.)
class NativePlayerScreen extends ConsumerStatefulWidget {
final String streamId;
final String title;
final model.StreamType streamType;
final String? containerExtension;
final PlaylistConfig playlist;
final List<model.Channel>? channels;
final int initialIndex;
// For series episodes (to track watch history)
final dynamic seriesId;
final int? season;
final int? episodeNum;
// Experimental
final bool forceDeinterlace;
const NativePlayerScreen({
super.key,
required this.streamId,
required this.title,
required this.playlist,
required this.streamType,
this.containerExtension,
this.channels,
this.initialIndex = 0,
this.initialPosition,
this.seriesId,
this.season,
this.episodeNum,
this.forceDeinterlace = false,
});
final Duration? initialPosition;
@override
ConsumerState<NativePlayerScreen> createState() => _NativePlayerScreenState();
}
class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
with WidgetsBindingObserver {
late final Player _player;
late final VideoController _controller;
// Focus Nodes
final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode();
final FocusNode _nextFocusNode = FocusNode();
final FocusNode _sliderFocusNode = FocusNode();
final FocusNode _backFocusNode = FocusNode();
final FocusNode _audioFocusNode = FocusNode();
final FocusNode _channelListButtonFocusNode = FocusNode();
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
/// directement dessus à l'ouverture de la liste.
final FocusNode _channelListItemFocusNode = FocusNode();
bool _isLoading = true;
bool _isFirstLoad = true; // true = black overlay; false = silent spinner
bool _isReconnecting = false; // silent reconnect spinner (no black overlay)
String? _errorMessage;
late int _currentIndex;
XtreamServiceMobile? _xtreamService;
bool _showControls = true;
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
final bool _isSeeking = false;
bool _useSoftwareDecoder = false;
// [TiviMate] Channel List Sidebar
bool _showChannelList = false;
Timer? _channelListTimer;
final ScrollController _channelListScrollController = ScrollController();
Timer? _clockTimer;
Timer? _controlsTimer; // Auto-hide timer
Timer? _epgTimer; // Periodic EPG refresh for live TV
Timer? _liveWatchdog; // Watchdog for live stream reconnection
/// Every player stream subscription, so none survives this screen.
/// Left dangling they keep firing setState against a dead element — the
/// classic slow leak behind "crashes after a few hours".
final List<StreamSubscription<dynamic>> _playerSubscriptions = [];
/// Set once the app is backgrounded, to stop any in-flight reload from
/// resurrecting playback behind the user's back.
bool _isReleased = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
String _currentTime = '';
bool _hasMarkedAsWatched = false;
DateTime? _lastSaveTime;
// [v23.0] Position-based stall tracking for robust watchdog
// Tracks the last time the player position actually advanced.
// A freeze is only "real" if position hasn't changed for > 60 seconds.
Duration _lastKnownPosition = Duration.zero;
DateTime? _lastPositionChangeTime;
void _resetControlsTimer() {
_controlsTimer?.cancel();
_startClock(); // Ensure clock runs when controls are shown
if (_showControls && _isPlaying) {
_controlsTimer = Timer(const Duration(seconds: 5), () {
if (mounted) {
setState(() => _showControls = false);
_stopClock(); // Save CPU when hidden
}
});
}
}
void _onUserInteraction() {
setState(() => _showControls = true);
_resetControlsTimer();
}
/// Dernier contrôle de l'OSD qui avait le focus télécommande.
///
/// Quand l'OSD se masque (auto-hide 5s), ses FocusNodes sont démontés et le
/// focus part « on ne sait où » : à la réouverture l'utilisateur devait
/// re-naviguer depuis un point aléatoire jusqu'au bouton de zapping. On
/// mémorise donc le dernier bouton utilisé pour le re-focaliser d'office.
FocusNode? _lastFocusedControl;
void _noteFocus(FocusNode node) {
_lastFocusedControl = node;
_resetControlsTimer();
}
/// Réaffiche l'OSD en redonnant le focus au dernier contrôle utilisé
/// (Play/Pause par défaut) — un seul appui suffit alors pour re-zapper.
void _showControlsRestoringFocus() {
setState(() => _showControls = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl ?? _playPauseFocusNode;
if (target.canRequestFocus) {
target.requestFocus();
} else {
_playPauseFocusNode.requestFocus();
}
});
_resetControlsTimer();
}
@override
void initState() {
super.initState();
// Register lifecycle observer
WidgetsBinding.instance.addObserver(this);
// Register keyboard handler for remote control
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
// Enable wakelock to prevent screen from turning off during playback
WakelockPlus.enable();
// Only start clock if setting is enabled (will check in build too, but timer can run)
_startClock();
_currentIndex = widget.initialIndex;
// Initialize media_kit player with potential software decoding fallback
// 'hwdec': 'auto' tries hardware first, then software.
// 'vo': 'gpu' is standard.
const config = PlayerConfiguration(
vo: 'gpu',
// msgLevel removed as it might be deprecated or invalid
);
// Create the player instance
_player = Player(configuration: config);
// Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps)
final decoderMode = ref.read(mobileSettingsProvider).decoderMode;
// [V5.2 Fix] Force native Android Hardware decoding (Hardware-to-Surface)
(_player.platform as dynamic)?.setProperty('hwdec', 'mediacodec');
// Performance & Scaling Fixes for Android
// 'opengl-pbo' removed to save VRAM on low-end devices preventing long-run crashes
(_player.platform as dynamic)?.setProperty('video-unscaled', 'no');
if (widget.forceDeinterlace) {
debugPrint('MediaKitPlayer: Forcing Deinterlace ON');
(_player.platform as dynamic)?.setProperty('deinterlace', 'yes');
}
debugPrint('MediaKitPlayer: Decoder Mode set to $decoderMode');
// ============ PERFORMANCE 3.0 (ANTI MICRO-COUPURE PROFILE) ============
// Tuned to eliminate micro-stuttering during live TV playback
// Increased buffers for smoother playback at cost of ~100MB RAM
// Unified Buffering Logic (Live vs VOD)
(_player.platform as dynamic)?.setProperty('cache', 'yes');
if (widget.streamType == model.StreamType.live) {
// ===== LIVE PROFILE — Anti-Artefact v22.0 (TiviMate Flow) =====
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
// --- Buffering basse latence (TiviMate Low ~ 3-5s) ---
// 8s/10s de readahead ajoutaient autant de retard sur le direct : 3s de
// cache + 5s de readahead suffisent à absorber la gigue réseau tout en
// restant nettement plus près du live edge.
(_player.platform as dynamic)?.setProperty('cache-secs', '3');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '60000000'); // 60MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '5');
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
// --- Plafond du buffer ARRIÈRE (stabilité longue durée) ---
// mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour
// arrière. Sur du live ce buffer ne sert à rien : cumulé aux 150 Mo de
// readahead il pousse le tas natif vers ~200 Mo et finit par déclencher
// un kill mémoire Android après plusieurs heures. 8 Mo suffisent aux
// resynchronisations internes du démuxeur.
(_player.platform as dynamic)?.setProperty('demuxer-max-back-bytes', '8000000');
// Si le buffer se vide : courte pause (0.5s) puis reprise — freeze minimal, SANS rattrapage
// 0.5s vs 1.5s : moins de gel visible, moins de PTS gap à rattraper
(_player.platform as dynamic)?.setProperty('cache-pause', 'yes');
(_player.platform as dynamic)?.setProperty('cache-pause-wait', '0.5');
(_player.platform as dynamic)?.setProperty('cache-pause-initial', 'no');
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
// --- Décodage hardware — Direct Surface ---
(_player.platform as dynamic)?.setProperty('hwdec', 'mediacodec');
// --- Synchronisation A/V : mode desync (clé anti-rattrapage) ---
// video-sync: desync = audio et vidéo jouent indépendamment, AUCUN ajustement de vitesse.
// C'est le mode le plus stable pour les flux live IPTV :
// - pas de fast-forward pour rattraper l'audio
// - pas de slow-down pour attendre la vidéo
// - identique au comportement natif ExoPlayer de TiviMate
// video-sync: audio (ancien) ajustait la vitesse lecture selon l'horloge audio
// ⇒ si audio dérive ou est en avance après un rebuffer : fast-forward visible = RATTRAPAGE
(_player.platform as dynamic)?.setProperty('correct-pts', 'no');
(_player.platform as dynamic)?.setProperty('video-sync', 'desync');
// autosync non applicable en mode desync, supprimé
// --- Qualité image : JAMAIS de skip de frames sur du live TV ---
(_player.platform as dynamic)?.setProperty('framedrop', 'no');
} else {
// VOD PROFILE: Radical Compatibility (Software Mode)
// Pour VOD, on garde correct-pts:yes (précision de position nécessaire pour le seek)
(_player.platform as dynamic)?.setProperty('hwdec', 'no');
(_player.platform as dynamic)?.setProperty('correct-pts', 'yes');
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
}
// [Audio Fix] L'ancienne chaîne `af=lavrresample,format=channels=stereo:
// sample_fmts=s16` mélangeait la syntaxe FFmpeg et mpv : `lavrresample`
// n'existe plus dans les builds récents et `sample_fmts` n'est pas une
// option du filtre `format` de mpv. Quand la chaîne échoue à s'initialiser
// pour un codec donné (AC3/EAC3/LATM selon la chaîne TV), mpv coupe la
// sortie audio ⇒ « certaines chaînes n'ont pas de son ».
// Le downmix natif `audio-channels=stereo` fait la même chose (5.1 → 2.0)
// sans chaîne de filtres fragile, et l'AO négocie lui-même le format.
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
(_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses)
(_player.platform as dynamic)?.setProperty('aid', 'auto');
// --- Seeking & Sync globaux ---
(_player.platform as dynamic)?.setProperty('hr-seek', 'no'); // Seeking rapide (plus sûr pour IPTV)
(_player.platform as dynamic)?.setProperty('hr-seek-framedrop', 'yes');
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
(_player.platform as dynamic)?.setProperty('interpolation', 'no'); // Pas d'interpolation (trop lourd sur Android)
// --- Réseau & Timeouts ---
(_player.platform as dynamic)?.setProperty('network-timeout', '120');
(_player.platform as dynamic)?.setProperty('stream-timeout', '120');
(_player.platform as dynamic)?.setProperty('tls-verify', 'no');
(_player.platform as dynamic)
?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0');
// --- Reconnexion réseau FFmpeg (consolidée) ---
// IMPORTANT : reconnect_at_eof SUPPRIMÉ
// Les serveurs IPTV ferment souvent la connexion HTTP entre segments (comportement normal).
// reconnect_at_eof=1 causait une reconnexion au live edge (position en avance) après chaque EOF
// ⇒ freeze pendant la reconnexion + saut de position = RATTRAPAGE visible
// Le watchdog applicatif (toutes les 15s) gère les vrais arrêts de stream.
(_player.platform as dynamic)?.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5',
);
// --- Probing du flux (une seule entrée, consolidée) ---
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
// Ces valeurs sont des PLAFONDS : FFmpeg s'arrête dès que toutes les pistes
// sont identifiées, donc les relever ne ralentit pas les flux sains.
// 1MB/2s ratait la piste audio (AAC-LATM déclaré tard) sur certaines
// chaînes ⇒ pas de son. 2MB/3s les récupère sans pénaliser le zapping.
(_player.platform as dynamic)?.setProperty(
'demuxer-lavf-o',
'analyzeduration=3000000,probesize=2000000',
);
// --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) ---
// vd-lavc-skiploopfilter SUPPRIMÉ (causait la pixelisation)
// vd-lavc-fast SUPPRIMÉ (décodage imprécis = artefacts)
(_player.platform as dynamic)?.setProperty('vd-lavc-threads', '0'); // Threads auto (0 = détection auto)
// Low-latency intentionally removed for stability
/*
if (widget.streamType == model.StreamType.live) {
(_player.platform as dynamic)?.setProperty('profile', 'low-latency');
}
*/
// Track Selection - Auto (Safe for all)
(_player.platform as dynamic)?.setProperty('aid', 'auto');
(_player.platform as dynamic)?.setProperty('alang', 'fr,fra,fre,en,eng');
(_player.platform as dynamic)?.setProperty('volume', '100');
(_player.platform as dynamic)?.setProperty('mute', 'no');
debugPrint('MediaKitPlayer: Combined Live/VOD configuration applied');
_controller = VideoController(_player);
// Listen to player state
_playerSubscriptions.add(_player.stream.playing.listen((playing) {
if (mounted) {
setState(() {
_isPlaying = playing;
if (playing) _errorMessage = null; // Clear error on successful play
});
if (playing) {
// Smooth Loading: Hide loading screen AFTER playback actually starts
// Faster hide for Live TV
final hideDelay = widget.streamType == model.StreamType.live ? 300 : 1000;
Future.delayed(Duration(milliseconds: hideDelay), () {
if (mounted && _isPlaying) {
setState(() => _isLoading = false);
}
});
_resetControlsTimer(); // Start auto-hide timer
}
}
}));
_playerSubscriptions.add(_player.stream.position.listen((position) {
if (mounted && !_isSeeking) {
// [v23.0] Track last position change time for time-based watchdog
if (position != _lastKnownPosition) {
_lastKnownPosition = position;
_lastPositionChangeTime = DateTime.now();
}
// Only rebuild when the position is actually on screen. mpv emits this
// several times per second; rebuilding the whole player tree for a
// hidden readout burns CPU and GC for hours on a live channel.
if (_showControls) {
setState(() => _position = position);
} else {
_position = position;
}
// Check if 80% of content watched (for VOD/Series only)
_checkAndMarkWatched(position);
// Auto-save position every 10 seconds (resilience against app kill/crash)
if (widget.streamType != model.StreamType.live &&
!_hasMarkedAsWatched) {
final now = DateTime.now();
if (_lastSaveTime == null ||
now.difference(_lastSaveTime!) > const Duration(seconds: 10)) {
_lastSaveTime = now;
// Don't save if < 30s or > 95%
if (position.inSeconds > 30 &&
_duration.inSeconds > 0 &&
position.inSeconds / _duration.inSeconds < 0.95) {
ref
.read(mobileWatchHistoryProvider.notifier)
.saveResumePosition(_contentId, position.inSeconds);
}
}
}
}
}));
_playerSubscriptions.add(_player.stream.duration.listen((duration) {
if (mounted) {
setState(() {
_duration = duration;
// [V5.1 UI Fix] Force loading off as soon as metadata/duration is known
// backup to the 'playing' event for VOD visibility
if (duration.inSeconds > 0 && _isLoading) {
_isLoading = false;
}
});
}
}));
_playerSubscriptions.add(_player.stream.width.listen((width) {
if (mounted && (width ?? 0) > 0 && _isLoading) {
debugPrint('MediaKitPlayer: Video width detected ($width), clearing loading overlay');
setState(() => _isLoading = false);
}
}));
_playerSubscriptions.add(_player.stream.error.listen((error) async {
if (!mounted) return;
if (error.isEmpty) return;
debugPrint('MediaKitPlayer Error Stream: $error');
// For LIVE TV: never reload the stream on errors — mpv handles recovery internally.
// Only show fatal errors that truly stop playback (detected by watchdog after 2min).
if (widget.streamType == model.StreamType.live) {
debugPrint('MediaKitPlayer: Live — ignoring error (mpv handles internally): $error');
return;
}
// For VOD/Series: retry with software decoding on first error
if (!_useSoftwareDecoder) {
debugPrint(
'MediaKitPlayer: VOD error — retrying with Software Decoding...',
);
setState(() => _useSoftwareDecoder = true);
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _loadStream();
return;
}
setState(() {
_errorMessage = error;
_isLoading = false;
});
}));
// Listen for stream completion
_playerSubscriptions.add(_player.stream.completed.listen((completed) {
if (completed && mounted) {
// For Live TV: do NOT reconnect immediately — let mpv handle it internally.
// Only reconnect after a long pause (handled by watchdog).
if (widget.streamType == model.StreamType.live) {
debugPrint(
'MediaKitPlayer: Live stream completed signal — ignoring (mpv handles reconnect internally)',
);
return;
}
// Logic for VOD/Series: Check if really finished or cut off
if (_duration.inSeconds > 0) {
final progress = _position.inSeconds / _duration.inSeconds;
if (progress < 0.95) {
debugPrint(
'MediaKitPlayer: VOD stopped prematurely at ${(progress * 100).toStringAsFixed(1)}%, attempting reconnect...',
);
_attemptReconnect();
}
}
}
}));
// Lock to landscape for video playback
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
// Initialize service and then player
_initializeServiceAndPlayer();
}
void _startClock() {
if (_clockTimer != null && _clockTimer!.isActive) return;
_updateTime();
_clockTimer =
Timer.periodic(const Duration(seconds: 1), (_) => _updateTime());
}
void _stopClock() {
_clockTimer?.cancel();
_clockTimer = null;
}
void _updateTime() {
if (!mounted) return;
final now = DateTime.now();
final formatted =
"${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
// The clock ticks every second but only changes once a minute — rebuild
// only when the displayed value actually differs.
if (formatted == _currentTime) return;
setState(() => _currentTime = formatted);
}
@override
void deactivate() {
// Stop player when widget is being removed from tree
_player.stop();
super.deactivate();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// `inactive` is deliberately NOT handled: on Android it fires for transient
// interruptions (notification shade, incoming-call banner, permission
// dialog, split-screen resize). Treating it as "backgrounded" killed
// playback every time the user swiped the shade down.
final isBackgrounded = state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached;
if (!isBackgrounded) return;
_releasePlayback();
// Exit the player screen so the user comes back to the dashboard rather
// than to a frozen frame.
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
Navigator.of(context).pop();
}
}
/// Fully stop playback and release the decoder.
///
/// Android reclaims MediaCodec instances from backgrounded apps: holding one
/// while hidden is what leaves the app with a dead surface (black screen, no
/// audio) on return. Stopping releases it deterministically, and also drops
/// the network reader so no bandwidth is consumed in the background.
void _releasePlayback() {
if (_isReleased) return;
_isReleased = true;
// Kill the timers first: a watchdog firing mid-release would call
// _loadStream() and restart the stream we are tearing down.
_liveWatchdog?.cancel();
_liveWatchdog = null;
_epgTimer?.cancel();
_epgTimer = null;
_controlsTimer?.cancel();
_controlsTimer = null;
_stopClock();
if (widget.streamType != model.StreamType.live) {
_saveResumePositionOnExit();
}
_player.stop();
WakelockPlus.disable();
}
@override
void dispose() {
// Remove lifecycle observer
WidgetsBinding.instance.removeObserver(this);
// Unregister keyboard handler
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
// Disable wakelock when leaving player
WakelockPlus.disable();
// Save resume position before cleanup (only for VOD/Series).
// Skipped when _releasePlayback already did it on backgrounding.
if (!_isReleased) {
_saveResumePositionOnExit();
}
_clockTimer?.cancel();
_controlsTimer?.cancel();
_epgTimer?.cancel();
_liveWatchdog?.cancel();
_channelListTimer?.cancel();
_channelListScrollController.dispose();
// Detach from the player streams BEFORE disposing it, so no late event can
// land on a deactivated element.
for (final subscription in _playerSubscriptions) {
subscription.cancel();
}
_playerSubscriptions.clear();
// Stop playback first to prevent audio continuing in background
_player.stop();
_player.dispose();
_xtreamService?.dispose();
// [P1-3 FIX] Dispose all focus nodes ONCE (was being disposed twice, causing crash)
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
_sliderFocusNode.dispose();
_backFocusNode.dispose();
_audioFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
// Restore normal orientation
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
/// Get content ID for resume position storage
String get _contentId {
if (widget.streamType == model.StreamType.series &&
widget.seriesId != null &&
widget.season != null &&
widget.episodeNum != null) {
return MobileWatchHistory.episodeKey(
widget.seriesId,
widget.season!,
widget.episodeNum!,
);
}
return widget.streamId;
}
/// Save resume position when exiting player
void _saveResumePositionOnExit() {
if (widget.streamType == model.StreamType.live) return;
debugPrint(
'MediaKitPlayer: Saving position - pos: ${_position.inSeconds}s, dur: ${_duration.inSeconds}s, contentId: $_contentId',
);
// If already marked as watched, clear any saved position
if (_hasMarkedAsWatched) {
ref
.read(mobileWatchHistoryProvider.notifier)
.clearResumePosition(_contentId);
debugPrint('MediaKitPlayer: Already watched, clearing position');
return;
}
// Check if almost finished (> 90%)
if (_duration.inSeconds > 0) {
final progress = _position.inSeconds / _duration.inSeconds;
if (progress > 0.90) {
ref
.read(mobileWatchHistoryProvider.notifier)
.clearResumePosition(_contentId);
debugPrint(
'MediaKitPlayer: Almost finished (${(progress * 100).toStringAsFixed(1)}%), clearing position',
);
return;
}
}
// Save position (saveResumePosition handles the 30s minimum check)
ref.read(mobileWatchHistoryProvider.notifier).saveResumePosition(
_contentId,
_position.inSeconds,
);
}
/// Seek to resume position with retry logic
void _seekToResume(int positionSeconds, [int attempt = 0]) {
if (!mounted || attempt >= 10) return;
Future.delayed(Duration(milliseconds: attempt == 0 ? 1500 : 500), () {
if (!mounted) return;
if (_isPlaying && _duration.inSeconds > 0) {
debugPrint(
'MediaKitPlayer: Seeking to resume position ${positionSeconds}s (attempt $attempt)',
);
_player.seek(Duration(seconds: positionSeconds));
// Show visual notification
final minutes = positionSeconds ~/ 60;
final seconds = positionSeconds % 60;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
),
);
} else {
debugPrint(
'MediaKitPlayer: Player not ready, retrying resume seek (attempt $attempt)',
);
_seekToResume(positionSeconds, attempt + 1);
}
});
}
/// Attempt to reconnect a live stream that stopped
void _attemptReconnect() {
if (!mounted || _isReleased) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
setState(() {
_errorMessage = 'Flux interrompu après plusieurs tentatives';
_isLoading = false;
});
return;
}
_reconnectAttempts++;
debugPrint(
'MediaKitPlayer: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts',
);
// Delay before reconnect to avoid hammering the server
Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () {
if (mounted) {
// If VOD/Series, try to resume from current position
if (widget.streamType != model.StreamType.live &&
_position.inSeconds > 0) {
_loadStream(startAt: _position);
} else {
_loadStream();
}
}
});
}
/// Check if 80% of content watched and mark as watched
void _checkAndMarkWatched(Duration position) {
// Skip if already marked, live TV, or no duration
if (_hasMarkedAsWatched) return;
if (widget.streamType == model.StreamType.live) return;
if (_duration.inSeconds <= 0) return;
final progress = position.inSeconds / _duration.inSeconds;
if (progress >= 0.80) {
_hasMarkedAsWatched = true;
debugPrint('MediaKitPlayer: 80% reached, marking as watched');
if (widget.streamType == model.StreamType.vod) {
// Mark movie as watched
ref
.read(mobileWatchHistoryProvider.notifier)
.markMovieWatched(widget.streamId);
} else if (widget.streamType == model.StreamType.series &&
widget.seriesId != null &&
widget.season != null &&
widget.episodeNum != null) {
// Mark episode as watched
final episodeKey = MobileWatchHistory.episodeKey(
widget.seriesId,
widget.season!,
widget.episodeNum!,
);
ref
.read(mobileWatchHistoryProvider.notifier)
.markEpisodeWatched(episodeKey);
}
}
}
int _watchdogStallCount = 0;
/// [v23.0] Watchdog revampé — Détection temporelle basée sur la POSITION réelle.
///
/// Problème de l'ancienne version :
/// Le watchdog vérifiait `!_isPlaying` toutes les 15s. Pendant un cache-pause de
/// 0.5s (normal sur IPTV), si le tick de 15s tombait pendant la pause, il incrémentait
/// le compteur. Après 2 tels événements (30s), il appelait `_loadStream()` → nouvelle
/// requête HTTP au LIVE EDGE → saut de position = RATTRAPAGE.
///
/// Nouvelle logique (style TiviMate) :
/// - Suit la dernière fois que la POSITION a avancé (plus fiable que _isPlaying)
/// - Ne reconnecte que si la position est gelée depuis > 60 secondes CONTINUES
/// - Les cache-pauses courtes (< 60s) sont ignorées — mpv les gère via stream-lavf-o
void _startLiveWatchdog() {
_liveWatchdog?.cancel();
_watchdogStallCount = 0;
if (widget.streamType != model.StreamType.live) return;
// Initialiser le timestamp de position
_lastPositionChangeTime = DateTime.now();
_lastKnownPosition = Duration.zero;
// Vérifie toutes les 10s, mais seuil de reconnexion à 60s de position gelée
_liveWatchdog = Timer.periodic(const Duration(seconds: 10), (_) {
if (!mounted || _isReleased) return;
// Si le player joue et la position avance : tout va bien, reset
if (_isPlaying && _lastPositionChangeTime != null) {
final stallSecs = DateTime.now().difference(_lastPositionChangeTime!).inSeconds;
if (stallSecs < 5) return; // Position avance normalement
}
// Calculer la durée réelle de gel (basée sur la position, pas sur _isPlaying)
final stallSecs = _lastPositionChangeTime != null
? DateTime.now().difference(_lastPositionChangeTime!).inSeconds
: 9999;
debugPrint('MediaKitPlayer: Watchdog — position gelée depuis ${stallSecs}s');
// Ne reconnecte QUE si vraiment gelé depuis > 60s (les cache-pauses sont < 5s)
// Cela laisse mpv's reconnect interne (stream-lavf-o) gérer les coupures courtes
if (stallSecs > 60) {
debugPrint('MediaKitPlayer: Watchdog — gel de 60s confirmé, reconnexion forcée');
_lastPositionChangeTime = DateTime.now(); // Éviter re-déclenchement immédiat
_attemptReconnect();
}
});
}
// Update build method to show clock in top bar
// ...
Future<void> _initializeServiceAndPlayer() async {
try {
final dir = await getApplicationDocumentsDirectory();
_xtreamService = XtreamServiceMobile(dir.path);
await _xtreamService!.setPlaylistAsync(widget.playlist);
// Resolve DNS and open (then drop) a bare socket to the panel host so
// mpv's first request skips resolution and the TCP/TLS handshake.
// Capped hard: playback must never wait more than a moment on this.
await _xtreamService!.prewarmHost().timeout(
const Duration(milliseconds: 1500),
onTimeout: () {},
);
await _loadStream();
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = "Service Error: $e";
});
}
}
}
model.ShortEPG? _epg;
// ... (existing initState)
Future<void> _loadStream({Duration? startAt}) async {
try {
if (_xtreamService == null) return;
// The app went to background while a (re)load was queued — do not bring
// the stream back up behind the user.
if (_isReleased || !mounted) return;
setState(() {
if (_isFirstLoad) {
_isLoading = true; // Black overlay only on first load
} else {
_isReconnecting = true; // Silent spinner on subsequent loads
}
_errorMessage = null;
_epg = null; // Reset EPG
});
// [V5.2 Fix] Absolute fail-safe Loading Timeout (5 seconds)
// This ensures even if metadata/width is never reached, the UI opens
Future.delayed(const Duration(seconds: 5), () {
if (mounted && _isLoading) {
debugPrint('MediaKitPlayer: Loading Timeout reached, forcing Visibility');
setState(() => _isLoading = false);
}
});
// Determine effective Stream ID
final currentStreamId =
widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].streamId
: widget.streamId;
// 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)
// Build stream URL based on type
String streamUrl;
if (widget.streamType == model.StreamType.live) {
streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == model.StreamType.vod) {
streamUrl = _xtreamService!.getVodStreamUrl(
currentStreamId,
widget.containerExtension ?? 'mp4',
);
} else {
streamUrl = _xtreamService!.getSeriesStreamUrl(
currentStreamId,
widget.containerExtension ?? 'mp4',
);
}
debugPrint('MediaKitPlayer: Loading stream: $streamUrl');
// [v23.0] Fix: Ne PAS overrider hwdec pour les streams live.
// initState a déjà configuré 'mediacodec' pour le live.
// Overrider ici avec mobileSettingsProvider.decoderMode annulerait ce réglage
// (si l'utilisateur a 'auto' dans les settings → retour à mediacodec-copy).
// Pour le VOD : on garde le fallback logiciel si nécessaire.
if (widget.streamType != model.StreamType.live) {
final hwdecValue = _useSoftwareDecoder
? 'no'
: ref.read(mobileSettingsProvider).decoderMode;
(_player.platform as dynamic)?.setProperty('hwdec', hwdecValue);
} else if (_useSoftwareDecoder) {
// Live fallback software uniquement si hardware a échoué
(_player.platform as dynamic)?.setProperty('hwdec', 'no');
}
await _player.open(
Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}),
play: true,
);
setState(() {
// [TiviMate] For live: DON'T hide loading here — let the 'playing' / 'width'
// stream listeners handle it for a glitch-free first-frame reveal.
// For VOD: immediately hide since we need the controls visible for seek.
if (widget.streamType != model.StreamType.live) {
_isLoading = false;
}
_isReconnecting = false;
_isFirstLoad = false;
_reconnectAttempts = 0; // Reset reconnect counter on success
});
// Show watchdog for live streams
if (widget.streamType == model.StreamType.live) {
_startLiveWatchdog();
}
// Resume from saved position (VOD/Series only)
if (widget.streamType != model.StreamType.live) {
// Priority 0: Explicit reconnect position (passed from _attemptReconnect)
if (startAt != null && startAt.inSeconds > 0) {
debugPrint(
'MediaKitPlayer: Resuming after reconnect at: ${startAt.inSeconds}s',
);
_seekToResume(startAt.inSeconds);
}
// Priority 1: Explicit initial position passed from caller verification
else if (widget.initialPosition != null &&
widget.initialPosition!.inSeconds > 0) {
debugPrint(
'MediaKitPlayer: Using explicit initial position: ${widget.initialPosition!.inSeconds}s',
);
_seekToResume(widget.initialPosition!.inSeconds);
} else {
// Priority 2: Internal lookup (fallback)
final resumePos = ref
.read(mobileWatchHistoryProvider)
.getResumePosition(_contentId);
debugPrint(
'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos',
);
if (resumePos > 30) {
_seekToResume(resumePos);
}
}
}
} catch (e) {
debugPrint('MediaKitPlayer error: $e');
// Automatic Retry with Software Decoding if it fails the first time
if (!_useSoftwareDecoder) {
debugPrint('MediaKitPlayer: Retrying with Software Decoding forced...');
setState(() {
_useSoftwareDecoder = true;
// _isLoading remains true
});
// Delay slightly to let player reset state if needed
await Future.delayed(const Duration(milliseconds: 500));
_loadStream(); // Recursive retry
return;
}
setState(() {
_isLoading = false;
_isReconnecting = false;
_isFirstLoad = false;
_errorMessage = e.toString();
});
}
}
Future<void> _loadEpg(String streamId) async {
try {
// 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);
}
} catch (e) {
debugPrint('Error loading EPG: $e');
}
}
void _playNext() {
if (widget.channels == null || widget.channels!.isEmpty) return;
final nextIndex = (_currentIndex + 1) % widget.channels!.length;
_switchChannel(nextIndex);
}
void _playPrevious() {
if (widget.channels == null || widget.channels!.isEmpty) return;
final prevIndex =
(_currentIndex - 1 + widget.channels!.length) % widget.channels!.length;
_switchChannel(prevIndex);
}
void _switchChannel(int index) {
setState(() {
_currentIndex = index;
_showChannelList = false; // close sidebar on switch
_showControls = true; // show regular OSD
});
// Garde le focus sur le bouton de zap qui vient d'être pressé : l'appui
// suivant sur OK rezappe immédiatement, sans re-navigation dans l'OSD.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl;
if (target != null && target.canRequestFocus && !target.hasFocus) {
target.requestFocus();
}
});
_resetControlsTimer();
_loadStream();
}
/// Open/close the channel list sidebar
void _toggleChannelList() {
setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) {
_scrollToCurrentChannel();
_restartChannelListTimer();
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
// liste sans étape intermédiaire. Second essai après l'animation de
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showChannelList) {
_channelListItemFocusNode.requestFocus();
}
});
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted &&
_showChannelList &&
!_channelListItemFocusNode.hasFocus) {
_channelListItemFocusNode.requestFocus();
}
});
} else {
_channelListTimer?.cancel();
}
}
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
void _restartChannelListTimer() {
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
});
}
/// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_channelListScrollController.hasClients) return;
const itemHeight = 64.0;
final offset = (_currentIndex * itemHeight) -
(_channelListScrollController.position.viewportDimension / 2) +
(itemHeight / 2);
_channelListScrollController.animateTo(
offset.clamp(0, _channelListScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
void _togglePlayPause() {
if (_isPlaying) {
_player.pause();
// Save position on pause
if (widget.streamType != model.StreamType.live) {
ref
.read(mobileWatchHistoryProvider.notifier)
.saveResumePosition(_contentId, _position.inSeconds);
}
} else {
_player.play();
}
_onUserInteraction();
}
void _toggleControls() {
// Always show controls on tap, then start auto-hide timer
if (!_showControls) {
// Reopen on the last focused control (falls back to Play/Pause)
_showControlsRestoringFocus();
return;
}
_resetControlsTimer();
}
/// Handle keyboard/remote control events
bool _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false;
try {
final key = event.logicalKey;
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
if (_showChannelList) {
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
return true;
}
_restartChannelListTimer();
return false;
}
// Explicit Play
if (key == LogicalKeyboardKey.mediaPlay) {
if (!_isPlaying) _player.play();
_onUserInteraction();
return true;
}
// Explicit Pause
if (key == LogicalKeyboardKey.mediaPause) {
if (_isPlaying) _player.pause();
_onUserInteraction();
return true;
}
// Space / Enter / Select / Toggle
if (key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.mediaPlayPause) {
if (!_showControls) {
// Live : OK réaffiche seulement l'OSD (focus restauré sur le dernier
// bouton utilisé) — surtout ne PAS mettre le direct en pause.
if (widget.streamType == model.StreamType.live &&
key != LogicalKeyboardKey.mediaPlayPause) {
_showControlsRestoringFocus();
return true;
}
// VOD : comportement historique — toggle play/pause + OSD
_togglePlayPause();
_toggleControls();
return true;
}
// If controls are visible, let Focus system handle button press
return false;
}
// Debug Toggle: Long Press Key 'D' or 0 (if mapped) - Simplified to just a secret key combo?
// Let's use a long-press logic on Select in _handleKeyEvent?
// Actually, handling long press in key down is hard.
// We'll add a 'Debug' button in the settings or just map Key '0' if remote has it.
// Debug Toggle: Long Press Key 'D' or 0 (if mapped)
if (key == LogicalKeyboardKey.digit0) {
final current = ref.read(mobileSettingsProvider).showDebugStats;
ref
.read(mobileSettingsProvider.notifier)
.toggleShowDebugStats(!current);
return true;
}
// Left Arrow - Show controls or navigate, seek in VOD when hidden
if (key == LogicalKeyboardKey.arrowLeft) {
if (!_showControls) {
if (widget.streamType != model.StreamType.live) {
final newPos = _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero ? Duration.zero : newPos);
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
return true;
}
return false; // Let Focus handle navigation
}
// Right Arrow - Show controls or navigate, seek in VOD when hidden
if (key == LogicalKeyboardKey.arrowRight) {
if (!_showControls) {
if (widget.streamType != model.StreamType.live) {
final newPos = _position + const Duration(seconds: 10);
if (newPos < _duration) {
_player.seek(newPos);
}
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
return true;
}
return false; // Let Focus handle navigation
}
// Up/Down Arrow - Show controls or navigate UI, NO zapping
if (key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.arrowDown) {
if (!_showControls) {
_showControlsRestoringFocus();
return true;
}
return false; // Let Focus handle navigation
}
// Channel Down = Previous Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelDown) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_playPrevious();
_onUserInteraction();
return true;
}
}
// Channel Up = Next Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelUp) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_playNext();
_onUserInteraction();
return true;
}
}
// Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_toggleChannelList();
return true;
}
}
// Cycle Aspect Ratio - secret key 'A' or digit 1
if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) {
_cycleAspectRatio();
return true;
}
// Escape / Back - Exit player
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack) {
// If controls are visible, close them first
if (_showControls) {
setState(() => _showControls = false);
} else {
Navigator.of(context).pop();
}
return true;
}
} catch (e) {
debugPrint('Error in _handleKeyEvent: $e');
}
return false;
}
Future<void> _cycleAudioTrack() async {
final tracks = _player.state.tracks.audio;
if (tracks.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aucune piste audio détectée'),
duration: Duration(seconds: 1),
),
);
}
return;
}
final current = _player.state.track.audio;
final currentIndex = tracks.indexOf(current);
final nextIndex = (currentIndex + 1) % tracks.length;
final nextTrack = tracks[nextIndex];
await _player.setAudioTrack(nextTrack);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Audio: ${nextTrack.language ?? "inconnu"} (${nextTrack.title ?? nextTrack.id})',
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final title = widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].name
: widget.title;
return Theme(
data: ThemeData.dark(),
child: Scaffold(
backgroundColor: Colors.black, // [V5.2 Fix] Enforce black background globally
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_showChannelList) {
setState(() => _showChannelList = false);
} else {
_toggleControls();
}
},
onPanDown: (_) => _onUserInteraction(),
onPanUpdate: (_) => _onUserInteraction(),
// Swipe from right edge → open channel list
onHorizontalDragEnd: (details) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null &&
details.primaryVelocity != null &&
details.primaryVelocity! < -300) {
_toggleChannelList();
}
},
child: Stack(
children: [
// Video Player
if (_errorMessage != null)
_buildErrorView()
else
Center(
child: Video(
controller: _controller,
controls: NoVideoControls, // We use our own custom controls
fit: _getBoxFit(
ref.watch(mobileSettingsProvider).aspectRatioMode,
),
),
),
// Loading Overlay — black only on FIRST load
if (_isLoading && _errorMessage == null)
Container(
// True black, not the warm ink: this sits over the video surface.
color: Colors.black,
child: _buildLoadingView(),
),
// Silent reconnect spinner (does NOT black out the video)
if (_isReconnecting && !_isLoading)
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(24),
),
child: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onSurface70,
),
),
),
),
// Custom controls overlay
if (_showControls && _errorMessage == null)
_buildControlsOverlay(title),
// Top bar (always visible when controls are shown)
if (_showControls)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: const BoxDecoration(
color: Colors
.black54, // Flat color instead of Gradient for performance
),
child: Row(
children: [
// Main Back Button
// Main Back Button
TVFocusable(
focusNode: _backFocusNode,
onPressed: () => Navigator.pop(context),
onFocus: _resetControlsTimer,
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppColors.onSurface,
),
onPressed: () => Navigator.pop(context),
),
),
// Hidden Debug Trigger (Invisible InkWell next to back button)
InkWell(
onLongPress: () {
final current =
ref.read(mobileSettingsProvider).showDebugStats;
ref
.read(mobileSettingsProvider.notifier)
.toggleShowDebugStats(!current);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Debug Mode: ${!current ? "ON" : "OFF"}',
),
duration: const Duration(seconds: 1),
),
);
},
child: const SizedBox(width: 20, height: 40),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.streamType == model.StreamType.live)
Text(
_currentTime,
style: const TextStyle(
color: AppColors.onSurface70,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Removed top-right arrows as requested
const SizedBox(width: 48), // Spacer for balance
],
),
),
),
),
// [TiviMate] Channel list sidebar
if (_showChannelList &&
widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
_buildChannelListSidebar(),
// Stats Overlay (Top Left) - Isolated to prevent main rebuilds
Positioned(
top: 80,
left: 16,
child: StatsOverlayWidget(player: _player),
),
],
),
),
),
);
}
BoxFit _getBoxFit(String mode) {
switch (mode) {
case 'cover':
return BoxFit.cover;
case 'fill':
return BoxFit.fill;
case 'contain':
default:
return BoxFit.contain;
}
}
void _cycleAspectRatio() {
final current = ref.read(mobileSettingsProvider).aspectRatioMode;
String next;
String label;
if (current == 'contain') {
next = 'cover';
label = 'Zoom (Cover)';
} else if (current == 'cover') {
next = 'fill';
label = 'Étirer (Fill)';
} else {
next = 'contain';
label = 'Original (Contain)';
}
ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Format: $label'),
duration: const Duration(seconds: 1),
backgroundColor: Colors.black87,
),
);
}
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
}
Widget _buildUnifiedOSD(String title) {
return Positioned(
bottom: 20,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.onSurface12),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// EPG Info (Left)
Expanded(child: _buildEPGBox(title)),
const SizedBox(width: 24),
// Controls Continuity (Right)
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Prev Channel
TVFocusable(
focusNode: _prevFocusNode,
onPressed: _playPrevious,
onFocus: () => _noteFocus(_prevFocusNode),
child: const Icon(
Icons.skip_previous,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Play/Pause
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: () => _noteFocus(_playPauseFocusNode),
child: Icon(
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: AppColors.onSurface,
size: 56,
),
),
const SizedBox(width: 16),
// Next Channel
TVFocusable(
focusNode: _nextFocusNode,
onPressed: _playNext,
onFocus: () => _noteFocus(_nextFocusNode),
child: const Icon(
Icons.skip_next,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Channel list sidebar (liste des chaînes de la catégorie)
if (widget.channels != null && widget.channels!.isNotEmpty)
TVFocusable(
focusNode: _channelListButtonFocusNode,
onPressed: _toggleChannelList,
onFocus: () => _noteFocus(_channelListButtonFocusNode),
child: const Icon(
Icons.playlist_play_rounded,
color: AppColors.onSurface,
size: 36,
),
),
// Disable Deinterlace Button
if (widget.forceDeinterlace) ...[
const SizedBox(width: 16),
TVFocusable(
onPressed: () {
final streamId =
widget.channels?[_currentIndex].streamId ??
widget.streamId;
ref
.read(mobileSettingsProvider.notifier)
.toggleChannelDeinterlace(streamId);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
streamId: streamId,
title: widget.channels?[_currentIndex].name ??
widget.title,
playlist: widget.playlist,
streamType: widget.streamType,
channels: widget.channels,
initialIndex: _currentIndex,
),
),
);
},
onFocus: _resetControlsTimer,
child: const Icon(
Icons.grid_off,
color: AppColors.onSurface,
size: 32,
),
),
],
],
),
],
),
),
),
);
}
Widget _buildEPGBox(String title) {
final rawNow = _epg?.nowPlaying ?? '';
String nowPlaying = rawNow.isNotEmpty ? rawNow : "Pas d'infos EPG";
final epgProgress = _epg?.progress ?? 0.0;
// Format start / end time for display
String startTime = '';
String endTime = '';
if (_epg != null && _epg!.start.isNotEmpty && _epg!.end.isNotEmpty) {
try {
final s = DateTime.parse(_epg!.start).toLocal();
final e = DateTime.parse(_epg!.end).toLocal();
startTime =
'${s.hour.toString().padLeft(2, '0')}:${s.minute.toString().padLeft(2, '0')}';
endTime =
'${e.hour.toString().padLeft(2, '0')}:${e.minute.toString().padLeft(2, '0')}';
} catch (_) {}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (widget.channels != null &&
widget.channels![_currentIndex].streamIcon.isNotEmpty)
Container(
margin: const EdgeInsets.only(right: 12),
width: 40,
height: 40,
child: CachedNetworkImage(
imageUrl: widget.channels![_currentIndex].streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface38),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: AppColors.onSurface70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
nowPlaying,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// EPG Progress Bar
if (startTime.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
Text(
startTime,
style: const TextStyle(
color: AppColors.onSurface54, fontSize: 11),
),
const SizedBox(width: 6),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 4,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
),
const SizedBox(width: 6),
Text(
endTime,
style: const TextStyle(
color: AppColors.onSurface54, fontSize: 11),
),
],
),
],
],
),
),
],
),
],
);
}
Widget _buildControlsOverlay(String title) {
if (widget.streamType == model.StreamType.live) {
return _buildUnifiedOSD(title);
}
return _buildVodControlsOverlay();
}
/*
/// [TiviMate] Zapping OSD — brief overlay on channel switch (like TiviMate)
Widget _buildZappingOSD() {
final ch = widget.channels![_currentIndex];
final chNum = _currentIndex + 1;
final nowPlaying = _epg?.nowPlaying ?? '';
final epgProgress = _epg?.progress ?? 0.0;
return Positioned(
bottom: 24,
left: 24,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.82),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.onSurface12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Channel logo
if (ch.streamIcon.isNotEmpty)
Container(
width: 52,
height: 52,
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AppColors.onSurface12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
),
)
else
Container(
width: 52,
height: 52,
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AppColors.onSurface12,
),
child: const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
// Channel info
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: const AppColors.primaryFill,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'CH $chNum',
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
Flexible(
child: Text(
ch.name,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (_isReconnecting || _isLoading)
const Padding(
padding: EdgeInsets.only(top: 6),
child: SizedBox(
height: 3,
child: LinearProgressIndicator(
backgroundColor: AppColors.onSurface24,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primary),
),
),
)
else if (nowPlaying.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
nowPlaying,
style: const TextStyle(
color: AppColors.onSurface70,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary),
),
),
],
],
),
),
],
),
),
),
);
}
*/
/// [TiviMate] Channel list sidebar — slide-in panel on the right
Widget _buildChannelListSidebar() {
final channels = widget.channels!;
return Positioned(
top: 0,
bottom: 0,
right: 0,
child: Container(
width: 280,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [Color(0xE6000000), Color(0x99000000)],
),
),
child: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list, color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
GestureDetector(
onTap: () => setState(() => _showChannelList = false),
child: const Icon(Icons.close,
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: AppColors.primary,
width: 3,
),
)
: null,
),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
// Logo
Container(
width: 40,
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ch.name,
style: TextStyle(
color: isActive
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'CH ${index + 1}',
style: const TextStyle(
color: AppColors.onSurface38,
fontSize: 11,
),
),
],
),
),
// Active indicator
if (isActive)
const Icon(
Icons.play_arrow,
color: AppColors.primary,
size: 18,
),
],
),
),
);
},
),
),
),
],
),
),
),
);
}
Widget _buildVodControlsOverlay() {
return Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: Stack(
children: [
// Bottom Controls (VOD - Centered)
Positioned(
bottom: 80,
left: 0,
right: 0,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Prev 10s
TVFocusable(
focusNode: _prevFocusNode,
onPressed: () async {
final pos = await _player.stream.position.first;
_player.seek(pos - const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: () => _noteFocus(_prevFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.replay_10,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
final pos = await _player.stream.position.first;
_player.seek(pos - const Duration(seconds: 10));
},
),
),
const SizedBox(width: 32),
// Play/Pause
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: () => _noteFocus(_playPauseFocusNode),
scale: 1.1,
borderRadius: BorderRadius.circular(50),
child: IconButton(
iconSize: 72,
icon: Icon(
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: AppColors.onSurface,
),
onPressed: _togglePlayPause,
),
),
const SizedBox(width: 32),
// Next 10s
TVFocusable(
focusNode: _nextFocusNode,
onPressed: () async {
final pos = await _player.stream.position.first;
_player.seek(pos + const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: () => _noteFocus(_nextFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.forward_10,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
final pos = await _player.stream.position.first;
_player.seek(pos + const Duration(seconds: 10));
},
),
),
],
),
),
),
// Progress Bar / Time (Bottom)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black87],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
_formatDuration(_position),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
Expanded(
child: Focus(
focusNode: _sliderFocusNode,
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey ==
LogicalKeyboardKey.arrowUp) {
_playPauseFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (event.logicalKey ==
LogicalKeyboardKey.arrowLeft) {
final newPos =
_position - const Duration(seconds: 10);
_player.seek(
newPos < Duration.zero
? Duration.zero
: newPos,
);
_resetControlsTimer();
return KeyEventResult.handled;
}
if (event.logicalKey ==
LogicalKeyboardKey.arrowRight) {
final newPos =
_position + const Duration(seconds: 10);
if (newPos < _duration) _player.seek(newPos);
_resetControlsTimer();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Slider(
value: _position.inSeconds
.toDouble()
.clamp(0, _duration.inSeconds.toDouble()),
min: 0.0,
max: _duration.inSeconds.toDouble(),
onChanged: (value) {
_player.seek(Duration(seconds: value.toInt()));
_resetControlsTimer();
},
activeColor: AppColors.primary,
inactiveColor: AppColors.onSurface24,
),
),
),
Text(
_formatDuration(_duration),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildLoadingView() {
// During first load of a live channel: show logo + name for better UX
final isLive = widget.streamType == model.StreamType.live;
final hasChannel = isLive &&
widget.channels != null &&
widget.channels!.isNotEmpty;
final ch = hasChannel ? widget.channels![_currentIndex] : null;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Channel logo during live TV first load
if (ch != null && ch.streamIcon.isNotEmpty)
Container(
width: 72,
height: 72,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface38, size: 40),
),
),
),
const CircularProgressIndicator(color: AppColors.primary),
const SizedBox(height: 16),
Text(
ch != null ? ch.name : 'Chargement...',
style: const TextStyle(
color: AppColors.onSurface70,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildErrorView() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: AppColors.error),
const SizedBox(height: 16),
const Text(
'Erreur de lecture',
style: TextStyle(color: AppColors.onSurface, fontSize: 18),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage ?? 'Une erreur inconnue est survenue',
style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Retour'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.onSurface,
foregroundColor: AppColors.onPrimaryFixed,
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _loadStream,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.onPrimaryFixed,
),
),
],
),
],
),
);
}
}
/// Isolated Stats Widget to prevent rebuilding the entire Video Player on every update
class StatsOverlayWidget extends ConsumerStatefulWidget {
final Player player;
const StatsOverlayWidget({super.key, required this.player});
@override
ConsumerState<StatsOverlayWidget> createState() => _StatsOverlayWidgetState();
}
class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
Duration _buffer = Duration.zero;
String _decoder = 'Unknown';
// Throttling updates
Timer? _updateTimer;
@override
void initState() {
super.initState();
// Use a periodic timer to poll/update stats instead of listening to every single frequent event
// This reduces UI thread load significantly on low-end devices
_updateTimer = Timer.periodic(const Duration(milliseconds: 1000), (_) {
if (mounted) {
setState(() {
_buffer = widget.player.state.buffer;
// Accessing nested prop safely just in case
final track = widget.player.state.track.video;
_decoder = track.decoder ?? 'Auto';
});
}
});
}
@override
void dispose() {
_updateTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!ref.watch(mobileSettingsProvider).showDebugStats) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.success.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'STATS FOR NERDS 🤓',
style: TextStyle(
color: AppColors.success,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
'Buffer: ${_buffer.inSeconds}s',
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
Text(
'Decoder: $_decoder',
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
],
),
);
}
}