perf(library): cut scroll jank in card grids and hub rows

Profile traces showed 100-370ms UI-thread frames while scrolling the
library screen, dominated by rebuilding and inflating media cards.

- Gate per-card focus/pointer chrome on input mode: FocusableWrapper
  skips the scale/border wrappers and creates its AnimationController
  lazily outside keyboard mode, and ClickableCursor plus the card tap
  region only build MouseRegion/InkWell machinery on desktop - TV and
  touch use a bare GestureDetector. Hub cards also drop their outer
  gesture wrapper outside keyboard mode; the card's own tap region
  always won the gesture arena anyway.

- Memoize sliver children (SliverChildMemo): browse/collections grids
  and hub rows return identical widget instances for unchanged items,
  so delegate swaps from pagination, watch-state, and deletion
  setStates no longer rebuild every realized card inside layout. The
  browse tab prunes the memo in lockstep with focus-node eviction so a
  cached card can never resurrect a disposed FocusNode.

- Budget fresh inflation (CardInflationBudget): while a scrollable is
  moving in pointer/touch mode at most one new card inflates per frame,
  the rest render as SkeletonMediaCard and upgrade on following frames.
  Hub rows also stop pre-inflating 250px of off-screen cards on entry.

Device traces: worst frame 373ms -> 103ms, per-card build 3.6ms ->
2.4ms median; remaining row-entry work is spread across frames.
This commit is contained in:
edde746
2026-07-05 02:23:42 +02:00
parent d80a1ed15a
commit 11f7fd766d
10 changed files with 563 additions and 87 deletions
@@ -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<MediaItem, LibraryBrows
GridFocusNodeMixin,
WatchStateAware,
DeletionAware,
PaginatedItemLoader<MediaItem, LibraryBrowseTab> {
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
SkeletonUpgradeScheduler {
@override
String? get itemServerId => widget.library.serverId;
@@ -249,6 +252,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(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<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
/// 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<MediaItem, LibraryBrows
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(0);
// All focus nodes were just disposed; cached cards captured them.
_cardMemo.clear();
});
try {
@@ -1204,7 +1218,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _activeFetchSize ~/ 2);
evictDistantItems(range.firstIndex, maxKeep: 500, threshold: 600);
evictDistantFocusNodes(range.firstIndex);
evictDistantFocusNodes(range.firstIndex, keepCount: _focusNodeKeepCount);
// Cached card widgets capture their focus node — drop them in lockstep
// with node eviction so a cache hit can't resurrect a disposed node.
_cardMemo.removeOutsideRange(range.firstIndex, halfWindow: _focusNodeKeepCount ~/ 2);
}
});
@@ -1782,14 +1799,24 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
addSemanticIndexes: false,
itemCount: itemCount,
itemBuilder: (context, index) {
final child = _buildMediaCardItem(
final item = loadedItems[index];
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
final child = _cardMemo.widgetFor(
index,
isFirstRow: index == 0,
isFirstColumn: true, // List view = single column
isLastColumn: true,
disableScale: true,
columnCount: 1,
itemCount: itemCount,
item,
epoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
build: () => _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<MediaItem, LibraryBrows
itemWidth: geometry.itemWidth,
itemHeight: geometry.itemHeight,
);
// Everything the card closures capture; a change flushes the memo
// so stale nav closures can't misroute d-pad focus.
final cardEpoch = (
ViewMode.grid,
columnCount,
itemCount,
fullCardLayout,
useWideRatio,
libraryDensity,
_shouldShowAlphaJumpBar,
isPhone,
);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
@@ -1829,15 +1868,41 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: itemCount,
itemBuilder: (context, index) => _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,
),
);
},
);
},
),
@@ -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<MediaItem> {
}
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
with LibraryTabFocusMixin<LibraryCollectionsTab>, PaginatedItemLoader<MediaItem, LibraryCollectionsTab> {
with
LibraryTabFocusMixin<LibraryCollectionsTab>,
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
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<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
@override
String get focusNodeDebugLabel => 'collections_first_item';
@@ -144,8 +154,19 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalSize,
itemBuilder: (context, index) =>
_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<MediaItem, Library
density: density,
fullBleedImage: fullCardLayout,
);
// Everything the card closures capture; a change flushes the memo.
final cardEpoch = (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
@@ -168,12 +191,34 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalSize,
itemBuilder: (context, index) => _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,
),
);
},
);
},
),