fix(tv): release native AV pipeline when app stays backgrounded

A paused-but-alive player keeps its MediaCodec decoders and tunneled
passthrough AudioTrack while Plezy sits in the background, which can
degrade playback in every other app on shared-pipeline TV SoCs until
force-stop. After a 30s grace on Android TV the player is stopped
(retaining session state); returning reloads in place, or re-tunes the
channel for live TV.
This commit is contained in:
edde746
2026-07-09 15:23:29 +02:00
parent 71d644c4ae
commit a4a2942546
3 changed files with 175 additions and 9 deletions
+123 -2
View File
@@ -26,6 +26,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
'pipActive': pipActive,
'pipTransitionInFlight': _androidAutoPipTransitionInFlight,
'hiddenForBackground': _hiddenForBackground,
'playerSuspendedForTvBackground': _playerSuspendedForTvBackground,
'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground,
'pendingForegroundMediaResume': _resumeFromSuspendedMediaControlOnForeground,
'backend': _playerBackendLabel,
@@ -46,6 +47,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
' pipActive=$pipActive'
' pipTransitionInFlight=$_androidAutoPipTransitionInFlight'
' hiddenForBackground=$_hiddenForBackground'
' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground'
' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground'
' pendingForegroundMediaResume=$_resumeFromSuspendedMediaControlOnForeground'
' backend=$_playerBackendLabel',
@@ -110,7 +112,11 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
if (isTv) {
await _suspendMediaControlsForTvBackground('hidden');
_recordLifecycleState('hidden', action: 'tv_background_pause_only');
if (_armTvBackgroundPlayerSuspendTimer()) {
_recordLifecycleState('hidden', action: 'tv_background_pause_suspend_armed');
} else {
_recordLifecycleState('hidden', action: 'tv_background_pause_only');
}
return;
}
@@ -143,12 +149,19 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
_recordLifecycleState('resumed', action: 'render_restored');
}
// A TV background suspend released the native pipeline via stop();
// rebuild the playback session in place before the media-control restore
// below can act on the stopped player.
if (_playerSuspendedForTvBackground) {
await _restorePlayerAfterTvBackgroundSuspend();
if (!mounted || currentPlayer != player) return;
}
// TV never hides the render layer on background (_handleAppHidden returns
// early without setting _hiddenForBackground), but the screensaver can
// still destroy the surface. Kick the video output so a missed surface
// callback can't leave the picture black: mpv re-attaches via
// refreshVideoOutput, ExoPlayer just reapplies sizing/z-order.
if (!_hiddenForBackground &&
else if (!_hiddenForBackground &&
Platform.isAndroid &&
PlatformDetector.isTV() &&
currentPlayer != null &&
@@ -167,4 +180,112 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
_resumeLiveTimelineAfterBackgroundIfNeeded();
_recordLifecycleState('resumed', action: 'complete');
}
/// Arm the grace timer that releases the native AV pipeline if the app
/// stays backgrounded (Android TV only). Returns whether it was armed.
bool _armTvBackgroundPlayerSuspendTimer() {
if (!Platform.isAndroid || !PlatformDetector.isTV()) return false;
if (_playerSuspendedForTvBackground) return false;
if (widget.isLive) {
// Restore re-tunes via _switchLiveChannel(0); don't suspend a live
// session the zap flow can't rebuild.
final channels = widget.live?.channels;
if (channels == null || channels.isEmpty) return false;
if (_live.channelIndex < 0 || _live.channelIndex >= channels.length) return false;
}
_tvBackgroundPlayerSuspendTimer?.cancel();
_tvBackgroundPlayerSuspendTimer = Timer(VideoPlayerScreenState._tvBackgroundPlayerSuspendGrace, () {
_tvBackgroundPlayerSuspendTimer = null;
_enqueueLifecycleTransition('tv_background_suspend', _suspendPlayerForTvBackground);
});
return true;
}
void _cancelTvBackgroundPlayerSuspendTimer() {
_tvBackgroundPlayerSuspendTimer?.cancel();
_tvBackgroundPlayerSuspendTimer = null;
}
/// Grace expired while still backgrounded: release the native AV pipeline
/// (MediaCodec decoders + AudioTrack, tunneled passthrough included) so a
/// parked Plezy can't starve other apps on shared-hardware TV SoCs. stop()
/// retains Dart-side position/duration/track state on both Android
/// backends, and the progress tracker keeps sending paused heartbeats at
/// the retained position, so the server session stays alive and resumable.
/// Position and track selections are latched here because the reload on
/// restore reads them after the native state is gone.
Future<void> _suspendPlayerForTvBackground() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null || !_isPlayerInitialized) return;
if (_playerSuspendedForTvBackground || _shouldSkipForPip) return;
final lifecycleState = WidgetsBinding.instance.lifecycleState;
if (lifecycleState == AppLifecycleState.resumed || lifecycleState == AppLifecycleState.inactive) return;
if (_playbackTransition != _PlaybackTransition.idle || !_hasFirstFrame.value) {
// A reload/zap/startup flow owns the player right now; stopping under
// it would corrupt its open sequence. Retry after another grace.
_armTvBackgroundPlayerSuspendTimer();
return;
}
_tvBackgroundSuspendPosition = currentPlayer.state.position;
_tvBackgroundSuspendAudioTrack = currentPlayer.state.track.audio;
_tvBackgroundSuspendSubtitleTrack = currentPlayer.state.track.subtitle;
_tvBackgroundSuspendSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
_playerSuspendedForTvBackground = true;
try {
await currentPlayer.stop();
_recordLifecycleState('hidden', action: 'tv_background_suspend');
} catch (e) {
_playerSuspendedForTvBackground = false;
_tvBackgroundSuspendPosition = null;
_tvBackgroundSuspendAudioTrack = null;
_tvBackgroundSuspendSubtitleTrack = null;
_tvBackgroundSuspendSecondarySubtitleTrack = null;
appLogger.w('TV background suspend failed; player left paused', error: e);
}
}
/// Rebuild the playback session after a TV background suspend released the
/// native pipeline. VOD reloads in place through the regular reload flow —
/// a fresh playback decision, since the suspended stream URL may have
/// expired server-side — and comes back paused; the caller's
/// [_restoreMediaControlsAfterResume] then resumes it (with
/// rewind-on-resume) exactly like a plain background pause. Live re-tunes
/// the current channel through the zap flow, which starts playing at the
/// live edge.
Future<void> _restorePlayerAfterTvBackgroundSuspend() async {
_playerSuspendedForTvBackground = false;
final resumePosition = _tvBackgroundSuspendPosition;
final audioTrack = _tvBackgroundSuspendAudioTrack;
final subtitleTrack = _tvBackgroundSuspendSubtitleTrack;
final secondarySubtitleTrack = _tvBackgroundSuspendSecondarySubtitleTrack;
_tvBackgroundSuspendPosition = null;
_tvBackgroundSuspendAudioTrack = null;
_tvBackgroundSuspendSubtitleTrack = null;
_tvBackgroundSuspendSecondarySubtitleTrack = null;
final currentPlayer = player;
if (!mounted || currentPlayer == null || !_isPlayerInitialized) return;
if (widget.isLive) {
_recordLifecycleState('resumed', action: 'tv_background_suspend_retune');
await _switchLiveChannel(0);
return;
}
_recordLifecycleState('resumed', action: 'tv_background_suspend_reload');
final reloaded = await _reloadMediaInPlace(
metadata: _currentMetadata,
resumePosition: resumePosition,
preserveCurrentTrackSelection: true,
preservedAudioTrack: audioTrack,
preservedSubtitleTrack: subtitleTrack,
preservedSecondarySubtitleTrack: secondarySubtitleTrack,
startPaused: true,
reason: 'TV background suspend restore',
);
if (!reloaded) {
appLogger.w('TV background suspend restore: in-place reload rejected');
}
}
}