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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user