fix(tv): retain live TV session while backgrounded

This commit is contained in:
edde746
2026-07-09 23:22:27 +02:00
parent fe5d6d8d96
commit 2c13438d71
4 changed files with 64 additions and 17 deletions
+13 -17
View File
@@ -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<void> _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<void> _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,
@@ -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;
}
+1
View File
@@ -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';
@@ -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,
);
});
}