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:
@@ -169,8 +169,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
bool _ownsNode = false;
|
||||
bool _isFocused = false;
|
||||
|
||||
late final AnimationController _animationController;
|
||||
late Animation<double> _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<double>? _scaleAnimation;
|
||||
|
||||
// Long-press detection for SELECT key
|
||||
Timer? _longPressTimer;
|
||||
@@ -180,7 +182,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFocusNode();
|
||||
_initAnimations();
|
||||
}
|
||||
|
||||
void _initFocusNode() {
|
||||
@@ -196,17 +197,20 @@ class _FocusableWrapperState extends State<FocusableWrapper> 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<double> _createScaleAnimation() {
|
||||
Animation<double> _createScaleAnimation(AnimationController controller) {
|
||||
return Tween<double>(
|
||||
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<FocusableWrapper> 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<FocusableWrapper> 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<FocusableWrapper> 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;
|
||||
|
||||
Widget inner;
|
||||
if (!isKeyboardMode) {
|
||||
inner = widget.child;
|
||||
} else {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final controller = _ensureAnimationController();
|
||||
// Update animation duration if theme changes
|
||||
if (_animationController.duration != duration) {
|
||||
_animationController.duration = duration;
|
||||
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<FocusableWrapper> 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
|
||||
|
||||
@@ -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,7 +1799,16 @@ 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,
|
||||
item,
|
||||
epoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
|
||||
build: () => _buildMediaCardItem(
|
||||
index,
|
||||
isFirstRow: index == 0,
|
||||
isFirstColumn: true, // List view = single column
|
||||
@@ -1790,6 +1816,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
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,7 +1868,31 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
addSemanticIndexes: false,
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(
|
||||
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),
|
||||
@@ -1840,6 +1903,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +191,27 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
addSemanticIndexes: false,
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(
|
||||
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),
|
||||
@@ -176,6 +219,8 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T extends StatefulWidget> on State<T> {
|
||||
bool _skeletonUpgradeScheduled = false;
|
||||
|
||||
void scheduleSkeletonUpgrade() {
|
||||
if (_skeletonUpgradeScheduled) return;
|
||||
_skeletonUpgradeScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_skeletonUpgradeScheduled = false;
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HubSection> createState() => HubSectionState();
|
||||
}
|
||||
|
||||
class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
class HubSectionState extends State<HubSection> with MountedSetStateMixin, SkeletonUpgradeScheduler {
|
||||
static const _longPressDuration = Duration(milliseconds: 500);
|
||||
|
||||
late FocusNode _hubFocusNode;
|
||||
@@ -101,6 +105,10 @@ class HubSectionState extends State<HubSection> 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<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
|
||||
|
||||
double _itemExtent = 0;
|
||||
double _leadingPaddingFor(bool isTv) => widget.inset
|
||||
? 0.0
|
||||
@@ -498,6 +506,19 @@ class HubSectionState extends State<HubSection> 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<HubSection> 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,7 +588,32 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
|
||||
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: 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)
|
||||
@@ -568,8 +621,15 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () => _onItemTapped(index),
|
||||
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
|
||||
// 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),
|
||||
@@ -584,6 +644,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
mixedHubContext: isMixedHub,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T extends Object> {
|
||||
/// 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<int, (T, Object?, Widget)> _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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user