diff --git a/lib/mixins/grid_focus_node_mixin.dart b/lib/mixins/grid_focus_node_mixin.dart index 249c4dd6..94d92d45 100644 --- a/lib/mixins/grid_focus_node_mixin.dart +++ b/lib/mixins/grid_focus_node_mixin.dart @@ -40,6 +40,28 @@ mixin GridFocusNodeMixin on State { } } + /// 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 = []; + 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) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index e7126118..9e691f6f 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -83,6 +83,7 @@ class _DiscoverScreenState extends State Timer? _indicatorTimer; final ValueNotifier _indicatorProgress = ValueNotifier(0.0); bool _isAutoScrollPaused = false; + bool _isTabVisible = true; HiddenLibrariesProvider? _hiddenLibrariesProvider; Set _lastSeenHiddenKeys = {}; @@ -309,8 +310,8 @@ class _DiscoverScreenState extends State @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 @override void onTabHidden() { + _isTabVisible = false; _autoScrollTimer?.cancel(); _stopIndicatorProgress(); } @override void onTabShown() { + _isTabVisible = true; if (!_isAutoScrollPaused) { _startAutoScroll(); } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 2433d020..190a4525 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -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 _loadedItems = {}; @@ -213,6 +222,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= 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= 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(); + 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 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 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 _buildScreens(bool offline) { return [ for (final tab in _getVisibleTabs(offline)) @@ -945,7 +960,7 @@ class _MainScreenState extends State 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 with RouteAware, WindowListener return OverlaySheetHost( child: Scaffold( - body: IndexedStack(index: _currentIndex, children: _screens), + body: _buildTickerAwareStack(), bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f65b0ed7..2cc60eca 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 createState() => _SkeletonLoaderState(); @@ -1089,7 +1091,22 @@ class _SkeletonLoaderState extends State 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 diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index b24a39da..f44520d4 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -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, ); }