chore(player,cards): share budgeted card realization and scope player subscriptions in bags
The memo-lookup, scroll-budget, skeleton-scheduling sequence was copied in the paginated grid, the browse tab, and hub rows; SkeletonUpgradeScheduler now owns it as realizeBudgeted with per-site skeleton and card builders, keeping the hub's focus salt and padded skeleton. The video player screen held fourteen nullable subscription fields and listed them twice when cancelling; they are two lists (player re-wire, media controls) snapshotted and cleared before cancellation, with the screen-lifetime Apple TV and sleep-timer subscriptions left as named fields. Live seek and subtitle switches open through one helper, and the initial live open applies the shared stream options instead of an inline property write.
This commit is contained in:
@@ -2201,18 +2201,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
|
||||
}
|
||||
|
||||
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
|
||||
if (cached != null) return cached;
|
||||
if (CardInflationBudget.isScrollingContext(context) &&
|
||||
!InputModeTracker.isKeyboardMode(context) &&
|
||||
!CardInflationBudget.tryTake()) {
|
||||
scheduleSkeletonUpgrade();
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
return _cardMemo.widgetFor(
|
||||
return realizeBudgeted(
|
||||
_cardMemo,
|
||||
context,
|
||||
index,
|
||||
item,
|
||||
epoch: position.layoutEpoch!,
|
||||
keyboardMode: InputModeTracker.isKeyboardMode(context, listen: false),
|
||||
build: () => _buildMediaCardItem(
|
||||
index,
|
||||
isFirstRow: position.isFirstRow,
|
||||
|
||||
@@ -128,18 +128,13 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
return _cardMemo.widgetFor(index, item, epoch: position.layoutEpoch!, build: () => _buildCard(position));
|
||||
}
|
||||
|
||||
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
|
||||
if (cached != null) return cached;
|
||||
if (CardInflationBudget.isScrollingContext(context) &&
|
||||
!InputModeTracker.isKeyboardMode(context) &&
|
||||
!CardInflationBudget.tryTake()) {
|
||||
scheduleSkeletonUpgrade();
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
return _cardMemo.widgetFor(
|
||||
return realizeBudgeted(
|
||||
_cardMemo,
|
||||
context,
|
||||
index,
|
||||
item,
|
||||
epoch: position.layoutEpoch!,
|
||||
keyboardMode: InputModeTracker.isKeyboardMode(context, listen: false),
|
||||
build: () => _buildCard(position, fullBleedImage: useFullCardLayout),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -220,6 +220,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
/// reconnection is handled by the server's transcoder on the input side.
|
||||
Future<void> _setLiveStreamOptions(Player player) => player.setProperty('force-seekable', 'no');
|
||||
|
||||
/// Re-opens the current session's live stream at [streamUrl]: options, then
|
||||
/// `open(isLive: true)` honouring the automotive playback gate.
|
||||
Future<void> _openLiveStream(Player player, String streamUrl) async {
|
||||
await _setLiveStreamOptions(player);
|
||||
await player.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: automotivePlaybackAllowedNow(),
|
||||
isLive: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// The raw live playback position as an absolute epoch second
|
||||
/// (`_live.streamStartEpoch + player position`).
|
||||
int get _rawPositionEpoch => (_live.streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
|
||||
@@ -269,12 +280,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
_live.atLiveEdge = (clamped >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
|
||||
_live.playbackStartTime = DateTime.now();
|
||||
|
||||
await _setLiveStreamOptions(currentPlayer);
|
||||
await currentPlayer.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: automotivePlaybackAllowedNow(),
|
||||
isLive: true,
|
||||
);
|
||||
await _openLiveStream(currentPlayer, streamUrl);
|
||||
if (mounted) _setPlayerState(() {});
|
||||
return true;
|
||||
}
|
||||
@@ -320,12 +326,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
_live.selectedSubtitle = previous;
|
||||
return PlaybackSourceChangeOutcome.failed;
|
||||
}
|
||||
await _setLiveStreamOptions(currentPlayer);
|
||||
await currentPlayer.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: automotivePlaybackAllowedNow(),
|
||||
isLive: true,
|
||||
);
|
||||
await _openLiveStream(currentPlayer, streamUrl);
|
||||
_live.markStreamRestartedAtLiveEdge();
|
||||
if (mounted) _setPlayerState(() {});
|
||||
return PlaybackSourceChangeOutcome.applied;
|
||||
|
||||
@@ -71,46 +71,52 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
int? lastObservedPositionMs;
|
||||
|
||||
_playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged);
|
||||
_playerStreamSubscriptions.add(currentPlayer.streams.playing.listen(_onPlayingStateChanged));
|
||||
|
||||
_completedSubscription = currentPlayer.streams.completed.listen((done) {
|
||||
// completed=false means a file (re)loaded after a reconnect-seek or fresh
|
||||
// open — re-arm the end-of-video latch so the real EOF can still show Play
|
||||
// Next. But only when playback is clear of the end region: a stray
|
||||
// completed=false while parked at EOF must NOT re-arm, or the position
|
||||
// listener would immediately re-fire the Play Next prompt.
|
||||
if (!done) {
|
||||
lastObservedPositionMs = null;
|
||||
final durMs = currentPlayer.state.duration.inMilliseconds;
|
||||
final posMs = currentPlayer.state.position.inMilliseconds;
|
||||
if (durMs <= 0 || posMs < durMs - _episode.completionLatch.rearmWindowMs) {
|
||||
_rearmCompletionLatch();
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.completed.listen((done) {
|
||||
// completed=false means a file (re)loaded after a reconnect-seek or fresh
|
||||
// open — re-arm the end-of-video latch so the real EOF can still show Play
|
||||
// Next. But only when playback is clear of the end region: a stray
|
||||
// completed=false while parked at EOF must NOT re-arm, or the position
|
||||
// listener would immediately re-fire the Play Next prompt.
|
||||
if (!done) {
|
||||
lastObservedPositionMs = null;
|
||||
final durMs = currentPlayer.state.duration.inMilliseconds;
|
||||
final posMs = currentPlayer.state.position.inMilliseconds;
|
||||
if (durMs <= 0 || posMs < durMs - _episode.completionLatch.rearmWindowMs) {
|
||||
_rearmCompletionLatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
// A mid-file EOF is the stream dying under us (#1520), not the media
|
||||
// ending: it must never mark the item watched, prompt Play Next, or
|
||||
// exit a movie. Intercepted here and not inside _onVideoCompleted
|
||||
// because the credits-marker auto-skip legitimately calls
|
||||
// _onVideoCompleted from mid-credits positions.
|
||||
if (done && _eofRecovery.interceptEof(currentPlayer)) return;
|
||||
_onVideoCompleted(done);
|
||||
});
|
||||
// A mid-file EOF is the stream dying under us (#1520), not the media
|
||||
// ending: it must never mark the item watched, prompt Play Next, or
|
||||
// exit a movie. Intercepted here and not inside _onVideoCompleted
|
||||
// because the credits-marker auto-skip legitimately calls
|
||||
// _onVideoCompleted from mid-credits positions.
|
||||
if (done && _eofRecovery.interceptEof(currentPlayer)) return;
|
||||
_onVideoCompleted(done);
|
||||
}),
|
||||
);
|
||||
|
||||
_errorSubscription = currentPlayer.streams.error.listen(_onPlayerError);
|
||||
_playerStreamSubscriptions.add(currentPlayer.streams.error.listen(_onPlayerError));
|
||||
|
||||
// warn is included so we can catch ffmpeg's "HTTP error 4xx/5xx" line in
|
||||
// _onPlayerLog — the error-level log that follows omits the status code.
|
||||
_logSubscription = currentPlayer.streams.log
|
||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||
.listen(_onPlayerLog);
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.log
|
||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||
.listen(_onPlayerLog),
|
||||
);
|
||||
|
||||
if (Platform.isAndroid && useExoPlayer) {
|
||||
_backendSwitchedSubscription = currentPlayer.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||
_playerStreamSubscriptions.add(currentPlayer.streams.backendSwitched.listen((_) => _onBackendSwitched()));
|
||||
}
|
||||
|
||||
_bufferingSubscription = currentPlayer.streams.buffering.listen((isBuffering) {
|
||||
_isBuffering.value = isBuffering;
|
||||
});
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.buffering.listen((isBuffering) {
|
||||
_isBuffering.value = isBuffering;
|
||||
}),
|
||||
);
|
||||
|
||||
// When server comes back online while buffering, force mpv to reconnect
|
||||
// immediately instead of waiting for ffmpeg's exponential backoff.
|
||||
@@ -119,63 +125,69 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
if (serverId != null) {
|
||||
final serverManager = context.read<MultiServerProvider>().serverManager;
|
||||
bool wasOffline = false;
|
||||
_serverStatusSubscription = serverManager.statusStream.listen((statusMap) {
|
||||
final isOnline = statusMap[serverId] == true;
|
||||
if (!isOnline) {
|
||||
wasOffline = true;
|
||||
} else if (wasOffline && (_isBuffering.value || _eofRecovery.parked)) {
|
||||
wasOffline = false;
|
||||
if (_eofRecovery.parked) {
|
||||
// A parked stream is dead server-side; a seek-in-place would
|
||||
// land in the drained cache — only a fresh resolve replaces it.
|
||||
unawaited(_eofRecovery.retry(reason: 'server back online'));
|
||||
} else {
|
||||
_forceStreamReconnect();
|
||||
_playerStreamSubscriptions.add(
|
||||
serverManager.statusStream.listen((statusMap) {
|
||||
final isOnline = statusMap[serverId] == true;
|
||||
if (!isOnline) {
|
||||
wasOffline = true;
|
||||
} else if (wasOffline && (_isBuffering.value || _eofRecovery.parked)) {
|
||||
wasOffline = false;
|
||||
if (_eofRecovery.parked) {
|
||||
// A parked stream is dead server-side; a seek-in-place would
|
||||
// land in the drained cache — only a fresh resolve replaces it.
|
||||
unawaited(_eofRecovery.retry(reason: 'server back online'));
|
||||
} else {
|
||||
_forceStreamReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
_lastLogError = null;
|
||||
_fatalHttpStatuses.clear();
|
||||
_resetLiveLadderOnPlaybackRestart();
|
||||
final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService);
|
||||
_trackManager?.onPlaybackRestart();
|
||||
await markFirstFrameReady;
|
||||
});
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.playbackRestart.listen((_) async {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
_lastLogError = null;
|
||||
_fatalHttpStatuses.clear();
|
||||
_resetLiveLadderOnPlaybackRestart();
|
||||
final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService);
|
||||
_trackManager?.onPlaybackRestart();
|
||||
await markFirstFrameReady;
|
||||
}),
|
||||
);
|
||||
|
||||
_positionSubscription = currentPlayer.streams.position.listen((position) {
|
||||
final activePlayer = player;
|
||||
if (activePlayer == null || activePlayer != currentPlayer) return;
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.position.listen((position) {
|
||||
final activePlayer = player;
|
||||
if (activePlayer == null || activePlayer != currentPlayer) return;
|
||||
|
||||
// Fallback for MPV backends whose playbackRestart event is unavailable.
|
||||
// Android ExoPlayer position can advance on its standalone clock without
|
||||
// a renderer, so it may infer readiness only after switching to MPV.
|
||||
final canInferRenderedFrameFromPosition =
|
||||
!(Platform.isAndroid && useExoPlayer) || (currentPlayer is PlayerAndroid && currentPlayer.usingMpvFallback);
|
||||
if (canInferRenderedFrameFromPosition && !_firstFrame.rendered) {
|
||||
if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) {
|
||||
unawaited(_markFirstFrameReady(currentPlayer, settingsService));
|
||||
// Fallback for MPV backends whose playbackRestart event is unavailable.
|
||||
// Android ExoPlayer position can advance on its standalone clock without
|
||||
// a renderer, so it may infer readiness only after switching to MPV.
|
||||
final canInferRenderedFrameFromPosition =
|
||||
!(Platform.isAndroid && useExoPlayer) || (currentPlayer is PlayerAndroid && currentPlayer.usingMpvFallback);
|
||||
if (canInferRenderedFrameFromPosition && !_firstFrame.rendered) {
|
||||
if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) {
|
||||
unawaited(_markFirstFrameReady(currentPlayer, settingsService));
|
||||
}
|
||||
lastObservedPositionMs = position.inMilliseconds;
|
||||
}
|
||||
lastObservedPositionMs = position.inMilliseconds;
|
||||
}
|
||||
|
||||
// A recovered stream that progressed well past the recovery point
|
||||
// proves the reload worked — restore the full spurious-EOF retry
|
||||
// budget for the next stream death.
|
||||
_eofRecovery.onPositionAdvanced(position.inMilliseconds);
|
||||
// A recovered stream that progressed well past the recovery point
|
||||
// proves the reload worked — restore the full spurious-EOF retry
|
||||
// budget for the next stream death.
|
||||
_eofRecovery.onPositionAdvanced(position.inMilliseconds);
|
||||
|
||||
final duration = activePlayer.state.duration;
|
||||
_episode.completionLatch.classifyPosition(
|
||||
positionMs: position.inMilliseconds,
|
||||
durationMs: duration.inMilliseconds,
|
||||
promptVisible: _episode.showPlayNextDialog,
|
||||
countdownActive: _episode.autoPlayTimer?.isActive == true,
|
||||
);
|
||||
});
|
||||
final duration = activePlayer.state.duration;
|
||||
_episode.completionLatch.classifyPosition(
|
||||
positionMs: position.inMilliseconds,
|
||||
durationMs: duration.inMilliseconds,
|
||||
promptVisible: _episode.showPlayNextDialog,
|
||||
countdownActive: _episode.autoPlayTimer?.isActive == true,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Roll the screen back to a re-runnable state after a failed player
|
||||
@@ -188,9 +200,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
final activePlayer = player;
|
||||
if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return;
|
||||
|
||||
// Rollback scope: the player streams plus the five media-controls
|
||||
// listeners — see [_cancelPlayerStreamSubscriptions] for the ownership
|
||||
// boundary that keeps the sleep-timer and Apple TV subscriptions alive.
|
||||
// Rollback scope: the player streams plus the media-controls listeners —
|
||||
// see [_cancelPlayerStreamSubscriptions] for the ownership boundary that
|
||||
// keeps the sleep-timer and Apple TV subscriptions alive.
|
||||
final cancellationFutures = _cancelPlayerStreamSubscriptions(includeMediaControls: true);
|
||||
try {
|
||||
await Future.wait(cancellationFutures);
|
||||
@@ -481,24 +493,26 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
);
|
||||
|
||||
// Set up media control event handling
|
||||
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
|
||||
if (_mediaControls.suspendedForTvBackground) {
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
|
||||
return;
|
||||
}
|
||||
_mediaControlSubscriptions.add(
|
||||
mediaControlsManager.controlEvents.listen((event) {
|
||||
if (_mediaControls.suspendedForTvBackground) {
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAppleAudioSessionEvent(event)) {
|
||||
unawaited(_handleAppleAudioSessionEvent(event));
|
||||
return;
|
||||
}
|
||||
if (_isAppleAudioSessionEvent(event)) {
|
||||
unawaited(_handleAppleAudioSessionEvent(event));
|
||||
return;
|
||||
}
|
||||
|
||||
if (PlatformDetector.isAppleTV() && _isPlaybackMediaControlEvent(event)) {
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored on Apple TV; using native remote bridge');
|
||||
return;
|
||||
}
|
||||
if (PlatformDetector.isAppleTV() && _isPlaybackMediaControlEvent(event)) {
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored on Apple TV; using native remote bridge');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaControlRouter.route(event);
|
||||
});
|
||||
mediaControlRouter.route(event);
|
||||
}),
|
||||
);
|
||||
|
||||
// Wire progress tracker, media-controls metadata, and the
|
||||
// Discord/Trakt/Tracker scrobblers. Shared with [_reloadMediaInPlace]
|
||||
@@ -518,33 +532,41 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
if (!mounted || player != currentPlayer || _mediaControlsManager != mediaControlsManager) return;
|
||||
|
||||
// Listen to playing state and update media controls
|
||||
_mediaControlsPlayingSubscription = currentPlayer.streams.playing.listen((isPlaying) {
|
||||
_mediaControls.pushPlaybackState();
|
||||
});
|
||||
_mediaControlSubscriptions.add(
|
||||
currentPlayer.streams.playing.listen((isPlaying) {
|
||||
_mediaControls.pushPlaybackState();
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen to position updates for media controls and Discord
|
||||
_mediaControlsPositionSubscription = currentPlayer.streams.position.listen((position) {
|
||||
mediaControlsManager.updatePlaybackState(
|
||||
isPlaying: currentPlayer.state.isActive,
|
||||
position: position,
|
||||
speed: currentPlayer.state.rate,
|
||||
);
|
||||
DiscordRPCService.instance.updatePosition(position);
|
||||
TrackerCoordinator.instance.updatePosition(position);
|
||||
// Keep the trackers' known duration current — mpv only emits on the
|
||||
// duration stream once per load, but this is cheap and avoids an extra
|
||||
// listener.
|
||||
TrackerCoordinator.instance.updateDuration(currentPlayer.state.duration);
|
||||
});
|
||||
_mediaControlSubscriptions.add(
|
||||
currentPlayer.streams.position.listen((position) {
|
||||
mediaControlsManager.updatePlaybackState(
|
||||
isPlaying: currentPlayer.state.isActive,
|
||||
position: position,
|
||||
speed: currentPlayer.state.rate,
|
||||
);
|
||||
DiscordRPCService.instance.updatePosition(position);
|
||||
TrackerCoordinator.instance.updatePosition(position);
|
||||
// Keep the trackers' known duration current — mpv only emits on the
|
||||
// duration stream once per load, but this is cheap and avoids an extra
|
||||
// listener.
|
||||
TrackerCoordinator.instance.updateDuration(currentPlayer.state.duration);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen to playback rate changes for Discord Rich Presence
|
||||
_mediaControlsRateSubscription = currentPlayer.streams.rate.listen((rate) {
|
||||
DiscordRPCService.instance.updatePlaybackSpeed(rate);
|
||||
});
|
||||
_mediaControlSubscriptions.add(
|
||||
currentPlayer.streams.rate.listen((rate) {
|
||||
DiscordRPCService.instance.updatePlaybackSpeed(rate);
|
||||
}),
|
||||
);
|
||||
|
||||
_mediaControlsSeekableSubscription = currentPlayer.streams.seekable.listen((_) {
|
||||
unawaited(_mediaControls.syncAvailability());
|
||||
});
|
||||
_mediaControlSubscriptions.add(
|
||||
currentPlayer.streams.seekable.listen((_) {
|
||||
unawaited(_mediaControls.syncAvailability());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPlayingStateChanged(bool isPlaying) {
|
||||
|
||||
@@ -73,7 +73,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
_live.markStreamRestartedAtLiveEdge();
|
||||
}
|
||||
|
||||
await currentPlayer.setProperty('force-seekable', 'no');
|
||||
await _setLiveStreamOptions(currentPlayer);
|
||||
await currentPlayer.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: !PlatformDetector.isAutomotive(),
|
||||
|
||||
@@ -492,70 +492,35 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void>? _audioFocusFuture;
|
||||
late final String _playbackSessionIdentifier;
|
||||
late String _playbackTranscodeSessionId;
|
||||
StreamSubscription<PlayerError>? _errorSubscription;
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
StreamSubscription<dynamic>? _mediaControlSubscription;
|
||||
|
||||
/// Player-driven listeners re-created by [_wirePlayerStreams] on every
|
||||
/// player (re)wire.
|
||||
final List<StreamSubscription<dynamic>> _playerStreamSubscriptions = [];
|
||||
|
||||
/// Media-controls listeners created once per attempt by [_initializeServices].
|
||||
final List<StreamSubscription<dynamic>> _mediaControlSubscriptions = [];
|
||||
StreamSubscription<AppleTvRemotePlayPauseAction>? _appleTvPlayPauseSubscription;
|
||||
StreamSubscription<bool>? _bufferingSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<void>? _playbackRestartSubscription;
|
||||
StreamSubscription<void>? _backendSwitchedSubscription;
|
||||
TrackManager? _trackManager;
|
||||
StreamSubscription<PlayerLog>? _logSubscription;
|
||||
StreamSubscription<void>? _sleepTimerSubscription;
|
||||
StreamSubscription<bool>? _mediaControlsPlayingSubscription;
|
||||
StreamSubscription<Duration>? _mediaControlsPositionSubscription;
|
||||
StreamSubscription<double>? _mediaControlsRateSubscription;
|
||||
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
|
||||
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
|
||||
bool _isHandlingBack = false;
|
||||
|
||||
/// Cancel-and-null scope for the screen's player-driven stream
|
||||
/// subscriptions — the single authority consumed by [_wirePlayerStreams]
|
||||
/// (re-wire: the nine player streams), [_tearDownFailedPlayerAttempt]
|
||||
/// (rollback: player streams plus the five media-controls listeners created
|
||||
/// in [_initializeServices]), and the screen's `dispose`. The
|
||||
/// initState-owned `_sleepTimerSubscription` and
|
||||
/// `_appleTvPlayPauseSubscription` are deliberately excluded: cancelling
|
||||
/// them on a re-wire or rollback would kill the sleep-timer prompt and the
|
||||
/// Apple TV remote for the rest of the screen's life.
|
||||
/// Cancel scope for the screen's player-driven stream subscriptions — the
|
||||
/// single authority consumed by [_wirePlayerStreams] (re-wire:
|
||||
/// [_playerStreamSubscriptions]), [_tearDownFailedPlayerAttempt] (rollback:
|
||||
/// player streams plus the [_mediaControlSubscriptions] created in
|
||||
/// [_initializeServices]), and the screen's `dispose`. The initState-owned
|
||||
/// `_sleepTimerSubscription` and `_appleTvPlayPauseSubscription` are
|
||||
/// deliberately excluded: cancelling them on a re-wire or rollback would
|
||||
/// kill the sleep-timer prompt and the Apple TV remote for the rest of the
|
||||
/// screen's life.
|
||||
List<Future<void>> _cancelPlayerStreamSubscriptions({required bool includeMediaControls}) {
|
||||
final cancellations = <Future<void>>[
|
||||
?_playingSubscription?.cancel(),
|
||||
?_completedSubscription?.cancel(),
|
||||
?_errorSubscription?.cancel(),
|
||||
?_logSubscription?.cancel(),
|
||||
?_backendSwitchedSubscription?.cancel(),
|
||||
?_bufferingSubscription?.cancel(),
|
||||
?_serverStatusSubscription?.cancel(),
|
||||
?_playbackRestartSubscription?.cancel(),
|
||||
?_positionSubscription?.cancel(),
|
||||
if (includeMediaControls) ...[
|
||||
?_mediaControlSubscription?.cancel(),
|
||||
?_mediaControlsPlayingSubscription?.cancel(),
|
||||
?_mediaControlsPositionSubscription?.cancel(),
|
||||
?_mediaControlsRateSubscription?.cancel(),
|
||||
?_mediaControlsSeekableSubscription?.cancel(),
|
||||
],
|
||||
];
|
||||
_playingSubscription = null;
|
||||
_completedSubscription = null;
|
||||
_errorSubscription = null;
|
||||
_logSubscription = null;
|
||||
_backendSwitchedSubscription = null;
|
||||
_bufferingSubscription = null;
|
||||
_serverStatusSubscription = null;
|
||||
_playbackRestartSubscription = null;
|
||||
_positionSubscription = null;
|
||||
final subscriptions = List<StreamSubscription<dynamic>>.of(_playerStreamSubscriptions);
|
||||
_playerStreamSubscriptions.clear();
|
||||
if (includeMediaControls) {
|
||||
_mediaControlSubscription = null;
|
||||
_mediaControlsPlayingSubscription = null;
|
||||
_mediaControlsPositionSubscription = null;
|
||||
_mediaControlsRateSubscription = null;
|
||||
_mediaControlsSeekableSubscription = null;
|
||||
subscriptions.addAll(_mediaControlSubscriptions);
|
||||
_mediaControlSubscriptions.clear();
|
||||
}
|
||||
return cancellations;
|
||||
return [for (final subscription in subscriptions) subscription.cancel()];
|
||||
}
|
||||
|
||||
/// Set just before this screen replaces itself with another player route
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'skeleton_media_card.dart';
|
||||
import 'sliver_child_memo.dart';
|
||||
|
||||
/// Global per-frame budget for inflating fresh media cards while scrolling.
|
||||
///
|
||||
/// Inflating a card (build + first layout + first paint) costs ~8ms on
|
||||
@@ -73,4 +76,30 @@ mixin SkeletonUpgradeScheduler<T extends StatefulWidget> on State<T> {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
/// Memoized card for [index], or a budgeted [skeleton] when [memo] misses
|
||||
/// while an enclosing scrollable is moving and this frame's
|
||||
/// [CardInflationBudget] is spent (the skeleton upgrades a frame later).
|
||||
/// Keyboard mode is exempt — skeletons aren't focus targets.
|
||||
Widget realizeBudgeted<I extends Object>(
|
||||
SliverChildMemo<I> memo,
|
||||
BuildContext context,
|
||||
int index,
|
||||
I item, {
|
||||
required Object epoch,
|
||||
Object? salt,
|
||||
required bool keyboardMode,
|
||||
required Widget Function() build,
|
||||
Widget Function() skeleton = _plainSkeleton,
|
||||
}) {
|
||||
final cached = memo.tryGet(index, item, epoch: epoch, salt: salt);
|
||||
if (cached != null) return cached;
|
||||
if (!keyboardMode && CardInflationBudget.isScrollingContext(context) && !CardInflationBudget.tryTake()) {
|
||||
scheduleSkeletonUpgrade();
|
||||
return skeleton();
|
||||
}
|
||||
return memo.widgetFor(index, item, epoch: epoch, salt: salt, build: build);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _plainSkeleton() => const SkeletonMediaCard();
|
||||
|
||||
@@ -715,29 +715,24 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
|
||||
final item = widget.hub.items[index];
|
||||
|
||||
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch, salt: isItemFocused);
|
||||
if (cached != null) return cached;
|
||||
// Budget fresh inflations while an enclosing
|
||||
// scrollable is moving (rows enter on the parent's
|
||||
// vertical scroll); skeletons upgrade a frame
|
||||
// later. Keyboard mode is exempt — skeletons
|
||||
// aren't focus targets.
|
||||
if (!isKeyboardMode &&
|
||||
CardInflationBudget.isScrollingContext(context) &&
|
||||
!CardInflationBudget.tryTake()) {
|
||||
scheduleSkeletonUpgrade();
|
||||
return Padding(
|
||||
padding: cardPadding,
|
||||
child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()),
|
||||
);
|
||||
}
|
||||
return _cardMemo.widgetFor(
|
||||
// later.
|
||||
return realizeBudgeted(
|
||||
_cardMemo,
|
||||
context,
|
||||
index,
|
||||
item,
|
||||
epoch: cardEpoch,
|
||||
// Focus moves only rebuild the two affected
|
||||
// indices instead of the whole realized row.
|
||||
salt: isItemFocused,
|
||||
keyboardMode: isKeyboardMode,
|
||||
skeleton: () => Padding(
|
||||
padding: cardPadding,
|
||||
child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()),
|
||||
),
|
||||
build: () => Padding(
|
||||
key: _itemKeyFor(index),
|
||||
padding: cardPadding,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/widgets/card_inflation_budget.dart';
|
||||
import 'package:plezy/widgets/sliver_child_memo.dart';
|
||||
|
||||
class _UpgradeHost extends StatefulWidget {
|
||||
const _UpgradeHost();
|
||||
@@ -24,6 +28,73 @@ class _UpgradeHostState extends State<_UpgradeHost> with SkeletonUpgradeSchedule
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox();
|
||||
}
|
||||
|
||||
class _Skeleton extends StatelessWidget {
|
||||
const _Skeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox();
|
||||
}
|
||||
|
||||
/// A 100px-per-item list whose cards go through [realizeBudgeted]. With a
|
||||
/// 600px test viewport and no cache extent exactly six items are realized
|
||||
/// per screen, so a 300px scroll lets three fresh items enter.
|
||||
class _BudgetedList extends StatefulWidget {
|
||||
const _BudgetedList({required this.controller, required this.keyboardMode});
|
||||
|
||||
final ScrollController controller;
|
||||
final bool keyboardMode;
|
||||
|
||||
@override
|
||||
State<_BudgetedList> createState() => _BudgetedListState();
|
||||
}
|
||||
|
||||
class _BudgetedListState extends State<_BudgetedList> with SkeletonUpgradeScheduler {
|
||||
static final List<Object> _items = List.generate(30, (_) => Object());
|
||||
final SliverChildMemo<Object> _memo = SliverChildMemo<Object>();
|
||||
int cardBuilds = 0;
|
||||
int skeletonBuilds = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: ListView.builder(
|
||||
controller: widget.controller,
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(0),
|
||||
itemExtent: 100,
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (context, index) => realizeBudgeted(
|
||||
_memo,
|
||||
context,
|
||||
index,
|
||||
_items[index],
|
||||
epoch: 0,
|
||||
keyboardMode: widget.keyboardMode,
|
||||
build: () {
|
||||
cardBuilds++;
|
||||
return const _Card();
|
||||
},
|
||||
skeleton: () {
|
||||
skeletonBuilds++;
|
||||
return const _Skeleton();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _exhaustBudget() {
|
||||
while (CardInflationBudget.tryTake()) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(CardInflationBudget.reset);
|
||||
|
||||
@@ -59,4 +130,68 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(state.builds, 3);
|
||||
});
|
||||
|
||||
group('realizeBudgeted', () {
|
||||
Future<_BudgetedListState> pumpList(
|
||||
WidgetTester tester,
|
||||
ScrollController controller, {
|
||||
bool keyboardMode = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(_BudgetedList(controller: controller, keyboardMode: keyboardMode));
|
||||
return tester.state<_BudgetedListState>(find.byType(_BudgetedList));
|
||||
}
|
||||
|
||||
/// Advances a 600px scroll animation to its midpoint (300px) with the
|
||||
/// frame budget already spent, so the three items entering the viewport
|
||||
/// are built while the scrollable still reports itself as scrolling.
|
||||
Future<void> scrollMidAnimationWithSpentBudget(WidgetTester tester, ScrollController controller) async {
|
||||
unawaited(controller.animateTo(600, duration: const Duration(milliseconds: 100), curve: Curves.linear));
|
||||
await tester.pump();
|
||||
_exhaustBudget();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(controller.offset, 300);
|
||||
}
|
||||
|
||||
testWidgets('ignores a spent budget when nothing is scrolling', (tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
_exhaustBudget();
|
||||
|
||||
final state = await pumpList(tester, controller);
|
||||
|
||||
expect(state.cardBuilds, 6);
|
||||
expect(state.skeletonBuilds, 0);
|
||||
});
|
||||
|
||||
testWidgets('emits skeletons for fresh items entering during a scroll, then upgrades them', (tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
final state = await pumpList(tester, controller);
|
||||
state.cardBuilds = 0;
|
||||
|
||||
await scrollMidAnimationWithSpentBudget(tester, controller);
|
||||
expect(state.skeletonBuilds, 3);
|
||||
expect(state.cardBuilds, 0);
|
||||
expect(find.byType(_Skeleton), findsNWidgets(3));
|
||||
|
||||
// The upgrade chain realizes every skeleton once the scroll settles;
|
||||
// indices 6..11 end up on screen, each built as a card exactly once.
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(_Skeleton), findsNothing);
|
||||
expect(find.byType(_Card), findsNWidgets(6));
|
||||
expect(state.cardBuilds, 6);
|
||||
});
|
||||
|
||||
testWidgets('never budgets in keyboard mode', (tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
final state = await pumpList(tester, controller, keyboardMode: true);
|
||||
state.cardBuilds = 0;
|
||||
|
||||
await scrollMidAnimationWithSpentBudget(tester, controller);
|
||||
expect(state.skeletonBuilds, 0);
|
||||
expect(state.cardBuilds, 3);
|
||||
expect(find.byType(_Skeleton), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user