mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: implement NativePlayerScreen using media_kit with optimized playback and decoding configurations
This commit is contained in:
1 parent
5110d70d73
commit
4a884af972
8 files changed
+754
-149
No files matched your search
@@ -4,7 +4,6 @@
|
|||||||
/// to prevent OOM crashes on low-end devices
|
/// to prevent OOM crashes on low-end devices
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -191,9 +191,11 @@ class HiveEncryptionBenchmark {
|
|||||||
|
|
||||||
print('\nStatus:');
|
print('\nStatus:');
|
||||||
print(
|
print(
|
||||||
' $writeStatus Write overhead: ${writeOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)');
|
' $writeStatus Write overhead: ${writeOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)',
|
||||||
|
);
|
||||||
print(
|
print(
|
||||||
' $readStatus Read overhead: ${readOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)');
|
' $readStatus Read overhead: ${readOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)',
|
||||||
|
);
|
||||||
print('=' * 60 + '\n');
|
print('=' * 60 + '\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +244,8 @@ class EncryptionBenchmarkResult {
|
|||||||
void print() {
|
void print() {
|
||||||
print(' Name: $name');
|
print(' Name: $name');
|
||||||
print(
|
print(
|
||||||
' Write: ${writeTimeMs}ms (${writePerOpUs.toStringAsFixed(2)}µs/op)');
|
' Write: ${writeTimeMs}ms (${writePerOpUs.toStringAsFixed(2)}µs/op)',
|
||||||
|
);
|
||||||
print(' Read: ${readTimeMs}ms (${readPerOpUs.toStringAsFixed(2)}µs/op)');
|
print(' Read: ${readTimeMs}ms (${readPerOpUs.toStringAsFixed(2)}µs/op)');
|
||||||
print(' Delete: ${deleteTimeMs}ms');
|
print(' Delete: ${deleteTimeMs}ms');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ class MemoryProfiler {
|
|||||||
});
|
});
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print(
|
print(
|
||||||
'⏰ PERIODIC MEMORY MONITORING STARTED (${interval.inSeconds}s interval)');
|
'⏰ PERIODIC MEMORY MONITORING STARTED (${interval.inSeconds}s interval)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +52,14 @@ class MemoryProfiler {
|
|||||||
|
|
||||||
/// Analyze memory growth between snapshots
|
/// Analyze memory growth between snapshots
|
||||||
static MemoryAnalysis analyzeGrowth(String startLabel, String endLabel) {
|
static MemoryAnalysis analyzeGrowth(String startLabel, String endLabel) {
|
||||||
final start = _snapshots.firstWhere((s) => s.label == startLabel,
|
final start = _snapshots.firstWhere(
|
||||||
orElse: () => _snapshots.first);
|
(s) => s.label == startLabel,
|
||||||
final end = _snapshots.firstWhere((s) => s.label == endLabel,
|
orElse: () => _snapshots.first,
|
||||||
orElse: () => _snapshots.last);
|
);
|
||||||
|
final end = _snapshots.firstWhere(
|
||||||
|
(s) => s.label == endLabel,
|
||||||
|
orElse: () => _snapshots.last,
|
||||||
|
);
|
||||||
|
|
||||||
return MemoryAnalysis(
|
return MemoryAnalysis(
|
||||||
startLabel: startLabel,
|
startLabel: startLabel,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// Profiler for video playback performance metrics
|
/// Profiler for video playback performance metrics
|
||||||
|
|||||||
@@ -147,8 +147,9 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateEPG() async {
|
Future<void> _updateEPG() async {
|
||||||
if (widget.streamType != model.StreamType.live || _xtreamService == null)
|
if (widget.streamType != model.StreamType.live || _xtreamService == null) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final currentChannelId =
|
final currentChannelId =
|
||||||
widget.channels?[_currentIndex].streamId ?? widget.streamId;
|
widget.channels?[_currentIndex].streamId ?? widget.streamId;
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
final FocusNode _audioFocusNode = FocusNode();
|
final FocusNode _audioFocusNode = FocusNode();
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isFirstLoad = true; // true = black overlay; false = silent spinner
|
||||||
|
bool _isReconnecting = false; // silent reconnect spinner (no black overlay)
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
late int _currentIndex;
|
late int _currentIndex;
|
||||||
XtreamServiceMobile? _xtreamService;
|
XtreamServiceMobile? _xtreamService;
|
||||||
@@ -85,6 +87,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
final bool _isSeeking = false;
|
final bool _isSeeking = false;
|
||||||
bool _useSoftwareDecoder = false;
|
bool _useSoftwareDecoder = false;
|
||||||
|
|
||||||
|
// [TiviMate] Zapping OSD — shown for 3s after each channel switch
|
||||||
|
bool _showZappingOSD = false;
|
||||||
|
Timer? _zappingOSDTimer;
|
||||||
|
|
||||||
|
// [TiviMate] Channel List Sidebar
|
||||||
|
bool _showChannelList = false;
|
||||||
|
Timer? _channelListTimer;
|
||||||
|
final ScrollController _channelListScrollController = ScrollController();
|
||||||
|
|
||||||
Timer? _clockTimer;
|
Timer? _clockTimer;
|
||||||
Timer? _controlsTimer; // Auto-hide timer
|
Timer? _controlsTimer; // Auto-hide timer
|
||||||
Timer? _epgTimer; // Periodic EPG refresh for live TV
|
Timer? _epgTimer; // Periodic EPG refresh for live TV
|
||||||
@@ -92,8 +103,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
static const int _maxReconnectAttempts = 5;
|
static const int _maxReconnectAttempts = 5;
|
||||||
String _currentTime = '';
|
String _currentTime = '';
|
||||||
bool _hasMarkedAsWatched = false; // Flag to mark watched only once at 80%
|
bool _hasMarkedAsWatched = false;
|
||||||
DateTime? _lastSaveTime; // For throttling position saves
|
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() {
|
void _resetControlsTimer() {
|
||||||
_controlsTimer?.cancel();
|
_controlsTimer?.cancel();
|
||||||
@@ -163,72 +180,95 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
(_player.platform as dynamic)?.setProperty('cache', 'yes');
|
(_player.platform as dynamic)?.setProperty('cache', 'yes');
|
||||||
|
|
||||||
if (widget.streamType == model.StreamType.live) {
|
if (widget.streamType == model.StreamType.live) {
|
||||||
// LIVE PROFILE: Free-Run Speed (TiviMate Style)
|
// ===== LIVE PROFILE — Anti-Artefact v22.0 (TiviMate Flow) =====
|
||||||
// 10s safety margin with minimal RAM footprint (16MB)
|
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
|
||||||
(_player.platform as dynamic)?.setProperty('cache-secs', '10');
|
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
|
||||||
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '16777216');
|
|
||||||
|
// --- Buffering stable (TiviMate Medium ~ 8-10s) ---
|
||||||
|
(_player.platform as dynamic)?.setProperty('cache-secs', '8');
|
||||||
|
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max
|
||||||
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
|
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
|
||||||
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
|
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
|
||||||
(_player.platform as dynamic)?.setProperty('correct-pts', 'no');
|
// 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
|
||||||
// Hardware decoding for Live (Essential for 1080p efficiency)
|
(_player.platform as dynamic)?.setProperty('cache-pause', 'yes');
|
||||||
(_player.platform as dynamic)?.setProperty('hwdec', 'mediacodec');
|
(_player.platform as dynamic)?.setProperty('cache-pause-wait', '0.5');
|
||||||
|
|
||||||
// Stop "Slow Motion" on RMC Découverte (1080i): No sync wait!
|
|
||||||
(_player.platform as dynamic)?.setProperty('video-sync', 'display-desync');
|
|
||||||
(_player.platform as dynamic)?.setProperty('deinterlace', 'no');
|
|
||||||
|
|
||||||
// Ensure temp files are cleaned up
|
|
||||||
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
|
|
||||||
(_player.platform as dynamic)?.setProperty('cache-pause-initial', 'no');
|
(_player.platform as dynamic)?.setProperty('cache-pause-initial', 'no');
|
||||||
(_player.platform as dynamic)?.setProperty('cache-pause', 'no');
|
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
|
||||||
(_player.platform as dynamic)?.setProperty('cache-pause-wait', '1');
|
|
||||||
(_player.platform as dynamic)?.setProperty('force-seekable', 'no');
|
// --- 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 {
|
} else {
|
||||||
// VOD PROFILE: Radical Compatibility (Software Mode)
|
// VOD PROFILE: Radical Compatibility (Software Mode)
|
||||||
// Pure CPU decoding to guarantee image visibility (Fixes "White Page" surface errors)
|
// 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('hwdec', 'no');
|
||||||
|
(_player.platform as dynamic)?.setProperty('correct-pts', 'yes');
|
||||||
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
|
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
|
||||||
}
|
}
|
||||||
|
|
||||||
// [V6.1 Fix] Universal Audio Compatibility (TiviMate-like driver)
|
// [V11.0 Perfect Restoration] Recovering settings from working commit a1628a6b
|
||||||
// Forced Stereo handles 5.1 tracks on 2.0 hardware without initialization errors
|
// Forced software re-sampling handles ANY stubborn 5.1/LATM/24-bit audio track.
|
||||||
|
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
|
||||||
|
(_player.platform as dynamic)?.setProperty('af', 'lavrresample,format=channels=stereo:sample_fmts=s16');
|
||||||
|
(_player.platform as dynamic)?.setProperty('audio-format', 's16');
|
||||||
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
|
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
|
||||||
(_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
|
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
|
||||||
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.5');
|
(_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('audio-samplerate', '48000');
|
||||||
|
(_player.platform as dynamic)?.setProperty('aid', 'auto');
|
||||||
|
|
||||||
// Global Seeking: 'no' is much safer for IPTV hardware startup
|
// --- Seeking & Sync globaux ---
|
||||||
(_player.platform as dynamic)?.setProperty('hr-seek', 'no');
|
(_player.platform as dynamic)?.setProperty('hr-seek', 'no'); // Seeking rapide (plus sûr pour IPTV)
|
||||||
|
(_player.platform as dynamic)?.setProperty('hr-seek-framedrop', 'yes');
|
||||||
// Ensure temp files are cleaned up
|
|
||||||
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
|
(_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes');
|
||||||
(_player.platform as dynamic)?.setProperty('interpolation', 'no'); // Keep off for safety
|
(_player.platform as dynamic)?.setProperty('interpolation', 'no'); // Pas d'interpolation (trop lourd sur Android)
|
||||||
|
|
||||||
// Network resilience - more aggressive settings
|
// --- Réseau & Timeouts ---
|
||||||
(_player.platform as dynamic)?.setProperty('network-timeout', '120');
|
(_player.platform as dynamic)?.setProperty('network-timeout', '120');
|
||||||
(_player.platform as dynamic)?.setProperty('stream-timeout', '120');
|
(_player.platform as dynamic)?.setProperty('stream-timeout', '120');
|
||||||
(_player.platform as dynamic)?.setProperty('tls-verify', 'no');
|
(_player.platform as dynamic)?.setProperty('tls-verify', 'no');
|
||||||
(_player.platform as dynamic)
|
(_player.platform as dynamic)
|
||||||
?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0');
|
?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0');
|
||||||
|
|
||||||
// [V5.2 Fix] Pure Hardware Acceleration flags (to match TiviMate performance)
|
// --- Reconnexion réseau FFmpeg (consolidée) ---
|
||||||
(_player.platform as dynamic)?.setProperty('vd-lavc-skiploopfilter', 'all');
|
// IMPORTANT : reconnect_at_eof SUPPRIMÉ
|
||||||
(_player.platform as dynamic)?.setProperty('vd-lavc-skipidct', 'all');
|
// Les serveurs IPTV ferment souvent la connexion HTTP entre segments (comportement normal).
|
||||||
(_player.platform as dynamic)?.setProperty('vd-lavc-fast', 'yes');
|
// reconnect_at_eof=1 causait une reconnexion au live edge (position en avance) après chaque EOF
|
||||||
(_player.platform as dynamic)?.setProperty('vd-lavc-threads', '4');
|
// ⇒ freeze pendant la reconnexion + saut de position = RATTRAPAGE visible
|
||||||
|
// Le watchdog applicatif (toutes les 15s) gère les vrais arrêts de stream.
|
||||||
// Network-level reconnection (FFmpeg internal — no app reload)
|
|
||||||
(_player.platform as dynamic)?.setProperty(
|
(_player.platform as dynamic)?.setProperty(
|
||||||
'stream-lavf-o',
|
'stream-lavf-o',
|
||||||
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_at_eof=1,reconnect_delay_max=5',
|
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5',
|
||||||
);
|
);
|
||||||
// [V5.1 Fix] discard genpts to fix CPU-starvation/slow-motion on low-end CPUs
|
|
||||||
|
// --- Probing du flux (une seule entrée, consolidée) ---
|
||||||
|
// Précédent : 2 setProperty('demuxer-lavf-o') en conflit
|
||||||
|
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
|
||||||
(_player.platform as dynamic)?.setProperty(
|
(_player.platform as dynamic)?.setProperty(
|
||||||
'demuxer-lavf-o',
|
'demuxer-lavf-o',
|
||||||
'analyzeduration=2000000,probesize=1000000,fflags=+discardcorrupt',
|
'analyzeduration=2000000,probesize=1000000',
|
||||||
);
|
);
|
||||||
(_player.platform as dynamic)?.setProperty('hr-seek-framedrop', 'yes');
|
|
||||||
(_player.platform as dynamic)?.setProperty('framedrop', 'vo');
|
// --- 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
|
// Low-latency intentionally removed for stability
|
||||||
/*
|
/*
|
||||||
@@ -255,9 +295,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
if (playing) _errorMessage = null; // Clear error on successful play
|
if (playing) _errorMessage = null; // Clear error on successful play
|
||||||
});
|
});
|
||||||
if (playing) {
|
if (playing) {
|
||||||
// Smooth Loading: Hide loading screen 1s AFTER playback actually starts
|
// Smooth Loading: Hide loading screen AFTER playback actually starts
|
||||||
// This masks initial glitches/re-buffering
|
// Faster hide for Live TV
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
final hideDelay = widget.streamType == model.StreamType.live ? 300 : 1000;
|
||||||
|
Future.delayed(Duration(milliseconds: hideDelay), () {
|
||||||
if (mounted && _isPlaying) {
|
if (mounted && _isPlaying) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -271,6 +312,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
if (mounted && !_isSeeking) {
|
if (mounted && !_isSeeking) {
|
||||||
setState(() => _position = position);
|
setState(() => _position = position);
|
||||||
|
|
||||||
|
// [v23.0] Track last position change time for time-based watchdog
|
||||||
|
if (position != _lastKnownPosition) {
|
||||||
|
_lastKnownPosition = position;
|
||||||
|
_lastPositionChangeTime = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if 80% of content watched (for VOD/Series only)
|
// Check if 80% of content watched (for VOD/Series only)
|
||||||
_checkAndMarkWatched(position);
|
_checkAndMarkWatched(position);
|
||||||
|
|
||||||
@@ -445,6 +492,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
_controlsTimer?.cancel();
|
_controlsTimer?.cancel();
|
||||||
_epgTimer?.cancel();
|
_epgTimer?.cancel();
|
||||||
_liveWatchdog?.cancel();
|
_liveWatchdog?.cancel();
|
||||||
|
_zappingOSDTimer?.cancel();
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_channelListScrollController.dispose();
|
||||||
|
|
||||||
// Stop playback first to prevent audio continuing in background
|
// Stop playback first to prevent audio continuing in background
|
||||||
_player.stop();
|
_player.stop();
|
||||||
@@ -625,34 +675,50 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
|
|
||||||
int _watchdogStallCount = 0;
|
int _watchdogStallCount = 0;
|
||||||
|
|
||||||
/// Start watchdog timer for live streams
|
/// [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() {
|
void _startLiveWatchdog() {
|
||||||
_liveWatchdog?.cancel();
|
_liveWatchdog?.cancel();
|
||||||
_watchdogStallCount = 0;
|
_watchdogStallCount = 0;
|
||||||
if (widget.streamType != model.StreamType.live) return;
|
if (widget.streamType != model.StreamType.live) return;
|
||||||
|
|
||||||
// [FIX] TiviMate-like Watchdog: Check every 30 seconds for faster recovery
|
// Initialiser le timestamp de position
|
||||||
_liveWatchdog = Timer.periodic(const Duration(seconds: 30), (_) {
|
_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) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// If intentionally paused by user, don't trigger watchdog
|
// Si le player joue et la position avance : tout va bien, reset
|
||||||
if (!_isPlaying && _player.state.playing == false && _errorMessage == null && !_isLoading) {
|
if (_isPlaying && _lastPositionChangeTime != null) {
|
||||||
return;
|
final stallSecs = DateTime.now().difference(_lastPositionChangeTime!).inSeconds;
|
||||||
|
if (stallSecs < 5) return; // Position avance normalement
|
||||||
}
|
}
|
||||||
|
|
||||||
// If buffering or not playing for 60s total, reconnect
|
// Calculer la durée réelle de gel (basée sur la position, pas sur _isPlaying)
|
||||||
if (!_isPlaying || _isLoading) {
|
final stallSecs = _lastPositionChangeTime != null
|
||||||
_watchdogStallCount++;
|
? DateTime.now().difference(_lastPositionChangeTime!).inSeconds
|
||||||
debugPrint(
|
: 9999;
|
||||||
'MediaKitPlayer: Watchdog health check: $_watchdogStallCount/2',
|
|
||||||
);
|
debugPrint('MediaKitPlayer: Watchdog — position gelée depuis ${stallSecs}s');
|
||||||
if (_watchdogStallCount >= 2) {
|
|
||||||
debugPrint('MediaKitPlayer: Watchdog — Stream unstable or stalled, triggering auto-reconnect...');
|
// Ne reconnecte QUE si vraiment gelé depuis > 60s (les cache-pauses sont < 5s)
|
||||||
_watchdogStallCount = 0;
|
// Cela laisse mpv's reconnect interne (stream-lavf-o) gérer les coupures courtes
|
||||||
_attemptReconnect();
|
if (stallSecs > 60) {
|
||||||
}
|
debugPrint('MediaKitPlayer: Watchdog — gel de 60s confirmé, reconnexion forcée');
|
||||||
} else {
|
_lastPositionChangeTime = DateTime.now(); // Éviter re-déclenchement immédiat
|
||||||
_watchdogStallCount = 0; // Reset if playing normally
|
_attemptReconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -686,7 +752,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
if (_xtreamService == null) return;
|
if (_xtreamService == null) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
if (_isFirstLoad) {
|
||||||
|
_isLoading = true; // Black overlay only on first load
|
||||||
|
} else {
|
||||||
|
_isReconnecting = true; // Silent spinner on subsequent loads
|
||||||
|
}
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_epg = null; // Reset EPG
|
_epg = null; // Reset EPG
|
||||||
});
|
});
|
||||||
@@ -735,15 +805,20 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
|
|
||||||
debugPrint('MediaKitPlayer: Loading stream: $streamUrl');
|
debugPrint('MediaKitPlayer: Loading stream: $streamUrl');
|
||||||
|
|
||||||
// Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps)
|
// [v23.0] Fix: Ne PAS overrider hwdec pour les streams live.
|
||||||
// If we are in fallback mode, force software ('no')
|
// initState a déjà configuré 'mediacodec' pour le live.
|
||||||
String hwdecValue;
|
// Overrider ici avec mobileSettingsProvider.decoderMode annulerait ce réglage
|
||||||
if (_useSoftwareDecoder) {
|
// (si l'utilisateur a 'auto' dans les settings → retour à mediacodec-copy).
|
||||||
hwdecValue = 'no';
|
// Pour le VOD : on garde le fallback logiciel si nécessaire.
|
||||||
} else {
|
if (widget.streamType != model.StreamType.live) {
|
||||||
hwdecValue = ref.read(mobileSettingsProvider).decoderMode;
|
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');
|
||||||
}
|
}
|
||||||
(_player.platform as dynamic)?.setProperty('hwdec', hwdecValue);
|
|
||||||
|
|
||||||
await _player.open(
|
await _player.open(
|
||||||
Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}),
|
Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}),
|
||||||
@@ -751,7 +826,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
// [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
|
_reconnectAttempts = 0; // Reset reconnect counter on success
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -807,6 +889,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_isReconnecting = false;
|
||||||
|
_isFirstLoad = false;
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -841,10 +925,48 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
void _switchChannel(int index) {
|
void _switchChannel(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
|
_showZappingOSD = true;
|
||||||
|
_showChannelList = false; // close sidebar on switch
|
||||||
|
_showControls = false; // hide regular OSD — Zapping OSD takes over
|
||||||
|
});
|
||||||
|
// Show zapping OSD for 3 seconds
|
||||||
|
_zappingOSDTimer?.cancel();
|
||||||
|
_zappingOSDTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) setState(() => _showZappingOSD = false);
|
||||||
});
|
});
|
||||||
_loadStream();
|
_loadStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open/close the channel list sidebar
|
||||||
|
void _toggleChannelList() {
|
||||||
|
setState(() => _showChannelList = !_showChannelList);
|
||||||
|
if (_showChannelList) {
|
||||||
|
_scrollToCurrentChannel();
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_channelListTimer = Timer(const Duration(seconds: 6), () {
|
||||||
|
if (mounted) setState(() => _showChannelList = false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
void _togglePlayPause() {
|
||||||
if (_isPlaying) {
|
if (_isPlaying) {
|
||||||
_player.pause();
|
_player.pause();
|
||||||
@@ -982,6 +1104,26 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OK / Select when channel list is open → switch to focused channel
|
||||||
|
if (_showChannelList &&
|
||||||
|
(key == LogicalKeyboardKey.enter ||
|
||||||
|
key == LogicalKeyboardKey.select ||
|
||||||
|
key == LogicalKeyboardKey.numpadEnter)) {
|
||||||
|
// Channel list handles its own tap, this closes it
|
||||||
|
setState(() => _showChannelList = false);
|
||||||
|
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
|
// Cycle Aspect Ratio - secret key 'A' or digit 1
|
||||||
if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) {
|
if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) {
|
||||||
_cycleAspectRatio();
|
_cycleAspectRatio();
|
||||||
@@ -1050,9 +1192,24 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
backgroundColor: Colors.black, // [V5.2 Fix] Enforce black background globally
|
backgroundColor: Colors.black, // [V5.2 Fix] Enforce black background globally
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: _toggleControls,
|
onTap: () {
|
||||||
|
if (_showChannelList) {
|
||||||
|
setState(() => _showChannelList = false);
|
||||||
|
} else {
|
||||||
|
_toggleControls();
|
||||||
|
}
|
||||||
|
},
|
||||||
onPanDown: (_) => _onUserInteraction(),
|
onPanDown: (_) => _onUserInteraction(),
|
||||||
onPanUpdate: (_) => _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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Video Player
|
// Video Player
|
||||||
@@ -1069,14 +1226,35 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Loading Overlay (Stays on top until smooth playback starts)
|
// Loading Overlay — black only on FIRST load
|
||||||
// We moved _buildLoadingView out of the if/else chain above to overlay it
|
|
||||||
if (_isLoading && _errorMessage == null)
|
if (_isLoading && _errorMessage == null)
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: _buildLoadingView(),
|
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: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Custom controls overlay
|
// Custom controls overlay
|
||||||
if (_showControls && _errorMessage == null)
|
if (_showControls && _errorMessage == null)
|
||||||
_buildControlsOverlay(title),
|
_buildControlsOverlay(title),
|
||||||
@@ -1166,6 +1344,20 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// [TiviMate] Zapping OSD — shown briefly on channel switch
|
||||||
|
if (_showZappingOSD &&
|
||||||
|
widget.streamType == model.StreamType.live &&
|
||||||
|
widget.channels != null &&
|
||||||
|
widget.channels!.isNotEmpty)
|
||||||
|
_buildZappingOSD(),
|
||||||
|
|
||||||
|
// [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
|
// Stats Overlay (Top Left) - Isolated to prevent main rebuilds
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 80,
|
top: 80,
|
||||||
@@ -1338,6 +1530,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
Widget _buildEPGBox(String title) {
|
Widget _buildEPGBox(String title) {
|
||||||
final rawNow = _epg?.nowPlaying ?? '';
|
final rawNow = _epg?.nowPlaying ?? '';
|
||||||
String nowPlaying = rawNow.isNotEmpty ? rawNow : "Pas d'infos EPG";
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1382,6 +1589,39 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
// EPG Progress Bar
|
||||||
|
if (startTime.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
startTime,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54, 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: Colors.white24,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
Color(0xFF4FC3F7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
endTime,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1395,7 +1635,306 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
if (widget.streamType == model.StreamType.live) {
|
if (widget.streamType == model.StreamType.live) {
|
||||||
return _buildUnifiedOSD(title);
|
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: Colors.white12),
|
||||||
|
),
|
||||||
|
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: Colors.white10,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: ch.streamIcon,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
const Icon(Icons.tv, color: Colors.white38, size: 32),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
margin: const EdgeInsets.only(right: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.white10,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.tv, color: Colors.white38, 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 Color(0xFF1565C0),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'CH $chNum',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
ch.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
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: Colors.white24,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Color(0xFF4FC3F7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (nowPlaying.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
nowPlaying,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
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: Colors.white24,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
Color(0xFF4FC3F7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [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: Colors.white12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.list, color: Colors.white70, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Chaînes',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _showChannelList = false),
|
||||||
|
child: const Icon(Icons.close,
|
||||||
|
color: Colors.white54, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Channel list
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _channelListScrollController,
|
||||||
|
itemCount: channels.length,
|
||||||
|
itemExtent: 64.0,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ch = channels[index];
|
||||||
|
final isActive = index == _currentIndex;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_switchChannel(index);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive
|
||||||
|
? const Color(0xFF1565C0).withOpacity(0.6)
|
||||||
|
: Colors.transparent,
|
||||||
|
border: isActive
|
||||||
|
? const Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: Color(0xFF4FC3F7),
|
||||||
|
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: Colors.white10,
|
||||||
|
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: Colors.white38,
|
||||||
|
size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.tv,
|
||||||
|
color: Colors.white38, size: 20),
|
||||||
|
),
|
||||||
|
// Name + num
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ch.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive
|
||||||
|
? Colors.white
|
||||||
|
: Colors.white70,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'CH ${index + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Active indicator
|
||||||
|
if (isActive)
|
||||||
|
const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: Color(0xFF4FC3F7),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVodControlsOverlay() {
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
@@ -1583,15 +2122,46 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLoadingView() {
|
Widget _buildLoadingView() {
|
||||||
return const Center(
|
// 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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(color: AppColors.primary),
|
// Channel logo during live TV first load
|
||||||
SizedBox(height: 16),
|
if (ch != null && ch.streamIcon.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: ch.streamIcon,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
const Icon(Icons.tv, color: Colors.white38, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const CircularProgressIndicator(color: AppColors.primary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Chargement...',
|
ch != null ? ch.name : 'Chargement...',
|
||||||
style: TextStyle(color: Colors.white70),
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -107,16 +107,20 @@ class XtreamServiceMobile {
|
|||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
} else if (response.data is Map) {
|
} else if (response.data is Map) {
|
||||||
if (kDebugMode) print('⚠️ API returned a Map instead of List: ${response.data}');
|
if (kDebugMode)
|
||||||
|
print('⚠️ API returned a Map instead of List: ${response.data}');
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
if (kDebugMode) print('⚠️ API returned unexpected type: ${response.data.runtimeType}');
|
if (kDebugMode)
|
||||||
|
print(
|
||||||
|
'⚠️ API returned unexpected type: ${response.data.runtimeType}');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Unexpected response status: ${response.statusCode}, body: ${response.data}');
|
print(
|
||||||
|
'⚠️ Unexpected response status: ${response.statusCode}, body: ${response.data}');
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -144,7 +148,8 @@ class XtreamServiceMobile {
|
|||||||
/// Instead of 50 requests for 50 channels, this loads all at once.
|
/// Instead of 50 requests for 50 channels, this loads all at once.
|
||||||
/// Results are cached for 1 hour to avoid repeated requests.
|
/// Results are cached for 1 hour to avoid repeated requests.
|
||||||
Future<Map<String, model.ShortEPG>> getBatchEPG(
|
Future<Map<String, model.ShortEPG>> getBatchEPG(
|
||||||
List<String> streamIds) async {
|
List<String> streamIds,
|
||||||
|
) async {
|
||||||
if (streamIds.isEmpty) return {};
|
if (streamIds.isEmpty) return {};
|
||||||
|
|
||||||
// Create cache key from sorted IDs for consistency
|
// Create cache key from sorted IDs for consistency
|
||||||
@@ -154,8 +159,9 @@ class XtreamServiceMobile {
|
|||||||
// Check if we have valid cached data
|
// Check if we have valid cached data
|
||||||
final cached = _epgBatchCache[cacheKeyStr];
|
final cached = _epgBatchCache[cacheKeyStr];
|
||||||
if (cached != null && !cached.isExpired) {
|
if (cached != null && !cached.isExpired) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
print('✅ EPG batch cache hit: ${streamIds.length} channels');
|
print('✅ EPG batch cache hit: ${streamIds.length} channels');
|
||||||
|
}
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,19 +196,21 @@ class XtreamServiceMobile {
|
|||||||
// Load EPG for each stream_id individually (API doesn't support true batch)
|
// Load EPG for each stream_id individually (API doesn't support true batch)
|
||||||
for (final streamId in streamIds) {
|
for (final streamId in streamIds) {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio
|
||||||
'$_baseUrl/player_api.php',
|
.get(
|
||||||
queryParameters: {
|
'$_baseUrl/player_api.php',
|
||||||
'username': _username,
|
queryParameters: {
|
||||||
'password': _password,
|
'username': _username,
|
||||||
'action': 'get_short_epg',
|
'password': _password,
|
||||||
'stream_id': streamId,
|
'action': 'get_short_epg',
|
||||||
},
|
'stream_id': streamId,
|
||||||
options: Options(
|
},
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
options: Options(
|
||||||
sendTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
),
|
sendTimeout: const Duration(seconds: 10),
|
||||||
).timeout(const Duration(seconds: 10));
|
),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode == 200 && response.data is Map) {
|
if (response.statusCode == 200 && response.data is Map) {
|
||||||
final data = response.data as Map;
|
final data = response.data as Map;
|
||||||
@@ -272,27 +280,34 @@ class XtreamServiceMobile {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ Loaded ${categories.length} live categories: ${categories.map((c) => c.categoryName).toList()}');
|
print(
|
||||||
|
'✅ Loaded ${categories.length} live categories: ${categories.map((c) => c.categoryName).toList()}');
|
||||||
}
|
}
|
||||||
|
|
||||||
return categories;
|
return categories;
|
||||||
} else if (response.data is Map) {
|
} else if (response.data is Map) {
|
||||||
if (kDebugMode) print('⚠️ get_live_categories returned a Map (not a list): ${response.data}');
|
if (kDebugMode)
|
||||||
|
print(
|
||||||
|
'⚠️ get_live_categories returned a Map (not a list): ${response.data}');
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
if (kDebugMode) print('⚠️ get_live_categories returned unexpected type: ${response.data.runtimeType}');
|
if (kDebugMode)
|
||||||
|
print(
|
||||||
|
'⚠️ get_live_categories returned unexpected type: ${response.data.runtimeType}');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Failed to load categories (status: ${response.statusCode}, body: ${response.data})');
|
print(
|
||||||
|
'⚠️ Failed to load categories (status: ${response.statusCode}, body: ${response.data})');
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print(
|
print(
|
||||||
'⏱️ Timeout loading live categories - will fallback to loading all channels');
|
'⏱️ Timeout loading live categories - will fallback to loading all channels',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -404,7 +419,8 @@ class XtreamServiceMobile {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ get_series returned unexpected status or type: ${response.statusCode}');
|
print(
|
||||||
|
'⚠️ get_series returned unexpected status or type: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -412,7 +428,6 @@ class XtreamServiceMobile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Get series paginated / filtered by category
|
/// Get series paginated / filtered by category
|
||||||
Future<List<model.Series>> getSeriesPaginated({String? categoryId}) async {
|
Future<List<model.Series>> getSeriesPaginated({String? categoryId}) async {
|
||||||
try {
|
try {
|
||||||
@@ -535,9 +550,11 @@ class XtreamServiceMobile {
|
|||||||
try {
|
try {
|
||||||
final allSeries = await getSeries();
|
final allSeries = await getSeries();
|
||||||
if (query.isEmpty) return allSeries;
|
if (query.isEmpty) return allSeries;
|
||||||
|
|
||||||
final normalizedQuery = query.toLowerCase();
|
final normalizedQuery = query.toLowerCase();
|
||||||
return allSeries.where((s) => s.name.toLowerCase().contains(normalizedQuery)).toList();
|
return allSeries
|
||||||
|
.where((s) => s.name.toLowerCase().contains(normalizedQuery))
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) print('❌ Error searching series: $e');
|
if (kDebugMode) print('❌ Error searching series: $e');
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -94,9 +94,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Determine if we should show the category grid
|
// Determine if we should show the category grid
|
||||||
final bool showGrid = uiState.isCategoryView &&
|
final bool showGrid =
|
||||||
_searchQuery.isEmpty &&
|
uiState.isCategoryView && _searchQuery.isEmpty && !_showFavoritesOnly;
|
||||||
!_showFavoritesOnly;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: const Color(0xFF000000),
|
color: const Color(0xFF000000),
|
||||||
@@ -183,7 +182,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
if (settings.liveTvKeywords.isNotEmpty) {
|
if (settings.liveTvKeywords.isNotEmpty) {
|
||||||
categories = allCategories
|
categories = allCategories
|
||||||
.where(
|
.where(
|
||||||
(cat) => settings.matchesLiveTvFilter(cat.categoryName),)
|
(cat) => settings.matchesLiveTvFilter(cat.categoryName),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
categories.sort(
|
categories.sort(
|
||||||
(a, b) => a.categoryName.compareTo(b.categoryName),
|
(a, b) => a.categoryName.compareTo(b.categoryName),
|
||||||
@@ -221,8 +221,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
toolbarHeight: _searchQuery.isNotEmpty || _showFavoritesOnly || !uiState.isCategoryView
|
toolbarHeight: _searchQuery.isNotEmpty ||
|
||||||
? 120.0
|
_showFavoritesOnly ||
|
||||||
|
!uiState.isCategoryView
|
||||||
|
? 120.0
|
||||||
: 110.0,
|
: 110.0,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Container(
|
background: Container(
|
||||||
@@ -247,13 +249,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
child: Container(
|
child: Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: AppDecorations.searchBar(context),
|
decoration: AppDecorations.searchBar(context),
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(horizontal: 12),
|
horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.search,
|
Icons.search,
|
||||||
color: AppDecorations.textSecondary(context),
|
color: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -265,11 +268,12 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
readOnly: !_isSearchEditing,
|
readOnly: !_isSearchEditing,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color: AppDecorations.textPrimary(
|
||||||
AppDecorations.textPrimary(context),
|
context),
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Rechercher une chaîne...',
|
hintText:
|
||||||
|
'Rechercher une chaîne...',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
@@ -283,11 +287,12 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
),
|
),
|
||||||
if (_searchQuery.isNotEmpty)
|
if (_searchQuery.isNotEmpty)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _searchController.clear(),
|
onTap: () =>
|
||||||
|
_searchController.clear(),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
color:
|
color: AppDecorations.textSecondary(
|
||||||
AppDecorations.textSecondary(context),
|
context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -306,7 +311,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back,
|
Icons.arrow_back,
|
||||||
color: AppDecorations.textPrimary(context),
|
color:
|
||||||
|
AppDecorations.textPrimary(context),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
@@ -337,7 +343,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
: uiState.selectedCategory ??
|
: uiState.selectedCategory ??
|
||||||
'Chaînes',
|
'Chaînes',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppDecorations.textPrimary(context),
|
color:
|
||||||
|
AppDecorations.textPrimary(context),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
),
|
),
|
||||||
@@ -351,17 +358,19 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
: Icons.favorite_border,
|
: Icons.favorite_border,
|
||||||
color: _showFavoritesOnly
|
color: _showFavoritesOnly
|
||||||
? const Color(0xFFFF453A)
|
? const Color(0xFFFF453A)
|
||||||
: AppDecorations.textSecondary(context),
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showFavoritesOnly = !_showFavoritesOnly;
|
_showFavoritesOnly = !_showFavoritesOnly;
|
||||||
if (_showFavoritesOnly) {
|
if (_showFavoritesOnly) {
|
||||||
uiNotifier.state = uiState.copyWith(
|
uiNotifier.state = uiState.copyWith(
|
||||||
isCategoryView: false);
|
isCategoryView: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!_showFavoritesOnly) {
|
if (!_showFavoritesOnly) {
|
||||||
uiNotifier.state =
|
uiNotifier.state = uiState.copyWith(
|
||||||
uiState.copyWith(isCategoryView: true);
|
isCategoryView: true);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -535,7 +544,8 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
// Phase 2: load channels for selected category
|
// Phase 2: load channels for selected category
|
||||||
final channelsAsync = categoryId != null
|
final channelsAsync = categoryId != null
|
||||||
? ref.watch(
|
? ref.watch(
|
||||||
mobileLiveChannelsByCategoryProvider((playlist, categoryId)),)
|
mobileLiveChannelsByCategoryProvider((playlist, categoryId)),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (channelsAsync == null) {
|
if (channelsAsync == null) {
|
||||||
@@ -579,9 +589,8 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
.where((c) => c.name.toLowerCase().contains(searchQuery))
|
.where((c) => c.name.toLowerCase().contains(searchQuery))
|
||||||
.toList();
|
.toList();
|
||||||
} else if (showFavoritesOnly) {
|
} else if (showFavoritesOnly) {
|
||||||
displayedChannels = channels
|
displayedChannels =
|
||||||
.where((c) => favorites.contains(c.streamId))
|
channels.where((c) => favorites.contains(c.streamId)).toList();
|
||||||
.toList();
|
|
||||||
} else {
|
} else {
|
||||||
displayedChannels = channels;
|
displayedChannels = channels;
|
||||||
}
|
}
|
||||||
@@ -719,12 +728,14 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
|||||||
cacheManager: AppCacheManager.instance,
|
cacheManager: AppCacheManager.instance,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.tv,
|
Icons.tv,
|
||||||
color: AppDecorations.iconMuted(context),
|
color:
|
||||||
|
AppDecorations.iconMuted(context),
|
||||||
size: 30,
|
size: 30,
|
||||||
),
|
),
|
||||||
placeholder: (_, __) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
Icons.tv,
|
Icons.tv,
|
||||||
color: AppDecorations.iconMuted(context),
|
color:
|
||||||
|
AppDecorations.iconMuted(context),
|
||||||
size: 30,
|
size: 30,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user