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