From aedca9b416a149885b0d0dbf8a7a6652d3bf553e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:45:53 +0200 Subject: [PATCH] chore(player): centralize first-frame readiness in FirstFrameGate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- .../video_player/first_frame_gate.dart | 64 ++++++++++++++ lib/screens/video_player/parts/build.dart | 6 +- .../video_player/parts/display_matching.dart | 2 +- lib/screens/video_player/parts/errors.dart | 4 +- lib/screens/video_player/parts/lifecycle.dart | 2 +- lib/screens/video_player/parts/live_tv.dart | 9 +- .../video_player/parts/playback_open.dart | 2 +- .../video_player/parts/playback_reload.dart | 12 ++- .../video_player/parts/playback_services.dart | 18 ++-- .../video_player/parts/playback_start.dart | 8 +- .../video_player/parts/watch_together.dart | 2 +- lib/screens/video_player_screen.dart | 13 ++- .../video_player/first_frame_gate_test.dart | 84 +++++++++++++++++++ 13 files changed, 182 insertions(+), 44 deletions(-) create mode 100644 lib/screens/video_player/first_frame_gate.dart create mode 100644 test/screens/video_player/first_frame_gate_test.dart diff --git a/lib/screens/video_player/first_frame_gate.dart b/lib/screens/video_player/first_frame_gate.dart new file mode 100644 index 000000000..f91888b1b --- /dev/null +++ b/lib/screens/video_player/first_frame_gate.dart @@ -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 uiReady = ValueNotifier(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(); + } +} diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 66533a1f6..26a2b4b49 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -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) diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index afc85a745..502d32326 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -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) { diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index 63aa6d748..8f25e9176 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -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', diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 789aaa10a..01e1a866e 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -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(); diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 9a2440dee..8e542686b 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -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); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 2df12fae5..a8460410b 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -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; diff --git a/lib/screens/video_player/parts/playback_reload.dart b/lib/screens/video_player/parts/playback_reload.dart index b20c015bc..9e342cabe 100644 --- a/lib/screens/video_player/parts/playback_reload.dart +++ b/lib/screens/video_player/parts/playback_reload.dart @@ -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, ); } diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index fd7ff0ebf..448cb5038 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -37,10 +37,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } Future _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(); } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 2c4a44982..2e8d1c74a 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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())); } } diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index c8ffd19ca..46d490bef 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -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. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index b73a5b61c..de72baf75 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 with WidgetsBindin } final ValueNotifier _isBuffering = ValueNotifier(false); - final ValueNotifier _hasFirstFrame = ValueNotifier(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 _isExiting = ValueNotifier(false); @@ -1019,7 +1016,7 @@ class VideoPlayerScreenState extends State 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 with WidgetsBindin _detachFromWatchTogetherSession(); _isBuffering.dispose(); - _hasFirstFrame.dispose(); + _firstFrame.dispose(); _isExiting.dispose(); _chromeController.dispose(); _toastController.dispose(); @@ -2174,7 +2171,7 @@ class VideoPlayerScreenState extends State 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(), ); diff --git a/test/screens/video_player/first_frame_gate_test.dart b/test/screens/video_player/first_frame_gate_test.dart new file mode 100644 index 000000000..3a57a6823 --- /dev/null +++ b/test/screens/video_player/first_frame_gate_test.dart @@ -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); + }); + }); +}