mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-20 13:48:18 +02:00
feat: XMLTV EPG fallback, Warm Cinema theme, background release
Align the Android app with xtremflow on three fronts. EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a -3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls back through the provider's xmltv.php, a user-supplied URL, then a community guide whenever get_short_epg returns nothing. Channels are matched on epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes, "FR:" and "|FR|" prefixes). A streamId -> Channel index populated by getLiveChannels supplies those keys, so no getShortEPG call site changed. EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow: a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited panels answered with errors, emptying the guide for the whole screen. Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla) into app_colors, app_theme and app_decorations. The 21 Apple TV colour names already in use are kept as compatibility aliases. mobile_theme now derives from AppTheme instead of duplicating its 390 lines. Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on transient Android interruptions such as pulling down the notification shade and was killing playback. Both engines now release on paused/hidden/detached via _releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock dropped. An _isReleased latch keeps the watchdog and reconnect paths from restarting a stream from the background. Long-run stability: cancel the six player stream subscriptions on dispose, cap demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the 150MB readahead pushed the native heap toward an Android memory kill after a few hours), rebuild on position only while the controls are visible, and dispose the Lite player's focus nodes. Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS socket before playback. No HTTP request is issued, so no provider connection slot is consumed. Capped at 1.5s. Verified: flutter analyze clean on all touched files; 19/19 tests, including a run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5 name probes matched); flutter build apk --release succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
63e77c5bc5
commit
8d74bbb320
15 files changed
+2138
-852
No files matched your search
@@ -72,6 +72,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
Timer? _controlsTimer;
|
||||
int _loadId = 0;
|
||||
|
||||
/// Set once the app is backgrounded, so a queued reload cannot restart the
|
||||
/// stream behind the user.
|
||||
bool _isReleased = false;
|
||||
|
||||
// Focus
|
||||
final FocusNode _playPauseFocusNode = FocusNode();
|
||||
final FocusNode _prevFocusNode = FocusNode();
|
||||
@@ -98,17 +102,45 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (_controller == null) return;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_controller?.pause();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// `inactive` is deliberately NOT handled: on Android it fires for transient
|
||||
// interruptions (notification shade, call banner, permission dialog).
|
||||
final isBackgrounded = state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.hidden ||
|
||||
state == AppLifecycleState.detached;
|
||||
if (!isBackgrounded) return;
|
||||
|
||||
_releasePlayback();
|
||||
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop playback and release the decoder when the app leaves the foreground.
|
||||
///
|
||||
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
|
||||
/// stream would still be pulled (and metered) in the background.
|
||||
void _releasePlayback() {
|
||||
if (_isReleased) return;
|
||||
_isReleased = true;
|
||||
|
||||
_clockTimer?.cancel();
|
||||
_clockTimer = null;
|
||||
_epgTimer?.cancel();
|
||||
_epgTimer = null;
|
||||
_controlsTimer?.cancel();
|
||||
_controlsTimer = null;
|
||||
_stabilizationTimer?.cancel();
|
||||
_stabilizationTimer = null;
|
||||
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
controller?.pause();
|
||||
controller?.dispose();
|
||||
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
void _startClock() {
|
||||
_clockTimer?.cancel();
|
||||
_updateTime();
|
||||
@@ -132,6 +164,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_xtreamService = XtreamServiceMobile(dir.path);
|
||||
await _xtreamService!.setPlaylistAsync(widget.playlist);
|
||||
|
||||
// Warm DNS + TCP/TLS to the panel host before the first request.
|
||||
await _xtreamService!.prewarmHost().timeout(
|
||||
const Duration(milliseconds: 1500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
|
||||
_loadStream();
|
||||
|
||||
if (widget.streamType == model.StreamType.live) {
|
||||
@@ -160,6 +199,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
Future<void> _loadStream() async {
|
||||
if (_xtreamService == null) return;
|
||||
// Backgrounded while a (re)load was queued — do not resurrect the stream.
|
||||
if (_isReleased || !mounted) return;
|
||||
|
||||
await _controller?.dispose();
|
||||
_controller = null;
|
||||
@@ -359,6 +400,11 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_controller?.pause(); // Force stop audio immediately
|
||||
_controller?.dispose();
|
||||
_xtreamService?.dispose();
|
||||
|
||||
_playPauseFocusNode.dispose();
|
||||
_prevFocusNode.dispose();
|
||||
_nextFocusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
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 = '';
|
||||
@@ -185,6 +194,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
(_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-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');
|
||||
@@ -284,7 +300,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_controller = VideoController(_player);
|
||||
|
||||
// Listen to player state
|
||||
_player.stream.playing.listen((playing) {
|
||||
_playerSubscriptions.add(_player.stream.playing.listen((playing) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlaying = playing;
|
||||
@@ -302,18 +318,25 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_resetControlsTimer(); // Start auto-hide timer
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_player.stream.position.listen((position) {
|
||||
_playerSubscriptions.add(_player.stream.position.listen((position) {
|
||||
if (mounted && !_isSeeking) {
|
||||
setState(() => _position = position);
|
||||
|
||||
// [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);
|
||||
|
||||
@@ -335,9 +358,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_player.stream.duration.listen((duration) {
|
||||
_playerSubscriptions.add(_player.stream.duration.listen((duration) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_duration = duration;
|
||||
@@ -348,16 +371,16 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_player.stream.width.listen((width) {
|
||||
_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);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_player.stream.error.listen((error) async {
|
||||
_playerSubscriptions.add(_player.stream.error.listen((error) async {
|
||||
if (!mounted) return;
|
||||
if (error.isEmpty) return;
|
||||
|
||||
@@ -385,10 +408,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_errorMessage = error;
|
||||
_isLoading = false;
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
// Listen for stream completion
|
||||
_player.stream.completed.listen((completed) {
|
||||
_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).
|
||||
@@ -409,7 +432,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Lock to landscape for video playback
|
||||
SystemChrome.setPreferredOrientations([
|
||||
@@ -435,13 +458,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
|
||||
void _updateTime() {
|
||||
if (mounted) {
|
||||
final now = DateTime.now();
|
||||
setState(() {
|
||||
_currentTime =
|
||||
"${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
|
||||
});
|
||||
}
|
||||
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
|
||||
@@ -453,23 +477,52 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached) {
|
||||
if (widget.streamType == model.StreamType.live) {
|
||||
// Force stop for Live TV
|
||||
_player.stop();
|
||||
} else {
|
||||
_player.pause();
|
||||
}
|
||||
// `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;
|
||||
|
||||
// Exit player screen so user returns to dashboard on resume
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
_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
|
||||
@@ -481,8 +534,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
// Disable wakelock when leaving player
|
||||
WakelockPlus.disable();
|
||||
|
||||
// Save resume position before cleanup (only for VOD/Series)
|
||||
_saveResumePositionOnExit();
|
||||
// Save resume position before cleanup (only for VOD/Series).
|
||||
// Skipped when _releasePlayback already did it on backgrounding.
|
||||
if (!_isReleased) {
|
||||
_saveResumePositionOnExit();
|
||||
}
|
||||
|
||||
_clockTimer?.cancel();
|
||||
_controlsTimer?.cancel();
|
||||
@@ -491,6 +547,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_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();
|
||||
@@ -605,7 +668,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
|
||||
/// Attempt to reconnect a live stream that stopped
|
||||
void _attemptReconnect() {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isReleased) return;
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
setState(() {
|
||||
_errorMessage = 'Flux interrompu après plusieurs tentatives';
|
||||
@@ -693,7 +756,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
|
||||
// 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 || _isReleased) return;
|
||||
|
||||
// Si le player joue et la position avance : tout va bien, reset
|
||||
if (_isPlaying && _lastPositionChangeTime != null) {
|
||||
@@ -727,6 +790,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_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) {
|
||||
@@ -745,6 +816,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user