diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index b7f1d70e..5a84a844 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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 _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, ); diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index ff2dd2c5..c0282f88 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -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 _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 _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'); + } + } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0159dd27..fd999683 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -391,6 +391,21 @@ class VideoPlayerScreenState extends State 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 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 with WidgetsBindin _autoPlayTimer?.cancel(); _tvBackgroundMediaControlResumeTimer?.cancel(); + _tvBackgroundPlayerSuspendTimer?.cancel(); _stillWatchingTimer?.cancel();