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
@@ -210,6 +210,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
/// Reload a VOD item/source while keeping the route, player instance, and
/// native renderer alive. This is the common path for episode navigation,
/// queue item jumps, Watch Together media switches, and source changes.
///
/// [preservedAudioTrack]/[preservedSubtitleTrack]/
/// [preservedSecondarySubtitleTrack] override the live player state when
/// [preserveCurrentTrackSelection] is set — for callers whose player no
/// longer holds the selections (the TV background suspend stops the native
/// player, which clears its track state, before the reload runs).
///
/// [startPaused] keeps the reloaded item paused: open() starts held, and
/// every post-open resume point (subtitle-load resume, frame-rate gate
/// release) arms track selection without playing, the same way a Watch
/// Together-owned start does. The caller owns starting playback.
Future<bool> _reloadMediaInPlace({
required MediaItem metadata,
int? selectedMediaIndex,
@@ -219,6 +230,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
int? selectedAudioStreamId,
Duration? resumePosition,
bool preserveCurrentTrackSelection = false,
AudioTrack? preservedAudioTrack,
SubtitleTrack? preservedSubtitleTrack,
SubtitleTrack? preservedSecondarySubtitleTrack,
bool startPaused = false,
bool useCurrentAudioStreamSelection = true,
bool showErrorUi = true,
String reason = 'media reload',
@@ -247,10 +262,14 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final previousHasFirstFrame = _hasFirstFrame.value;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
final currentAudioTrack = preserveCurrentTrackSelection ? currentPlayer.state.track.audio : null;
final currentSubtitleTrack = preserveCurrentTrackSelection ? currentPlayer.state.track.subtitle : null;
final currentAudioTrack = preserveCurrentTrackSelection
? preservedAudioTrack ?? currentPlayer.state.track.audio
: null;
final currentSubtitleTrack = preserveCurrentTrackSelection
? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle
: null;
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? currentPlayer.state.track.secondarySubtitle
? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle
: null;
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
var didOpenReplacement = false;
@@ -410,7 +429,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup,
play:
!frameRatePlan.holdPlaybackStart &&
!wtOwnsStart &&
!startPaused &&
externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
onOpened: () {
@@ -453,7 +476,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
trackManager.cacheExternalSubtitles(result.externalSubtitles);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart;
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart && !startPaused;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: externalSubtitlePlan,
@@ -464,9 +487,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
shouldResumeAfterSubtitleLoad: () =>
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
!wtOwnsStart &&
!startPaused &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: wtOwnsStart && !frameRatePlan.holdPlaybackStart,
applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart,
);
if (!isCurrentReload()) return true;
@@ -474,11 +498,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// startPaused rides the Watch Together yield path: the gate release
// arms track selection but leaves the player paused for the caller.
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart,
wtOwnsStart: wtOwnsStart || startPaused,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
+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');
}
}
}
+19
View File
@@ -391,6 +391,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
String _playerBackendLabel = 'unknown';
Timer? _tvBackgroundMediaControlResumeTimer;
/// Android TV: release the native AV pipeline once the app stays
/// backgrounded past this grace window. A merely paused player keeps its
/// MediaCodec decoders and (tunneled passthrough) AudioTrack alive, which
/// on shared-pipeline TV SoCs degrades every other app until Plezy is
/// force-stopped. The grace absorbs transient hidden/paused blips
/// (assistant overlay, HDMI-CEC events) so quick app switches don't churn
/// codecs.
static const Duration _tvBackgroundPlayerSuspendGrace = Duration(seconds: 30);
Timer? _tvBackgroundPlayerSuspendTimer;
bool _playerSuspendedForTvBackground = false;
Duration? _tvBackgroundSuspendPosition;
AudioTrack? _tvBackgroundSuspendAudioTrack;
SubtitleTrack? _tvBackgroundSuspendSubtitleTrack;
SubtitleTrack? _tvBackgroundSuspendSecondarySubtitleTrack;
/// Whether to skip lifecycle actions because PiP is active or about to start.
/// Apple auto-PiP is system-initiated during the background transition, and
/// Android auto-PiP on API 26-30 has a brief native transition window before
@@ -634,6 +649,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_recordLifecycleState('paused', action: 'backgrounded');
break;
case AppLifecycleState.resumed:
// Synchronously, before the queued transition: a pending suspend must
// not fire between this event and _handleAppResumed running.
_cancelTvBackgroundPlayerSuspendTimer();
_recordLifecycleState('resumed');
_enqueueLifecycleTransition('resumed', _handleAppResumed);
break;
@@ -1154,6 +1172,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_autoPlayTimer?.cancel();
_tvBackgroundMediaControlResumeTimer?.cancel();
_tvBackgroundPlayerSuspendTimer?.cancel();
_stillWatchingTimer?.cancel();