chore(player): centralize first-frame readiness in FirstFrameGate

_hasRenderedFirstFrame had five writer files with hand-rolled
snapshot/restore pairs in the reload and channel-zap rollbacks, and the
paired _hasFirstFrame ValueNotifier was reset independently along the
open path. Move both flags into FirstFrameGate with explicit verbs —
markReady, resetUiForOpen, resetRenderedForAttempt, reset,
forceUiReadyOnFailure, snapshot/restore — so the UI-vs-reporting
asymmetry and the transactional rollbacks are enforced by one type.
The uiReady notifier keeps its identity across attempts; the video
surface, controls, and buffering overlay hold it by reference.
Unit-tests the gate's verb semantics.
This commit is contained in:
edde746
2026-08-20 02:45:53 +02:00
parent d9a5fb034e
commit aedca9b416
13 changed files with 182 additions and 44 deletions
@@ -0,0 +1,64 @@
import 'package:flutter/foundation.dart';
/// Snapshot of both first-frame flags, taken before an in-place reload or a
/// live channel zap so a failed replacement can restore the outgoing
/// stream's readiness state transactionally.
typedef FirstFrameSnapshot = ({bool uiReady, bool rendered});
/// The player screen's first-frame readiness state, in one place.
///
/// Two deliberately asymmetric flags:
/// - [uiReady] drives the UI (spinner, chrome, controls readiness). It may be
/// forced true after a startup failure purely to hide the loading spinner.
/// - [rendered] is the stricter reporting latch: only a renderer event (or
/// the established non-ExoPlayer position fallback) marks it, and progress
/// reporting/watchdog gating read it.
///
/// The [uiReady] notifier is created once per screen and must keep its
/// identity across playback attempts, reloads, and channel zaps — the video
/// surface, controls, and buffering overlay hold it by reference.
class FirstFrameGate {
final ValueNotifier<bool> uiReady = ValueNotifier<bool>(false);
bool _rendered = false;
bool get rendered => _rendered;
/// A frame is on screen: latch reporting readiness and unblock the UI.
void markReady() {
_rendered = true;
uiReady.value = true;
}
/// A new open is about to produce its own first frame; show the spinner.
void resetUiForOpen() {
uiReady.value = false;
}
/// A new playback attempt owns the reporting latch from here on.
void resetRenderedForAttempt() {
_rendered = false;
}
/// Full reset — attempt teardown and live channel zaps.
void reset() {
uiReady.value = false;
_rendered = false;
}
/// Hide the spinner after a startup failure WITHOUT latching rendered:
/// UI readiness and reporting readiness intentionally diverge here.
void forceUiReadyOnFailure() {
uiReady.value = true;
}
FirstFrameSnapshot snapshot() => (uiReady: uiReady.value, rendered: _rendered);
void restore(FirstFrameSnapshot snapshot) {
uiReady.value = snapshot.uiReady;
_rendered = snapshot.rendered;
}
void dispose() {
uiReady.dispose();
}
}
+3 -3
View File
@@ -277,7 +277,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
return Video(
player: player!,
hasFirstFrame: _hasFirstFrame,
hasFirstFrame: _firstFrame.uiReady,
controls: (context) => PlexVideoControls(
player: player!,
volumeController: _volumeController!,
@@ -319,7 +319,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
canControl: authority.canControlPlayback,
canNavigateMediaItems: authority.canNavigateMediaItems,
hasFirstFrame: _hasFirstFrame,
hasFirstFrame: _firstFrame.uiReady,
playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null,
chromeController: _chromeController,
shaderService: _shaderService,
@@ -370,7 +370,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
// Hidden in PiP mode
VideoPlayerBufferingOverlay(
isBuffering: _isBuffering,
hasFirstFrame: _hasFirstFrame,
hasFirstFrame: _firstFrame.uiReady,
isExiting: _isExiting,
),
// Watch Together overlays (isolated from video surface repaints)
@@ -135,7 +135,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
void _onFullscreenChanged() {
if (_displayModeService == null) return;
if (FullscreenStateManager().isFullscreen) {
if (_hasFirstFrame.value && !_displayModeService!.anyChangeApplied) {
if (_firstFrame.uiReady.value && !_displayModeService!.anyChangeApplied) {
_applyWindowsDisplayMatching();
}
} else if (_displayModeService!.anyChangeApplied) {
+2 -2
View File
@@ -69,7 +69,7 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
// A sidecar subtitle fetch shares this log stream and could arm the
// watchdog too, but a first frame disarms it, so that only matters when
// the primary media is itself stuck.
if (status == 503 && !widget.isLive && !_hasRenderedFirstFrame && !_hasFatalPlaybackError) {
if (status == 503 && !widget.isLive && !_firstFrame.rendered && !_hasFatalPlaybackError) {
_http503Watchdog.onOpenPhase503();
}
if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) {
@@ -82,7 +82,7 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
/// server is still refusing the stream. Synthesize the error the reconnect
/// loop will never raise on its own so the normal failure policy runs.
void _onOpenHttp503Persistent() {
if (!mounted || _isExiting.value || _hasRenderedFirstFrame || _hasFatalPlaybackError) return;
if (!mounted || _isExiting.value || _firstFrame.rendered || _hasFatalPlaybackError) return;
appLogger.w(
'Server kept answering the stream with HTTP 503 for '
'${openHttp503Patience.inSeconds}s without a first frame — giving up on this open',
@@ -279,7 +279,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
if (_playerSuspendedForTvBackground || _shouldSkipForPip) return;
final lifecycleState = WidgetsBinding.instance.lifecycleState;
if (lifecycleState == AppLifecycleState.resumed || lifecycleState == AppLifecycleState.inactive) return;
if (_playbackTransition != _PlaybackTransition.idle || !_hasFirstFrame.value) {
if (_playbackTransition != _PlaybackTransition.idle || !_firstFrame.uiReady.value) {
// A reload/zap/startup flow owns the player right now; stopping under
// it would corrupt its open sequence. Retry after another grace.
_armTvBackgroundPlayerSuspendTimer();
+3 -6
View File
@@ -343,8 +343,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
_liveSeek.cancel();
final previousSession = _live.session;
final previousHasFirstFrame = _hasFirstFrame.value;
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
final previousFirstFrame = _firstFrame.snapshot();
final channel = channels[newIndex];
appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');
@@ -375,8 +374,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
}
_setPlayerState(() {
_hasFirstFrame.value = false;
_hasRenderedFirstFrame = false;
_firstFrame.reset();
});
replacementOpenStarted = true;
await currentPlayer.open(
@@ -420,8 +418,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (!isCurrentChannelSwitch()) return;
if (replacementOpenStarted && mounted && _live.session == previousSession) {
_setPlayerState(() {
_hasFirstFrame.value = previousHasFirstFrame;
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
_firstFrame.restore(previousFirstFrame);
});
}
appLogger.e('Failed to switch channel', error: e);
@@ -871,7 +871,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
// Open video through Player
if (result.videoUrl != null) {
// Reset first frame flag and frame rate retry counter for new video
if (clearFirstFrameForOpen) _hasFirstFrame.value = false;
if (clearFirstFrameForOpen) _firstFrame.resetUiForOpen();
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) {
_frameRate.applied = true;
@@ -395,8 +395,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
final previousPartId = _currentMediaInfo?.partId;
final previousMediaSourceId = _currentMediaInfo?.mediaSourceId;
final previousHasFirstFrame = _hasFirstFrame.value;
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
final previousFirstFrame = _firstFrame.snapshot();
final previousHasFatalPlaybackError = _hasFatalPlaybackError;
_hasFatalPlaybackError = false;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
@@ -498,7 +497,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
_unfocusPlayNextPrompt();
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
_hasFirstFrame.value = false;
_firstFrame.resetUiForOpen();
// Detach before pausing so the reload's internal pause can't broadcast
// a party-wide pause; the finally below restores the attachment.
@@ -669,7 +668,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
return true;
},
onOpening: () {
_hasRenderedFirstFrame = false;
_firstFrame.resetRenderedForAttempt();
// 503s observed from here on belong to the replacement open.
_http503Watchdog.disarm();
},
@@ -735,8 +734,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
if (previousLaunchIdentity != null) {
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
}
_hasFirstFrame.value = previousHasFirstFrame;
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
_firstFrame.restore(previousFirstFrame);
_hasFatalPlaybackError = previousHasFatalPlaybackError;
// If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends).
@@ -792,7 +790,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
ratingKey: _currentMetadata.id,
serverId: reattachServerId,
mediaTitle: _currentMetadata.displayTitle,
hasFirstFrame: _hasFirstFrame.value,
hasFirstFrame: _firstFrame.uiReady.value,
remoteSeek: _seekPlayback,
);
}
@@ -37,10 +37,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
}
Future<void> _markFirstFrameReady(Player currentPlayer, SettingsService settingsService) async {
if (!mounted || player != currentPlayer || _hasRenderedFirstFrame || _hasFatalPlaybackError) return;
if (!mounted || player != currentPlayer || _firstFrame.rendered || _hasFatalPlaybackError) return;
_hasRenderedFirstFrame = true;
_hasFirstFrame.value = true;
_firstFrame.markReady();
_http503Watchdog.disarm();
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
final progressTracker = _progressTracker;
@@ -156,7 +155,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
// a renderer, so it may infer readiness only after switching to MPV.
final canInferRenderedFrameFromPosition =
!(Platform.isAndroid && useExoPlayer) || (currentPlayer is PlayerAndroid && currentPlayer.usingMpvFallback);
if (canInferRenderedFrameFromPosition && !_hasRenderedFirstFrame) {
if (canInferRenderedFrameFromPosition && !_firstFrame.rendered) {
if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) {
unawaited(_markFirstFrameReady(currentPlayer, settingsService));
}
@@ -280,8 +279,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (mounted) {
_isBuffering.value = false;
_hasFirstFrame.value = false;
_hasRenderedFirstFrame = false;
_firstFrame.reset();
_http503Watchdog.disarm();
}
}
@@ -372,8 +370,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
playMethod: effectivePlayMethod,
playSessionId: playSessionId,
mediaInfo: mediaInfo,
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _hasRenderedFirstFrame,
canReportPlayback: () => _firstFrame.rendered && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _firstFrame.rendered,
subtitleOffIsDeliberate: () => _playbackSession?.subtitleSelection.declinedPreference == null,
onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode'
? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId)
@@ -402,8 +400,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
player: currentPlayer,
isOffline: true,
offlineWatchService: offlineWatchService,
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _hasRenderedFirstFrame,
canReportPlayback: () => _firstFrame.rendered && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _firstFrame.rendered,
);
_progressTracker!.startTracking();
}
@@ -5,7 +5,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer);
_hasRenderedFirstFrame = false;
_firstFrame.resetRenderedForAttempt();
_hasFatalPlaybackError = false;
// 503s observed from here on belong to this attempt's open.
_http503Watchdog.disarm();
@@ -13,7 +13,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
try {
_hasFirstFrame.value = false;
_firstFrame.resetUiForOpen();
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions(currentPlayer);
if (!attempt.isCurrent) return;
@@ -314,7 +314,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
_firstFrame.forceUiReadyOnFailure(); // Hide spinner on every current startup failure
showErrorSnackBar(context, e.message);
}
} catch (e, st) {
@@ -323,7 +323,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
_firstFrame.forceUiReadyOnFailure(); // Hide spinner on every current startup failure
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
@@ -23,7 +23,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
ratingKey: _currentMetadata.id,
serverId: serverId,
mediaTitle: _currentMetadata.displayTitle,
hasFirstFrame: _hasFirstFrame.value,
hasFirstFrame: _firstFrame.uiReady.value,
startupHold: startupHold,
// Sync-issued seeks ride the screen's seek path so Plex transcode
// restarts keep working for out-of-buffer targets.
+5 -8
View File
@@ -85,6 +85,7 @@ import '../utils/route_visibility.dart';
import '../utils/video_player_navigation.dart';
import '../utils/android_exit_diagnostics.dart';
import 'video_player/completion_latch.dart';
import 'video_player/first_frame_gate.dart';
import 'video_player/frame_rate_matcher.dart';
import 'video_player/companion_remote_binding.dart';
import 'video_player/media_controls_screen_controller.dart';
@@ -986,11 +987,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
// UI readiness may be forced true to hide the loading spinner after a
// startup failure. Reporting readiness is stricter: only a renderer event
// (or the established non-ExoPlayer position fallback) sets this latch.
bool _hasRenderedFirstFrame = false;
final FirstFrameGate _firstFrame = FirstFrameGate();
bool _hasFatalPlaybackError = false;
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
@@ -1019,7 +1016,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
isPromptOpen: () => _showPlayNextDialog || _showStillWatchingPrompt,
dismissPrompt: _dismissPlaybackPromptForBack,
isChromePresented: () =>
_isPlayerInitialized && player != null && _hasFirstFrame.value && _chromeController.controlsPresented,
_isPlayerInitialized && player != null && _firstFrame.uiReady.value && _chromeController.controlsPresented,
// On phones a Back must exit the player even with the controls up
// (#1938); the staged chrome handling is TV/desktop behavior (#4443b761
// applied it to the phone system-back path too, so back stopped
@@ -2017,7 +2014,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_detachFromWatchTogetherSession();
_isBuffering.dispose();
_hasFirstFrame.dispose();
_firstFrame.dispose();
_isExiting.dispose();
_chromeController.dispose();
_toastController.dispose();
@@ -2174,7 +2171,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
primePlayerNavigationFocusForEvent(
event,
focusNode: _screenFocusNode,
playerReady: _isPlayerInitialized && player != null && _hasFirstFrame.value,
playerReady: _isPlayerInitialized && player != null && _firstFrame.uiReady.value,
isCurrentRoute: isRouteChainCurrent(context),
isAppleTV: PlatformDetector.isAppleTV(),
);
@@ -0,0 +1,84 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/first_frame_gate.dart';
void main() {
group('FirstFrameGate', () {
test('markReady latches both reporting and UI readiness', () {
final gate = FirstFrameGate();
addTearDown(gate.dispose);
gate.markReady();
expect(gate.rendered, isTrue);
expect(gate.uiReady.value, isTrue);
});
test('forceUiReadyOnFailure hides the spinner without latching rendered', () {
final gate = FirstFrameGate();
addTearDown(gate.dispose);
gate.forceUiReadyOnFailure();
expect(gate.uiReady.value, isTrue, reason: 'spinner must hide on a current startup failure');
expect(gate.rendered, isFalse, reason: 'reporting readiness is stricter than UI readiness');
});
test('resetUiForOpen leaves the reporting latch untouched', () {
final gate = FirstFrameGate()..markReady();
addTearDown(gate.dispose);
gate.resetUiForOpen();
expect(gate.uiReady.value, isFalse);
expect(gate.rendered, isTrue);
});
test('resetRenderedForAttempt leaves UI readiness untouched', () {
final gate = FirstFrameGate()..markReady();
addTearDown(gate.dispose);
gate.resetRenderedForAttempt();
expect(gate.rendered, isFalse);
expect(gate.uiReady.value, isTrue);
});
test('reset clears both flags', () {
final gate = FirstFrameGate()..markReady();
addTearDown(gate.dispose);
gate.reset();
expect(gate.rendered, isFalse);
expect(gate.uiReady.value, isFalse);
});
test('snapshot/restore round-trips a diverged state across a failed replacement', () {
final gate = FirstFrameGate()..forceUiReadyOnFailure();
addTearDown(gate.dispose);
final snapshot = gate.snapshot();
gate.reset();
gate.restore(snapshot);
expect(gate.uiReady.value, isTrue);
expect(gate.rendered, isFalse);
});
test('uiReady keeps its identity so listeners survive resets', () {
final gate = FirstFrameGate();
addTearDown(gate.dispose);
final notifier = gate.uiReady;
var notifications = 0;
notifier.addListener(() => notifications++);
gate.markReady();
gate.reset();
gate.restore((uiReady: true, rendered: true));
expect(identical(notifier, gate.uiReady), isTrue);
expect(notifications, 3);
});
});
}