diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 868eea52..658daea0 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -182,14 +182,13 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { /// 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; + if (!shouldSuspendPlayerForTvBackground( + isAndroid: Platform.isAndroid, + isTv: PlatformDetector.isTV(), + isLive: widget.isLive, + alreadySuspended: _playerSuspendedForTvBackground, + )) { + return false; } _tvBackgroundPlayerSuspendTimer?.cancel(); _tvBackgroundPlayerSuspendTimer = Timer(VideoPlayerScreenState._tvBackgroundPlayerSuspendGrace, () { @@ -215,6 +214,9 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { Future _suspendPlayerForTvBackground() async { final currentPlayer = player; if (!mounted || currentPlayer == null || !_isPlayerInitialized) return; + // A live stream's tuned session is also its time-shift buffer. Stopping + // it would force a re-tune at the live edge and discard pause state. + if (widget.isLive) return; if (_playerSuspendedForTvBackground || _shouldSkipForPip) return; final lifecycleState = WidgetsBinding.instance.lifecycleState; if (lifecycleState == AppLifecycleState.resumed || lifecycleState == AppLifecycleState.inactive) return; @@ -248,9 +250,9 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { /// 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. + /// rewind-on-resume) exactly like a plain background pause. Live sessions + /// never enter this flow because their tuned session and capture-buffer + /// position must remain intact across backgrounding. Future _restorePlayerAfterTvBackgroundSuspend() async { _playerSuspendedForTvBackground = false; final resumePosition = _tvBackgroundSuspendPosition; @@ -265,12 +267,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { 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, diff --git a/lib/screens/video_player/tv_background_suspend_policy.dart b/lib/screens/video_player/tv_background_suspend_policy.dart new file mode 100644 index 00000000..bbbad314 --- /dev/null +++ b/lib/screens/video_player/tv_background_suspend_policy.dart @@ -0,0 +1,14 @@ +/// Whether backgrounding may release the native video pipeline after its +/// grace period. +/// +/// Live TV deliberately stays paused with its player open: the tuned session +/// owns the capture buffer, so stopping it would lose both the time-shift +/// position and the user's paused/playing intent when playback is rebuilt. +bool shouldSuspendPlayerForTvBackground({ + required bool isAndroid, + required bool isTv, + required bool isLive, + required bool alreadySuspended, +}) { + return isAndroid && isTv && !isLive && !alreadySuspended; +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e5ee24ed..4060c136 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -81,6 +81,7 @@ import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; +import 'video_player/tv_background_suspend_policy.dart'; import 'video_player/widgets/player_prompt_overlays.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/video_controls/player_chrome_controller.dart'; diff --git a/test/screens/video_player/tv_background_suspend_policy_test.dart b/test/screens/video_player/tv_background_suspend_policy_test.dart new file mode 100644 index 00000000..33de6f3f --- /dev/null +++ b/test/screens/video_player/tv_background_suspend_policy_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/video_player/tv_background_suspend_policy.dart'; + +void main() { + test('Android TV VOD releases the player after the background grace period', () { + expect( + shouldSuspendPlayerForTvBackground(isAndroid: true, isTv: true, isLive: false, alreadySuspended: false), + isTrue, + ); + }); + + test('live TV retains its tuned session and time-shift state', () { + expect( + shouldSuspendPlayerForTvBackground(isAndroid: true, isTv: true, isLive: true, alreadySuspended: false), + isFalse, + ); + }); + + test('non-Android and non-TV players do not use the TV suspend flow', () { + expect( + shouldSuspendPlayerForTvBackground(isAndroid: false, isTv: true, isLive: false, alreadySuspended: false), + isFalse, + ); + expect( + shouldSuspendPlayerForTvBackground(isAndroid: true, isTv: false, isLive: false, alreadySuspended: false), + isFalse, + ); + }); + + test('an already suspended player is not scheduled again', () { + expect( + shouldSuspendPlayerForTvBackground(isAndroid: true, isTv: true, isLive: false, alreadySuspended: true), + isFalse, + ); + }); +}