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 _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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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