perf: library grid scroll and idle frame rendering

- Window _loadedItems and focus nodes to bound memory growth
- Eager prefetch and image precaching near viewport
- Static skeleton placeholders during active scroll
- Throttle alpha bar updates, dynamic initial fetch size
- Fix discover timer scheduling frames when tab hidden
- Disable tickers for offscreen IndexedStack children
- Static image placeholder with fade-in transition
This commit is contained in:
edde746
2026-03-24 17:19:28 +01:00
parent ff0fafe938
commit 8d56171bd2
6 changed files with 254 additions and 23 deletions
+22
View File
@@ -40,6 +40,28 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
}
}
/// Evict focus nodes far from [centerIndex], keeping at most [keepCount] around it.
void evictDistantFocusNodes(int centerIndex, {int keepCount = 200}) {
if (gridItemFocusNodes.length <= keepCount) return;
final halfKeep = keepCount ~/ 2;
final keepStart = centerIndex - halfKeep;
final keepEnd = centerIndex + halfKeep;
final keysToRemove = <int>[];
for (final key in gridItemFocusNodes.keys) {
if (key < keepStart || key > keepEnd) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
final node = gridItemFocusNodes.remove(key);
if (node != null && !node.hasFocus) {
node.dispose();
}
}
}
/// Dispose all grid-item focus nodes.
void disposeGridFocusNodes() {
for (final node in gridItemFocusNodes.values) {
+5 -2
View File
@@ -83,6 +83,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Timer? _indicatorTimer;
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
bool _isAutoScrollPaused = false;
bool _isTabVisible = true;
HiddenLibrariesProvider? _hiddenLibrariesProvider;
Set<String> _lastSeenHiddenKeys = {};
@@ -309,8 +310,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Restart auto-scroll when app becomes active again
if (!_isAutoScrollPaused) _startAutoScroll();
// Restart auto-scroll only if discover tab is visible
if (_isTabVisible && !_isAutoScrollPaused) _startAutoScroll();
// Refresh continue watching on mobile only
// (on desktop, "resumed" fires on every window focus gain)
if (Platform.isIOS || Platform.isAndroid) {
@@ -390,12 +391,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
void onTabHidden() {
_isTabVisible = false;
_autoScrollTimer?.cancel();
_stopIndicatorProgress();
}
@override
void onTabShown() {
_isTabVisible = true;
if (!_isAutoScrollPaused) {
_startAutoScroll();
}
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart';
@@ -12,9 +13,11 @@ import '../../../models/plex_filter.dart';
import '../../../models/plex_first_character.dart';
import '../../../models/plex_sort.dart';
import '../../../providers/settings_provider.dart';
import '../../../services/image_cache_service.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/plex_image_helper.dart';
import '../alpha_jump_bar.dart';
import '../alpha_jump_helper.dart';
import '../alpha_scroll_handle.dart';
@@ -184,6 +187,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
bool _isScrollActive = false;
Timer? _scrollActivityTimer;
// Eager prefetch throttle
DateTime? _lastEagerPrefetch;
// Alpha bar update: throttle (leading edge) + trailing timer (ensures final position)
DateTime? _lastAlphaUpdate;
Timer? _alphaUpdateTimer;
// Pagination state
int _totalSize = 0;
final Map<int, PlexMetadata> _loadedItems = {};
@@ -213,6 +222,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_cancelToken?.cancel();
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_alphaUpdateTimer?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
@@ -458,7 +468,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final result = await client.getLibraryContent(
widget.library.key,
start: 0,
size: _fetchSize,
size: _calculateInitialFetchSize(),
filters: filterParams,
cancelToken: _cancelToken,
);
@@ -527,6 +537,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
});
// Prefetch images for items about to enter the viewport
_prefetchImages(start, result.items);
// Re-check for remaining gaps in the visible range after this fetch
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && currentRequestId == _requestId) {
@@ -872,9 +885,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Debounced scroll-idle handler: load visible range when scrolling settles
_scrollIdleTimer?.cancel();
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) _loadVisibleRange();
if (!mounted) return;
_loadVisibleRange();
_evictDistantItems();
final firstVisible = _itemIndexFromScrollOffset(_scrollController.offset);
evictDistantFocusNodes(firstVisible);
});
// Eager prefetch: fetch data before scroll stops
_checkEagerPrefetch();
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
// During a jump animation, skip alpha bar processing to avoid flashing.
@@ -887,7 +907,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_hasJumpPin = false;
}
_updateVisibleIndex();
// Throttle alpha bar updates to ~50fps, with a trailing-edge timer
// so the final scroll position always gets an update
final now = DateTime.now();
if (_lastAlphaUpdate == null || now.difference(_lastAlphaUpdate!) >= const Duration(milliseconds: 20)) {
_lastAlphaUpdate = now;
_updateVisibleIndex();
}
_alphaUpdateTimer?.cancel();
_alphaUpdateTimer = Timer(const Duration(milliseconds: 20), () {
if (mounted) _updateVisibleIndex();
});
}
/// Recompute the first-visible-index from the current scroll offset.
@@ -1095,6 +1125,143 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_fetchRange(fetchStart, fetchSize);
}
/// Eagerly prefetch data when approaching unloaded boundaries during scroll.
/// Throttled to at most once per 100ms to avoid spamming.
void _checkEagerPrefetch() {
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
if (_lastCrossAxisExtent <= 0) return;
final now = DateTime.now();
if (_lastEagerPrefetch != null && now.difference(_lastEagerPrefetch!) < const Duration(milliseconds: 100)) return;
final offset = _scrollController.offset;
final viewportHeight = _scrollController.position.viewportDimension;
final firstIndex = _itemIndexFromScrollOffset(offset);
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
final visibleCount = visibleRows * _currentColumnCount;
// Look 1 viewport ahead for unloaded items
final lookAheadStart = (firstIndex + visibleCount).clamp(0, _totalSize);
final lookAheadEnd = (lookAheadStart + visibleCount).clamp(0, _totalSize);
for (var i = lookAheadStart; i < lookAheadEnd; i++) {
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
_lastEagerPrefetch = now;
_fetchRange(i, _fetchSize);
return;
}
}
// Check backward if nothing needed forward
final lookBehindStart = (firstIndex - visibleCount).clamp(0, _totalSize);
for (var i = firstIndex - 1; i >= lookBehindStart; i--) {
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
_lastEagerPrefetch = now;
_fetchRange(i, _fetchSize);
return;
}
}
}
/// Compute initial fetch size based on viewport dimensions.
int _calculateInitialFetchSize() {
try {
final screenSize = MediaQuery.of(context).size;
final settingsProvider = context.read<SettingsProvider>();
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing;
final columnCount =
((screenSize.width + crossAxisSpacing) / (maxExtent + crossAxisSpacing)).ceil().clamp(1, 100);
final itemWidth = screenSize.width / columnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return _fetchSize;
final visibleRows = (screenSize.height / rowHeight).ceil() + 1;
final visibleCount = visibleRows * columnCount;
return (visibleCount * 3).clamp(100, 500);
} catch (_) {
return _fetchSize;
}
}
/// Evict loaded items far from the current viewport to bound memory usage.
static const int _maxLoadedItems = 500;
static const int _evictionThreshold = 600;
void _evictDistantItems() {
if (_loadedItems.length <= _evictionThreshold) return;
final centerIndex = _itemIndexFromScrollOffset(_scrollController.offset);
final halfKeep = _maxLoadedItems ~/ 2;
final keepStart = centerIndex - halfKeep;
final keepEnd = centerIndex + halfKeep;
_loadedItems.removeWhere((index, _) => index < keepStart || index > keepEnd);
}
/// Prefetch images for items near the viewport to reduce pop-in.
void _prefetchImages(int startIndex, List<PlexMetadata> items) {
if (!_scrollController.hasClients || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return;
final offset = _scrollController.offset;
final viewportHeight = _scrollController.position.viewportDimension;
final firstVisible = _itemIndexFromScrollOffset(offset);
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
final visibleEnd = firstVisible + visibleRows * _currentColumnCount;
// Prefetch 2 rows beyond visible area
final prefetchEnd = visibleEnd + 2 * _currentColumnCount;
final client = getClientForLibrary();
final devicePixelRatio = PlexImageHelper.effectiveDevicePixelRatio(context);
for (var i = 0; i < items.length; i++) {
final index = startIndex + i;
if (index < firstVisible || index > prefetchEnd) continue;
final thumb = items[i].thumb;
if (thumb == null || thumb.isEmpty) continue;
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: thumb,
maxWidth: itemWidth,
maxHeight: itemHeight,
devicePixelRatio: devicePixelRatio,
enableTranscoding: PlexImageHelper.shouldTranscode(thumb),
imageType: ImageType.poster,
);
if (imageUrl.isEmpty) continue;
final scaledWidth = itemWidth * devicePixelRatio;
final scaledHeight = itemHeight * devicePixelRatio;
final (_, memHeight) = PlexImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
);
precacheImage(
CachedNetworkImageProvider(
imageUrl,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
maxHeight: memHeight,
),
context,
);
}
}
/// Whether the filters chip is visible
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
@@ -1277,7 +1444,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Show skeleton placeholder for unloaded items
if (item == null) {
return const _SkeletonCard();
return _SkeletonCard(animate: !_isScrollActive);
}
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
@@ -1303,7 +1470,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Skeleton placeholder card that matches the poster + title layout of a real media card.
/// Not focusable — dpad focus skips over these.
class _SkeletonCard extends StatelessWidget {
const _SkeletonCard();
final bool animate;
const _SkeletonCard({this.animate = true});
@override
Widget build(BuildContext context) {
@@ -1316,12 +1485,13 @@ class _SkeletonCard extends StatelessWidget {
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: const SkeletonLoader(child: SizedBox.expand()),
child: SkeletonLoader(animate: animate, child: const SizedBox.expand()),
),
),
const SizedBox(height: 4),
// Title bar
SkeletonLoader(
animate: animate,
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: const SizedBox(height: 13, width: double.infinity),
),
@@ -1331,6 +1501,7 @@ class _SkeletonCard extends StatelessWidget {
alignment: Alignment.centerLeft,
widthFactor: 0.6,
child: SkeletonLoader(
animate: animate,
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: const SizedBox(height: 11),
),
+17 -2
View File
@@ -528,6 +528,21 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
_isShowingProfileSelection = false;
}
/// IndexedStack that disables tickers for offscreen children to prevent
/// animation controllers on non-visible tabs from scheduling frames.
Widget _buildTickerAwareStack() {
return IndexedStack(
index: _currentIndex,
children: [
for (var i = 0; i < _screens.length; i++)
TickerMode(
enabled: i == _currentIndex,
child: _screens[i],
),
],
);
}
List<Widget> _buildScreens(bool offline) {
return [
for (final tab in _getVisibleTabs(offline))
@@ -945,7 +960,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
node: _contentFocusScope,
// No autofocus - we control focus programmatically to prevent
// autofocus from stealing focus back after setState() rebuilds
child: IndexedStack(index: _currentIndex, children: _screens),
child: _buildTickerAwareStack(),
),
),
),
@@ -990,7 +1005,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
return OverlaySheetHost(
child: Scaffold(
body: IndexedStack(index: _currentIndex, children: _screens),
body: _buildTickerAwareStack(),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
+20 -3
View File
@@ -1066,12 +1066,14 @@ class _ClickableTextState extends State<_ClickableText> {
}
}
/// Skeleton loader widget with subtle opacity pulse animation
/// Skeleton loader widget with subtle opacity pulse animation.
/// Set [animate] to false during active scroll to avoid compositor cost.
class SkeletonLoader extends StatefulWidget {
final Widget? child;
final BorderRadius? borderRadius;
final bool animate;
const SkeletonLoader({super.key, this.child, this.borderRadius});
const SkeletonLoader({super.key, this.child, this.borderRadius, this.animate = true});
@override
State<SkeletonLoader> createState() => _SkeletonLoaderState();
@@ -1089,7 +1091,22 @@ class _SkeletonLoaderState extends State<SkeletonLoader> with SingleTickerProvid
begin: 0.3,
end: 0.7,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
_animationController.repeat(reverse: true);
if (widget.animate) {
_animationController.repeat(reverse: true);
} else {
_animationController.value = 0.5;
}
}
@override
void didUpdateWidget(SkeletonLoader oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.animate && !oldWidget.animate) {
_animationController.repeat(reverse: true);
} else if (!widget.animate && oldWidget.animate) {
_animationController.stop();
_animationController.value = 0.5;
}
}
@override
+13 -10
View File
@@ -7,7 +7,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../services/image_cache_service.dart';
import '../../services/plex_client.dart';
import '../utils/plex_image_helper.dart';
import 'media_card.dart';
/// Set to `true` to blur all artwork (for store screenshots).
const kBlurArtwork = false;
@@ -350,21 +349,25 @@ class PlexOptimizedImage extends StatelessWidget {
alignment: alignment,
errorBuilder: (context, error, stackTrace) => _buildErrorWidget(context, error),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _buildPlaceholder(context);
if (wasSynchronouslyLoaded) return child;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: frame != null
? child
: _buildPlaceholder(context),
);
},
);
}
Widget _buildPlaceholder(BuildContext _) {
return SizedBox(
Widget _buildPlaceholder(BuildContext context) {
return Container(
width: width,
height: height,
child: SkeletonLoader(
child: fallbackIcon != null
? Center(child: AppIcon(fallbackIcon!, fill: 1, size: 40, color: Colors.white54))
: null,
),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: fallbackIcon != null
? Center(child: AppIcon(fallbackIcon!, fill: 1, size: 40, color: Colors.white54))
: null,
);
}