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
+51 -30
View File
@@ -169,8 +169,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
bool _ownsNode = false; bool _ownsNode = false;
bool _isFocused = false; bool _isFocused = false;
late final AnimationController _animationController; // Created lazily on first focus/keyboard-mode build: touch scrolling builds
late Animation<double> _scaleAnimation; // hundreds of these wrappers and must not pay for a Ticker per card.
AnimationController? _animationController;
Animation<double>? _scaleAnimation;
// Long-press detection for SELECT key // Long-press detection for SELECT key
Timer? _longPressTimer; Timer? _longPressTimer;
@@ -180,7 +182,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
void initState() { void initState() {
super.initState(); super.initState();
_initFocusNode(); _initFocusNode();
_initAnimations();
} }
void _initFocusNode() { void _initFocusNode() {
@@ -196,17 +197,20 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} }
} }
void _initAnimations() { AnimationController _ensureAnimationController() {
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); final existing = _animationController;
if (existing != null) return existing;
_scaleAnimation = _createScaleAnimation(); 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>( return Tween<double>(
begin: 1.0, begin: 1.0,
end: widget.focusScale, end: widget.focusScale,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); ).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutCubic));
} }
@override @override
@@ -227,14 +231,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} }
if (widget.focusScale != oldWidget.focusScale) { if (widget.focusScale != oldWidget.focusScale) {
_scaleAnimation = _createScaleAnimation(); final controller = _animationController;
if (controller != null) {
_scaleAnimation = _createScaleAnimation(controller);
}
} }
} }
@override @override
void dispose() { void dispose() {
_longPressTimer?.cancel(); _longPressTimer?.cancel();
_animationController.dispose(); _animationController?.dispose();
if (_ownsNode) { if (_ownsNode) {
_focusNode.dispose(); _focusNode.dispose();
} }
@@ -255,9 +262,9 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
// Animate scale // Animate scale
if (hasFocus) { if (hasFocus) {
_animationController.forward(); _ensureAnimationController().forward();
} else { } else {
_animationController.reverse(); _animationController?.reverse();
} }
// Auto-scroll into view // Auto-scroll into view
@@ -466,23 +473,28 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(context); // Only show focus effects during keyboard/d-pad navigation. In pointer/
// Only show focus effects during keyboard/d-pad navigation // touch mode no card ever shows focus chrome, so skip the animated
final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); // 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 Widget inner;
if (_animationController.duration != duration) { if (!isKeyboardMode) {
_animationController.duration = duration; 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( inner = AnimatedBuilder(
focusNode: _focusNode, animation: _scaleAnimation!,
autofocus: widget.autofocus,
descendantsAreFocusable: widget.descendantsAreFocusable,
onFocusChange: _handleFocusChange,
onKeyEvent: _handleKeyEvent,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) { builder: (context, child) {
final shouldScale = showFocus && !widget.disableScale; final shouldScale = showFocus && !widget.disableScale;
// The glow (full-bleed cards) is drawn in an overlay above siblings so // 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, 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 // Add semantics if label provided
@@ -57,7 +57,9 @@ import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/watch_state_aware.dart';
import '../../../mixins/deletion_aware.dart'; import '../../../mixins/deletion_aware.dart';
import '../../../mixins/paginated_item_loader.dart'; import '../../../mixins/paginated_item_loader.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/skeleton_media_card.dart'; import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../utils/deletion_notifier.dart'; import '../../../utils/deletion_notifier.dart';
import '../../../utils/global_key_utils.dart'; import '../../../utils/global_key_utils.dart';
import '../../../utils/watch_state_notifier.dart'; import '../../../utils/watch_state_notifier.dart';
@@ -104,7 +106,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
GridFocusNodeMixin, GridFocusNodeMixin,
WatchStateAware, WatchStateAware,
DeletionAware, DeletionAware,
PaginatedItemLoader<MediaItem, LibraryBrowseTab> { PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
SkeletonUpgradeScheduler {
@override @override
String? get itemServerId => widget.library.serverId; String? get itemServerId => widget.library.serverId;
@@ -249,6 +252,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {}; Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0); final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0);
LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty; 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; double _effectiveTopPadding = _gridTopPadding;
final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item'); final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item');
double? _measuredListRowHeight; double? _measuredListRowHeight;
@@ -684,6 +696,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// This invalidates the last focused index // This invalidates the last focused index
gridContentVersion++; gridContentVersion++;
cleanupGridFocusNodes(0); cleanupGridFocusNodes(0);
// All focus nodes were just disposed; cached cards captured them.
_cardMemo.clear();
}); });
try { try {
@@ -1204,7 +1218,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (range != null) { if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _activeFetchSize ~/ 2); ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _activeFetchSize ~/ 2);
evictDistantItems(range.firstIndex, maxKeep: 500, threshold: 600); 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, addSemanticIndexes: false,
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final child = _buildMediaCardItem( final item = loadedItems[index];
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
final child = _cardMemo.widgetFor(
index, index,
isFirstRow: index == 0, item,
isFirstColumn: true, // List view = single column epoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
isLastColumn: true, build: () => _buildMediaCardItem(
disableScale: true, index,
columnCount: 1, isFirstRow: index == 0,
itemCount: itemCount, isFirstColumn: true, // List view = single column
isLastColumn: true,
disableScale: true,
columnCount: 1,
itemCount: itemCount,
),
); );
return index == 0 ? _buildMeasuredFirstListItem(child) : child; return index == 0 ? _buildMeasuredFirstListItem(child) : child;
}, },
@@ -1822,6 +1849,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
itemWidth: geometry.itemWidth, itemWidth: geometry.itemWidth,
itemHeight: geometry.itemHeight, 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( return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the // Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item. // per-child wrappers shrinks build + semantics work per item.
@@ -1829,15 +1868,41 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
addSemanticIndexes: false, addSemanticIndexes: false,
gridDelegate: geometry.delegate, gridDelegate: geometry.delegate,
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem( itemBuilder: (context, index) {
index, final item = loadedItems[index];
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), if (item == null) {
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), _scheduleRangeLoad();
isLastColumn: (index % columnCount) == (columnCount - 1), return const SkeletonMediaCard();
columnCount: columnCount, }
itemCount: itemCount, final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch);
fullBleedImage: fullCardLayout, 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:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart'; import '../../../media/library_query.dart';
import '../../../media/media_item.dart'; import '../../../media/media_item.dart';
import '../../../mixins/library_tab_focus_mixin.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/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart'; import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart'; import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/settings_builder.dart'; import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart'; import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../widgets/sliver_cross_axis_layout_builder.dart'; import '../../../widgets/sliver_cross_axis_layout_builder.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../main_screen.dart'; import '../../main_screen.dart';
@@ -39,9 +42,16 @@ class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
} }
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab> class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
with LibraryTabFocusMixin<LibraryCollectionsTab>, PaginatedItemLoader<MediaItem, LibraryCollectionsTab> { with
LibraryTabFocusMixin<LibraryCollectionsTab>,
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
SkeletonUpgradeScheduler {
static const int _pageSize = 36; 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 @override
String get focusNodeDebugLabel => 'collections_first_item'; String get focusNodeDebugLabel => 'collections_first_item';
@@ -144,8 +154,19 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
addSemanticIndexes: false, addSemanticIndexes: false,
itemCount: totalSize, itemCount: totalSize,
itemBuilder: (context, index) => itemBuilder: (context, index) {
_buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true), 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, density: density,
fullBleedImage: fullCardLayout, fullBleedImage: fullCardLayout,
); );
// Everything the card closures capture; a change flushes the memo.
final cardEpoch = (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density);
return SliverGrid.builder( return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the // Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item. // per-child wrappers shrinks build + semantics work per item.
@@ -168,12 +191,34 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
addSemanticIndexes: false, addSemanticIndexes: false,
gridDelegate: geometry.delegate, gridDelegate: geometry.delegate,
itemCount: totalSize, itemCount: totalSize,
itemBuilder: (context, index) => _buildMediaCardItem( itemBuilder: (context, index) {
index, final item = loadedItems[index];
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount), if (item == null) {
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount), ensureIndexLoaded(index, pageSize: _pageSize);
fullBleedImage: fullCardLayout, 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,
),
);
},
); );
}, },
), ),
+76
View File
@@ -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(() {});
});
}
}
+4 -3
View File
@@ -10,9 +10,10 @@ class ClickableCursor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// No pointer on TV: skip the MouseRegion entirely — one exists per card // Cursor feedback only matters where a pointer exists: skip the
// and they add up on low-end devices. // MouseRegion on TV and touch handhelds — one exists per card and they
if (PlatformDetector.isTV()) return child; // add up on low-end devices.
if (!PlatformDetector.isDesktopOS()) return child;
return MouseRegion(cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, child: child); return MouseRegion(cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, child: child);
} }
} }
+84 -23
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.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 '../mixins/mounted_set_state_mixin.dart';
import '../screens/hub_detail_screen.dart'; import '../screens/hub_detail_screen.dart';
import '../utils/media_navigation_helper.dart'; import '../utils/media_navigation_helper.dart';
import 'card_inflation_budget.dart';
import 'focus_builders.dart'; import 'focus_builders.dart';
import 'media_card.dart'; import 'media_card.dart';
import 'skeleton_media_card.dart';
import 'sliver_child_memo.dart';
import '../utils/scroll_utils.dart'; import '../utils/scroll_utils.dart';
import 'horizontal_scroll_with_arrows.dart'; import 'horizontal_scroll_with_arrows.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
@@ -92,7 +96,7 @@ class HubSection extends StatefulWidget {
State<HubSection> createState() => HubSectionState(); 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); static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode; 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) /// Current visual focus index (not tied to Flutter's focus system)
int _focusedIndex = 0; 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 _itemExtent = 0;
double _leadingPaddingFor(bool isTv) => widget.inset double _leadingPaddingFor(bool isTv) => widget.inset
? 0.0 ? 0.0
@@ -498,6 +506,19 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
final focusExtra = focusBorderWidth * 2; // border on both sides final focusExtra = focusBorderWidth * 2; // border on both sides
_itemExtent = cardWidth + focusExtra + 4; _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( return SizedBox(
height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom
child: HorizontalScrollWithArrows( child: HorizontalScrollWithArrows(
@@ -510,6 +531,13 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
controller: scrollController, controller: scrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
clipBehavior: Clip.none, 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 padding: widget.inset
? EdgeInsets.symmetric(vertical: isTv ? 6 : 2) ? EdgeInsets.symmetric(vertical: isTv ? 6 : 2)
: EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2), : EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2),
@@ -560,28 +588,61 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
final item = widget.hub.items[index]; final item = widget.hub.items[index];
return Padding( final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch, salt: isItemFocused);
key: _itemKeyFor(index), if (cached != null) return cached;
padding: widget.inset // Budget fresh inflations while an enclosing
? const EdgeInsets.only(right: 4) // scrollable is moving (rows enter on the parent's
: const EdgeInsets.symmetric(horizontal: 2), // vertical scroll); skeletons upgrade a frame
child: FocusBuilders.buildLockedFocusWrapper( // later. Keyboard mode is exempt — skeletons
context: context, // aren't focus targets.
isFocused: isItemFocused, if (!isKeyboardMode &&
onTap: () => _onItemTapped(index), CardInflationBudget.isScrollingContext(context) &&
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), !CardInflationBudget.tryTake()) {
delegateFocusBorder: true, scheduleSkeletonUpgrade();
child: MediaCard( return Padding(
key: _getMediaCardKey(index), padding: widget.inset
item: item, ? const EdgeInsets.only(right: 4)
width: cardWidth, : const EdgeInsets.symmetric(horizontal: 2),
height: posterHeight, child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()),
onRefresh: widget.onRefresh, );
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, }
forceGridMode: true, return _cardMemo.widgetFor(
isInContinueWatching: widget.isInContinueWatching, index,
usesContinueWatchingAction: widget.usesContinueWatchingAction, item,
mixedHubContext: isMixedHub, 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,
),
), ),
), ),
); );
+6 -4
View File
@@ -976,9 +976,11 @@ class SkeletonLoader extends StatelessWidget {
} }
} }
/// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) where a /// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) on
/// pointer exists, a bare [GestureDetector] on TV where the d-pad drives /// desktop where hover feedback matters, a bare [GestureDetector] on TV and
/// activation and the per-card ink/hover/focus machinery is dead weight. /// 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 /// Keyboard focus is handled by the focus wrappers either way
/// (canRequestFocus stays false on the InkWell). /// (canRequestFocus stays false on the InkWell).
class _CardTapRegion extends StatelessWidget { class _CardTapRegion extends StatelessWidget {
@@ -1002,7 +1004,7 @@ class _CardTapRegion extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (PlatformDetector.isTV()) { if (!PlatformDetector.isDesktopOS()) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
+76
View File
@@ -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);
}
}
+67
View File
@@ -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);
});
}