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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user