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