diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 50bdb8a5..ddb44c20 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -169,8 +169,10 @@ class _FocusableWrapperState extends State with SingleTickerPr bool _ownsNode = false; bool _isFocused = false; - late final AnimationController _animationController; - late Animation _scaleAnimation; + // Created lazily on first focus/keyboard-mode build: touch scrolling builds + // hundreds of these wrappers and must not pay for a Ticker per card. + AnimationController? _animationController; + Animation? _scaleAnimation; // Long-press detection for SELECT key Timer? _longPressTimer; @@ -180,7 +182,6 @@ class _FocusableWrapperState extends State with SingleTickerPr void initState() { super.initState(); _initFocusNode(); - _initAnimations(); } void _initFocusNode() { @@ -196,17 +197,20 @@ class _FocusableWrapperState extends State with SingleTickerPr } } - void _initAnimations() { - _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); - - _scaleAnimation = _createScaleAnimation(); + AnimationController _ensureAnimationController() { + final existing = _animationController; + if (existing != null) return existing; + final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); + _animationController = controller; + _scaleAnimation = _createScaleAnimation(controller); + return controller; } - Animation _createScaleAnimation() { + Animation _createScaleAnimation(AnimationController controller) { return Tween( begin: 1.0, end: widget.focusScale, - ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); + ).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutCubic)); } @override @@ -227,14 +231,17 @@ class _FocusableWrapperState extends State with SingleTickerPr } if (widget.focusScale != oldWidget.focusScale) { - _scaleAnimation = _createScaleAnimation(); + final controller = _animationController; + if (controller != null) { + _scaleAnimation = _createScaleAnimation(controller); + } } } @override void dispose() { _longPressTimer?.cancel(); - _animationController.dispose(); + _animationController?.dispose(); if (_ownsNode) { _focusNode.dispose(); } @@ -255,9 +262,9 @@ class _FocusableWrapperState extends State with SingleTickerPr // Animate scale if (hasFocus) { - _animationController.forward(); + _ensureAnimationController().forward(); } else { - _animationController.reverse(); + _animationController?.reverse(); } // Auto-scroll into view @@ -466,23 +473,28 @@ class _FocusableWrapperState extends State with SingleTickerPr @override Widget build(BuildContext context) { - final duration = FocusTheme.getAnimationDuration(context); - // Only show focus effects during keyboard/d-pad navigation - final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + // Only show focus effects during keyboard/d-pad navigation. In pointer/ + // touch mode no card ever shows focus chrome, so skip the animated + // scale/border wrappers entirely — they cost real build time multiplied + // by every card in a grid. The Focus node stays mounted so d-pad + // traversal finds the cards the moment keyboard mode activates (which + // rebuilds this widget via the inherited dependency below). + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final showFocus = _isFocused && isKeyboardMode; - // Update animation duration if theme changes - if (_animationController.duration != duration) { - _animationController.duration = duration; - } + Widget inner; + if (!isKeyboardMode) { + inner = widget.child; + } else { + final duration = FocusTheme.getAnimationDuration(context); + final controller = _ensureAnimationController(); + // Update animation duration if theme changes + if (controller.duration != duration) { + controller.duration = duration; + } - Widget result = Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - descendantsAreFocusable: widget.descendantsAreFocusable, - onFocusChange: _handleFocusChange, - onKeyEvent: _handleKeyEvent, - child: AnimatedBuilder( - animation: _scaleAnimation, + inner = AnimatedBuilder( + animation: _scaleAnimation!, builder: (context, child) { final shouldScale = showFocus && !widget.disableScale; // The glow (full-bleed cards) is drawn in an overlay above siblings so @@ -519,9 +531,18 @@ class _FocusableWrapperState extends State with SingleTickerPr child: card, ); } - return Transform.scale(scale: shouldScale ? _scaleAnimation.value : 1.0, child: card); + return Transform.scale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: card); }, - ), + ); + } + + Widget result = Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + descendantsAreFocusable: widget.descendantsAreFocusable, + onFocusChange: _handleFocusChange, + onKeyEvent: _handleKeyEvent, + child: inner, ); // Add semantics if label provided diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1ac78603..2c001beb 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -57,7 +57,9 @@ import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/deletion_aware.dart'; import '../../../mixins/paginated_item_loader.dart'; +import '../../../widgets/card_inflation_budget.dart'; import '../../../widgets/skeleton_media_card.dart'; +import '../../../widgets/sliver_child_memo.dart'; import '../../../utils/deletion_notifier.dart'; import '../../../utils/global_key_utils.dart'; import '../../../utils/watch_state_notifier.dart'; @@ -104,7 +106,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { + PaginatedItemLoader, + SkeletonUpgradeScheduler { @override String? get itemServerId => widget.library.serverId; @@ -249,6 +252,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> _jellyfinFilterValues = const {}; final ValueNotifier _currentFirstVisibleIndex = ValueNotifier(0); LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty; + + /// Reuses card widgets across delegate swaps so tab-level setStates + /// (pagination, watch state, deletions) don't rebuild every realized card + /// inside grid layout. + final SliverChildMemo _cardMemo = SliverChildMemo(); + + /// Shared by focus-node eviction and card-memo pruning so the memo can + /// never outlive the focus nodes its cached cards capture. + static const int _focusNodeKeepCount = 200; double _effectiveTopPadding = _gridTopPadding; final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item'); double? _measuredListRowHeight; @@ -684,6 +696,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( + index, + isFirstRow: index == 0, + isFirstColumn: true, // List view = single column + isLastColumn: true, + disableScale: true, + columnCount: 1, + itemCount: itemCount, + ), ); return index == 0 ? _buildMeasuredFirstListItem(child) : child; }, @@ -1822,6 +1849,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( - index, - isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), - isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), - isLastColumn: (index % columnCount) == (columnCount - 1), - columnCount: columnCount, - itemCount: itemCount, - fullBleedImage: fullCardLayout, - ), + itemBuilder: (context, index) { + final item = loadedItems[index]; + if (item == null) { + _scheduleRangeLoad(); + return const SkeletonMediaCard(); + } + final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch); + if (cached != null) return cached; + // Fresh inflation. While the grid is actually scrolling in + // pointer/touch mode, respect the global per-frame budget: + // over-budget cards render as skeletons and upgrade a frame + // later, so a row entering the viewport can't drop a frame. + // Keyboard/d-pad mode is exempt — skeletons aren't focusable + // and would break traversal; idle fills stay instant. + if (CardInflationBudget.isScrollingContext(context) && + !InputModeTracker.isKeyboardMode(context) && + !CardInflationBudget.tryTake()) { + scheduleSkeletonUpgrade(); + return const SkeletonMediaCard(); + } + return _cardMemo.widgetFor( + index, + item, + epoch: cardEpoch, + build: () => _buildMediaCardItem( + index, + isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + isLastColumn: (index % columnCount) == (columnCount - 1), + columnCount: columnCount, + itemCount: itemCount, + fullBleedImage: fullCardLayout, + ), + ); + }, ); }, ), diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 43bd7829..caa41bea 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../focus/input_mode_tracker.dart'; import '../../../media/library_query.dart'; import '../../../media/media_item.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; @@ -11,10 +12,12 @@ import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; import '../../../utils/platform_detector.dart'; +import '../../../widgets/card_inflation_budget.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/settings_builder.dart'; import '../../../widgets/skeleton_media_card.dart'; +import '../../../widgets/sliver_child_memo.dart'; import '../../../widgets/sliver_cross_axis_layout_builder.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; @@ -39,9 +42,16 @@ class LibraryCollectionsTab extends BaseLibraryTab { } class _LibraryCollectionsTabState extends BaseLibraryTabState - with LibraryTabFocusMixin, PaginatedItemLoader { + with + LibraryTabFocusMixin, + PaginatedItemLoader, + SkeletonUpgradeScheduler { static const int _pageSize = 36; + /// Reuses card widgets across delegate swaps so tab-level setStates + /// (pagination, refreshes) don't rebuild every realized card inside layout. + final SliverChildMemo _cardMemo = SliverChildMemo(); + @override String get focusNodeDebugLabel => 'collections_first_item'; @@ -144,8 +154,19 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState - _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true), + itemBuilder: (context, index) { + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: _pageSize); + return const SkeletonMediaCard(); + } + return _cardMemo.widgetFor( + index, + item, + epoch: (ViewMode.list, totalSize, density), + build: () => _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true), + ); + }, ), ); } @@ -161,6 +182,8 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState _buildMediaCardItem( - index, - isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount), - isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount), - fullBleedImage: fullCardLayout, - ), + itemBuilder: (context, index) { + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: _pageSize); + return const SkeletonMediaCard(); + } + final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch); + if (cached != null) return cached; + // Budget fresh inflations while scrolling in pointer/touch mode + // (see CardInflationBudget); skeletons upgrade a frame later. + if (CardInflationBudget.isScrollingContext(context) && + !InputModeTracker.isKeyboardMode(context) && + !CardInflationBudget.tryTake()) { + scheduleSkeletonUpgrade(); + return const SkeletonMediaCard(); + } + return _cardMemo.widgetFor( + index, + item, + epoch: cardEpoch, + build: () => _buildMediaCardItem( + index, + isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount), + fullBleedImage: fullCardLayout, + ), + ); + }, ); }, ), diff --git a/lib/widgets/card_inflation_budget.dart b/lib/widgets/card_inflation_budget.dart new file mode 100644 index 00000000..8a839019 --- /dev/null +++ b/lib/widgets/card_inflation_budget.dart @@ -0,0 +1,76 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +/// Global per-frame budget for inflating fresh media cards while scrolling. +/// +/// Inflating a card (build + first layout + first paint) costs ~8ms on +/// low-end hardware, and a grid row entering the viewport inflates a whole +/// row of them in one frame — a guaranteed dropped frame. Callers ask +/// [tryTake] for a slot before inflating a *new* card during an active +/// scroll; when the budget is spent they render a [SkeletonMediaCard] +/// instead and upgrade it on a following frame (see +/// [SkeletonUpgradeScheduler]). +/// +/// The budget is global, not per-list, so several hub rows entering in the +/// same frame share one cap instead of multiplying it. Cards that are +/// already built (memo hits) never consume a slot. +abstract final class CardInflationBudget { + /// One fresh card per frame: a card costs ~8ms and an upgrade frame also + /// pays the delegate walk, so two would already blow a 60Hz budget on the + /// devices this exists for. Typical fling entry rate on a 3-column grid is + /// under one card per frame, so the backlog stays near zero. + static const int maxPerFrame = 1; + + static int _taken = 0; + static bool _resetScheduled = false; + + /// Claims an inflation slot for the current frame. Returns false when the + /// frame's budget is already spent. + static bool tryTake() { + if (_taken >= maxPerFrame) return false; + _taken++; + if (!_resetScheduled) { + _resetScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _resetScheduled = false; + _taken = 0; + }); + } + return true; + } + + @visibleForTesting + static void reset() { + _taken = 0; + _resetScheduled = false; + } + + /// Whether an enclosing scrollable is actively scrolling — the condition + /// under which fresh inflations should be budgeted. Checks the nearest + /// scrollable and the nearest vertical one: a card in a horizontal hub row + /// enters either because its own row scrolls or because the vertical list + /// carrying the row does. + static bool isScrollingContext(BuildContext context) { + if (Scrollable.maybeOf(context)?.position.isScrollingNotifier.value ?? false) { + return true; + } + return Scrollable.maybeOf(context, axis: Axis.vertical)?.position.isScrollingNotifier.value ?? false; + } +} + +/// Re-arms a post-frame rebuild while budgeted skeletons are pending, so +/// every skeleton is upgraded to its real card within a frame or two of the +/// budget freeing up. The chain stops by itself: a build that emits no +/// skeleton schedules nothing. +mixin SkeletonUpgradeScheduler on State { + bool _skeletonUpgradeScheduled = false; + + void scheduleSkeletonUpgrade() { + if (_skeletonUpgradeScheduled) return; + _skeletonUpgradeScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _skeletonUpgradeScheduled = false; + if (mounted) setState(() {}); + }); + } +} diff --git a/lib/widgets/clickable_cursor.dart b/lib/widgets/clickable_cursor.dart index 1f5ed9ac..892ce477 100644 --- a/lib/widgets/clickable_cursor.dart +++ b/lib/widgets/clickable_cursor.dart @@ -10,9 +10,10 @@ class ClickableCursor extends StatelessWidget { @override Widget build(BuildContext context) { - // No pointer on TV: skip the MouseRegion entirely — one exists per card - // and they add up on low-end devices. - if (PlatformDetector.isTV()) return child; + // Cursor feedback only matters where a pointer exists: skip the + // MouseRegion on TV and touch handhelds — one exists per card and they + // add up on low-end devices. + if (!PlatformDetector.isDesktopOS()) return child; return MouseRegion(cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, child: child); } } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index ae5bb257..fd78ecf1 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -20,8 +21,11 @@ import '../media/media_item.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; +import 'card_inflation_budget.dart'; import 'focus_builders.dart'; import 'media_card.dart'; +import 'skeleton_media_card.dart'; +import 'sliver_child_memo.dart'; import '../utils/scroll_utils.dart'; import 'horizontal_scroll_with_arrows.dart'; import '../i18n/strings.g.dart'; @@ -92,7 +96,7 @@ class HubSection extends StatefulWidget { State createState() => HubSectionState(); } -class HubSectionState extends State with MountedSetStateMixin { +class HubSectionState extends State with MountedSetStateMixin, SkeletonUpgradeScheduler { static const _longPressDuration = Duration(milliseconds: 500); late FocusNode _hubFocusNode; @@ -101,6 +105,10 @@ class HubSectionState extends State with MountedSetStateMixin { /// Current visual focus index (not tied to Flutter's focus system) int _focusedIndex = 0; + /// Reuses card widgets across rebuilds (parent setStates, focus moves) so + /// only changed indices rebuild instead of every realized card in the row. + final SliverChildMemo _cardMemo = SliverChildMemo(); + double _itemExtent = 0; double _leadingPaddingFor(bool isTv) => widget.inset ? 0.0 @@ -498,6 +506,19 @@ class HubSectionState extends State with MountedSetStateMixin { final focusExtra = focusBorderWidth * 2; // border on both sides _itemExtent = cardWidth + focusExtra + 4; + // Everything the card closures capture; a change flushes + // the memo so cached cards can't carry stale geometry. + final cardEpoch = ( + cardWidth, + posterHeight, + useWideLayout, + isMixedHub, + isKeyboardMode, + widget.inset, + widget.isInContinueWatching, + widget.usesContinueWatchingAction, + ); + return SizedBox( height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom child: HorizontalScrollWithArrows( @@ -510,6 +531,13 @@ class HubSectionState extends State with MountedSetStateMixin { controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, + // On touch, don't pre-inflate off-screen cards when a row + // enters the viewport — the default 250px realizes 2+ extra + // cards per side in the same frame, fattening the row-entry + // spike. Cards inflate as they scroll in instead (a few ms + // each). TV keeps the default: d-pad animateTo benefits from + // the prefetch and TV rows inflate via the focus path anyway. + scrollCacheExtent: isTv ? null : const ScrollCacheExtent.pixels(0), padding: widget.inset ? EdgeInsets.symmetric(vertical: isTv ? 6 : 2) : EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2), @@ -560,28 +588,61 @@ class HubSectionState extends State with MountedSetStateMixin { final item = widget.hub.items[index]; - return Padding( - key: _itemKeyFor(index), - padding: widget.inset - ? const EdgeInsets.only(right: 4) - : const EdgeInsets.symmetric(horizontal: 2), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isItemFocused, - onTap: () => _onItemTapped(index), - onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), - delegateFocusBorder: true, - child: MediaCard( - key: _getMediaCardKey(index), - item: item, - width: cardWidth, - height: posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: widget.isInContinueWatching, - usesContinueWatchingAction: widget.usesContinueWatchingAction, - mixedHubContext: isMixedHub, + 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: widget.inset + ? const EdgeInsets.only(right: 4) + : const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()), + ); + } + return _cardMemo.widgetFor( + index, + item, + epoch: cardEpoch, + // Focus moves only rebuild the two affected + // indices instead of the whole realized row. + salt: isItemFocused, + build: () => Padding( + key: _itemKeyFor(index), + padding: widget.inset + ? const EdgeInsets.only(right: 4) + : const EdgeInsets.symmetric(horizontal: 2), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isItemFocused, + // Pointer/touch taps never reach these: MediaCard's own + // tap region is deeper in the tree and always wins the + // gesture arena. Passing null lets the wrapper collapse + // to the bare card outside keyboard mode instead of + // building a second dead gesture-detector stack per card. + onTap: isKeyboardMode ? () => _onItemTapped(index) : null, + onLongPress: isKeyboardMode + ? () => _mediaCardKeys[index]?.currentState?.showContextMenu() + : null, + delegateFocusBorder: true, + child: MediaCard( + key: _getMediaCardKey(index), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isInContinueWatching, + usesContinueWatchingAction: widget.usesContinueWatchingAction, + mixedHubContext: isMixedHub, + ), ), ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f0f4fa3c..a0999c81 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -976,9 +976,11 @@ class SkeletonLoader extends StatelessWidget { } } -/// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) where a -/// pointer exists, a bare [GestureDetector] on TV where the d-pad drives -/// activation and the per-card ink/hover/focus machinery is dead weight. +/// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) on +/// desktop where hover feedback matters, a bare [GestureDetector] on TV and +/// touch handhelds — the ripple is invisible under poster art and the +/// per-card ink/hover/focus machinery (~15 elements each) is dead weight +/// that adds up while scrolling card grids. /// Keyboard focus is handled by the focus wrappers either way /// (canRequestFocus stays false on the InkWell). class _CardTapRegion extends StatelessWidget { @@ -1002,7 +1004,7 @@ class _CardTapRegion extends StatelessWidget { @override Widget build(BuildContext context) { - if (PlatformDetector.isTV()) { + if (!PlatformDetector.isDesktopOS()) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, diff --git a/lib/widgets/sliver_child_memo.dart b/lib/widgets/sliver_child_memo.dart new file mode 100644 index 00000000..aa56373b --- /dev/null +++ b/lib/widgets/sliver_child_memo.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; + +/// Per-index widget cache for lazy sliver/list children. +/// +/// `SliverChildBuilderDelegate.shouldRebuild` is unconditionally true, so any +/// rebuild of the surrounding widget (pagination `setState`, settings change, +/// hub refresh) hands every *realized* child a brand-new widget and rebuilds +/// its whole subtree — 5-20ms per media card, times every visible card, often +/// inside layout via `SliverCrossAxisLayoutBuilder`. Returning the *identical* +/// widget instance for an unchanged item lets `Element.updateChild` +/// short-circuit the entire subtree instead. +/// +/// A cache entry is reused only while: +/// - the [epoch] passed to [widgetFor] equals the one the cache was built +/// under (pack everything the item builder closes over — column count, +/// item count, card geometry, view prefs — into a record so any change +/// flushes stale closures), and +/// - the item at that index is `identical` to the cached one (item updates +/// replace the object, so in-place data changes invalidate naturally), and +/// - the optional per-index [salt] compares equal (for cheap per-index state +/// like "is this the focused index" that isn't part of the item). +class SliverChildMemo { + /// Hard cap so a long scroll through a huge library can't pin thousands of + /// widget trees (and their item objects, defeating item eviction). Clearing + /// only costs one rebuild of the currently realized children. + static const int _maxEntries = 600; + + Object? _epoch; + final Map _cache = {}; + + /// Returns the cached widget for [index] when item/epoch/salt are + /// unchanged, without building anything on a miss. Lets callers decide + /// whether a miss is allowed to inflate this frame (see + /// `CardInflationBudget`). A changed [epoch] flushes the cache here too so + /// a subsequent [widgetFor] sees the same state. + Widget? tryGet(int index, T item, {required Object epoch, Object? salt}) { + if (epoch != _epoch) { + _cache.clear(); + _epoch = epoch; + return null; + } + final entry = _cache[index]; + if (entry != null && identical(entry.$1, item) && entry.$2 == salt) { + return entry.$3; + } + return null; + } + + /// Returns the cached widget for [index] when item/epoch/salt are + /// unchanged, otherwise runs [build] and caches the result. + Widget widgetFor(int index, T item, {required Object epoch, Object? salt, required Widget Function() build}) { + if (epoch != _epoch) { + _cache.clear(); + _epoch = epoch; + } + final entry = _cache[index]; + if (entry != null && identical(entry.$1, item) && entry.$2 == salt) { + return entry.$3; + } + if (_cache.length >= _maxEntries) _cache.clear(); + final widget = build(); + _cache[index] = (item, salt, widget); + return widget; + } + + void clear() => _cache.clear(); + + /// Drops entries outside `centerIndex ± halfWindow`. + /// + /// Call alongside per-index resource eviction (e.g. focus-node eviction): + /// a cached widget must not outlive resources it captured, like a + /// [FocusNode] that eviction disposed. + void removeOutsideRange(int centerIndex, {required int halfWindow}) { + _cache.removeWhere((index, _) => index < centerIndex - halfWindow || index > centerIndex + halfWindow); + } +} diff --git a/test/focus/focusable_wrapper_test.dart b/test/focus/focusable_wrapper_test.dart new file mode 100644 index 00000000..854b71fa --- /dev/null +++ b/test/focus/focusable_wrapper_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; + +/// The wrapper's focus chrome (scale Transform + border AnimatedContainer) +/// must only exist in keyboard/d-pad mode: on touch it is pure dead weight +/// multiplied by every card in a grid (see library scroll jank). +void main() { + Finder chromeIn(Type type) => + find.descendant(of: find.byType(FocusableWrapper), matching: find.byType(type)); + + Widget buildWrapper() => Scaffold( + body: FocusableWrapper( + onSelect: () {}, + child: const SizedBox(width: 10, height: 10), + ), + ); + + testWidgets('pointer mode builds no focus chrome around the child', (tester) async { + await tester.pumpWidget(MaterialApp(home: buildWrapper())); + + expect(chromeIn(Transform), findsNothing); + expect(chromeIn(AnimatedContainer), findsNothing); + // The Focus node stays mounted so d-pad traversal finds the card the + // moment keyboard mode activates. + expect(chromeIn(Focus), findsWidgets); + }); + + testWidgets('keyboard mode builds the scale/border chrome', (tester) async { + await tester.pumpWidget(InputModeTracker(child: MaterialApp(home: buildWrapper()))); + + // A navigation key press flips the tracker into keyboard mode. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(chromeIn(Transform), findsOneWidget); + expect(chromeIn(AnimatedContainer), findsOneWidget); + }); + + testWidgets('focusing in pointer mode works without a pre-built controller', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableWrapper( + focusNode: node, + onSelect: () {}, + child: const SizedBox(width: 10, height: 10), + ), + ), + ), + ); + + // The AnimationController is created lazily on first focus; gaining and + // losing focus in pointer mode must not throw. + node.requestFocus(); + await tester.pump(); + node.unfocus(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/card_inflation_budget_test.dart b/test/widgets/card_inflation_budget_test.dart new file mode 100644 index 00000000..e9d2f171 --- /dev/null +++ b/test/widgets/card_inflation_budget_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/card_inflation_budget.dart'; + +class _UpgradeHost extends StatefulWidget { + const _UpgradeHost(); + + @override + State<_UpgradeHost> createState() => _UpgradeHostState(); +} + +class _UpgradeHostState extends State<_UpgradeHost> with SkeletonUpgradeScheduler { + int builds = 0; + int pendingSkeletons = 2; + + @override + Widget build(BuildContext context) { + builds++; + if (pendingSkeletons > 0) { + pendingSkeletons--; + scheduleSkeletonUpgrade(); + } + return const SizedBox(); + } +} + +void main() { + setUp(CardInflationBudget.reset); + + testWidgets('budget grants maxPerFrame slots and resets on the next frame', (tester) async { + await tester.pumpWidget(const SizedBox()); + + for (var i = 0; i < CardInflationBudget.maxPerFrame; i++) { + expect(CardInflationBudget.tryTake(), isTrue); + } + expect(CardInflationBudget.tryTake(), isFalse); + + // In production tryTake only runs during builds, where a frame is in + // flight; here the takes happened between frames, so schedule one for + // the post-frame reset to ride on. + tester.binding.scheduleFrame(); + await tester.pump(); + + expect(CardInflationBudget.tryTake(), isTrue); + }); + + testWidgets('skeleton upgrade chain re-arms per frame and stops when drained', (tester) async { + await tester.pumpWidget(const _UpgradeHost()); + final state = tester.state<_UpgradeHostState>(find.byType(_UpgradeHost)); + expect(state.builds, 1); + + // Each pump runs the post-frame setState, upgrading one pending skeleton. + await tester.pump(); + expect(state.builds, 2); + await tester.pump(); + expect(state.builds, 3); + + // Drained: no reschedule, no further rebuilds. + await tester.pump(); + expect(state.builds, 3); + }); +}