diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 7c14cee33..c619bc3e4 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -2201,18 +2201,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( index, isFirstRow: position.isFirstRow, diff --git a/lib/screens/libraries/tabs/paginated_card_grid_tab.dart b/lib/screens/libraries/tabs/paginated_card_grid_tab.dart index fa3542724..7fa62a575 100644 --- a/lib/screens/libraries/tabs/paginated_card_grid_tab.dart +++ b/lib/screens/libraries/tabs/paginated_card_grid_tab.dart @@ -128,18 +128,13 @@ abstract class PaginatedCardGridTabState _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), ); }, diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index bb8d9d056..25f21aa75 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -220,6 +220,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { /// reconnection is handled by the server's transcoder on the input side. Future _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 _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; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 93460f38e..33e3cea39 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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().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) { diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 31c00c794..551e1dbb6 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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(), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6a5538a01..ff2d900ad 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -492,70 +492,35 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future? _audioFocusFuture; late final String _playbackSessionIdentifier; late String _playbackTranscodeSessionId; - StreamSubscription? _errorSubscription; - StreamSubscription? _playingSubscription; - StreamSubscription? _completedSubscription; - StreamSubscription? _mediaControlSubscription; + + /// Player-driven listeners re-created by [_wirePlayerStreams] on every + /// player (re)wire. + final List> _playerStreamSubscriptions = []; + + /// Media-controls listeners created once per attempt by [_initializeServices]. + final List> _mediaControlSubscriptions = []; StreamSubscription? _appleTvPlayPauseSubscription; - StreamSubscription? _bufferingSubscription; - StreamSubscription? _positionSubscription; - StreamSubscription? _playbackRestartSubscription; - StreamSubscription? _backendSwitchedSubscription; TrackManager? _trackManager; - StreamSubscription? _logSubscription; StreamSubscription? _sleepTimerSubscription; - StreamSubscription? _mediaControlsPlayingSubscription; - StreamSubscription? _mediaControlsPositionSubscription; - StreamSubscription? _mediaControlsRateSubscription; - StreamSubscription? _mediaControlsSeekableSubscription; - StreamSubscription>? _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> _cancelPlayerStreamSubscriptions({required bool includeMediaControls}) { - final cancellations = >[ - ?_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>.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 diff --git a/lib/widgets/card_inflation_budget.dart b/lib/widgets/card_inflation_budget.dart index 8a8390196..f7e2cf857 100644 --- a/lib/widgets/card_inflation_budget.dart +++ b/lib/widgets/card_inflation_budget.dart @@ -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 on State { 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( + SliverChildMemo 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(); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 022e672c5..23838011b 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -715,29 +715,24 @@ class HubSectionState extends State 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, diff --git a/test/widgets/card_inflation_budget_test.dart b/test/widgets/card_inflation_budget_test.dart index e9d2f1715..ecdb826b1 100644 --- a/test/widgets/card_inflation_budget_test.dart +++ b/test/widgets/card_inflation_budget_test.dart @@ -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 _items = List.generate(30, (_) => Object()); + final SliverChildMemo _memo = SliverChildMemo(); + 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 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); + }); + }); }