From eb49ead83b253eba7c67cdb6dfc4f8b8d1409bd1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:50:22 +0200 Subject: [PATCH] fix: paginate collection/playlist/person endpoints close #903 --- lib/mixins/paginated_item_loader.dart | 251 +++++++++++ lib/screens/actor_media_screen.dart | 2 +- lib/screens/collection_detail_screen.dart | 92 ++-- .../focusable_detail_screen_mixin.dart | 74 ++++ .../libraries/tabs/library_browse_tab.dart | 402 ++++-------------- .../playlist/playlist_detail_screen.dart | 2 +- lib/services/plex_client.dart | 118 +++-- lib/services/sync_rule_executor.dart | 4 +- lib/widgets/media_context_menu.dart | 4 +- lib/widgets/skeleton_media_card.dart | 39 ++ 10 files changed, 603 insertions(+), 385 deletions(-) create mode 100644 lib/mixins/paginated_item_loader.dart create mode 100644 lib/widgets/skeleton_media_card.dart diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart new file mode 100644 index 00000000..6eb1d40a --- /dev/null +++ b/lib/mixins/paginated_item_loader.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../models/plex_metadata.dart'; +import '../services/plex_client.dart'; +import '../utils/plex_http_client.dart'; +import '../utils/plex_http_exception.dart'; + +/// Sparse-loading state + fetch orchestration for paginated item grids/lists. +/// +/// State lives in [loadedItems] (index → item) and [totalSize]. Subclasses +/// provide [fetchPage] to hit an endpoint; the mixin handles dedupe, retry +/// with backoff, abort propagation, and request-generation invalidation so +/// reloads don't collide with in-flight fetches. +/// +/// Typical lifecycle: +/// 1. Subclass's `loadItems` calls [resetPaginationState] inside `setState`, +/// then awaits [loadInitialPage]. +/// 2. On scroll, subclass calls [ensureRangeLoaded] with the visible index +/// range. Eager prefetch ahead of the viewport via [prefetchAhead]. +/// 3. On dispose, subclass calls [disposePagination]. +mixin PaginatedItemLoader on State { + /// Sparse map of loaded items, keyed by position. + final Map loadedItems = {}; + + /// Total items on the server. 0 until the first page completes. + int totalSize = 0; + + final Set _loadingRanges = {}; + AbortController? _cancelToken; + + /// Monotonic generation — bumped on reset/dispose so stale fetches are + /// discarded instead of mutating state from a prior load. + int _requestId = 0; + + int _retryCount = 0; + Timer? _retryTimer; + bool _visibleRangeLoading = false; + DateTime? _lastEagerPrefetch; + + /// Re-invoked by the retry timer. Most recent range-load args. + VoidCallback? _scheduledRetry; + + /// Fetch a page of items. Subclass implements this — typically delegating + /// to a paginated `PlexClient` method that returns a [LibraryContentResult]. + Future fetchPage(int start, int size, AbortController? abort); + + /// Hook fired after each successful page merge. Default: no-op. + /// Override for image prefetch, syncing a base-class `items` list, etc. + void onPageLoaded(int start, List items) {} + + /// Synchronously clear pagination state and bump the generation counter. + /// Call from inside the subclass's `setState` before awaiting + /// [loadInitialPage]. Aborts any in-flight fetches from the previous load. + void resetPaginationState() { + _requestId++; + _cancelToken?.abort(); + _cancelToken = AbortController(); + _retryTimer?.cancel(); + _retryCount = 0; + _visibleRangeLoading = false; + _lastEagerPrefetch = null; + _scheduledRetry = null; + loadedItems.clear(); + _loadingRanges.clear(); + totalSize = 0; + } + + /// Fetch the first page. Await from outside `setState`. Mutates + /// [loadedItems] and [totalSize] on success; throws on failure. + Future loadInitialPage(int pageSize) async { + final generation = _requestId; + final result = await fetchPage(0, pageSize, _cancelToken); + if (generation != _requestId || !mounted) return result; + + for (var i = 0; i < result.items.length; i++) { + loadedItems[i] = result.items[i]; + } + totalSize = result.totalSize; + onPageLoaded(0, result.items); + return result; + } + + /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount) + /// with [buffer] extra indices on each side. Serialized — only one + /// range-fetch runs at a time — and re-checks after each success so a + /// single call can backfill multiple gaps. + Future ensureRangeLoaded(int firstIndex, int visibleCount, {int buffer = 100}) async { + if (_visibleRangeLoading || totalSize == 0) return; + + final rangeStart = (firstIndex - buffer).clamp(0, totalSize); + final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, totalSize); + + int? fetchStart; + int? fetchEnd; + for (var i = rangeStart; i < rangeEnd; i++) { + if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) { + fetchStart ??= i; + fetchEnd = i + 1; + } + } + if (fetchStart == null || fetchEnd == null) return; + + _retryTimer?.cancel(); + _scheduledRetry = () => ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer); + _visibleRangeLoading = true; + try { + final success = await _fetchRange(fetchStart, fetchEnd - fetchStart); + if (success && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer); + }); + } + } finally { + _visibleRangeLoading = false; + } + } + + /// Throttled eager prefetch: if anything immediately outside the viewport + /// is unloaded, fetch a page. Runs at most once per 100ms. + void prefetchAhead(int firstIndex, int visibleCount, {int pageSize = 200}) { + if (totalSize == 0) return; + + final now = DateTime.now(); + if (_lastEagerPrefetch != null && now.difference(_lastEagerPrefetch!) < const Duration(milliseconds: 100)) { + return; + } + + 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, pageSize); + return; + } + } + + 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, pageSize); + return; + } + } + } + + /// Evict entries far from [centerIndex] once [loadedItems] exceeds + /// [threshold], keeping [maxKeep] entries centered on [centerIndex]. + void evictDistantItems(int centerIndex, {int maxKeep = 500, int threshold = 600}) { + if (loadedItems.length <= threshold) return; + final halfKeep = maxKeep ~/ 2; + loadedItems.removeWhere((index, _) => index < centerIndex - halfKeep || index > centerIndex + halfKeep); + } + + /// Remove the item at [index] and shift higher indices down by one. + /// Mirrors the "one item deleted on the server" invariant: decrements + /// [totalSize] even if [index] wasn't in the sparse map (evicted). + void removeLoadedItemAndShift(int index) { + loadedItems.remove(index); + final shifted = {}; + for (final entry in loadedItems.entries) { + if (entry.key > index) { + shifted[entry.key - 1] = entry.value; + } else { + shifted[entry.key] = entry.value; + } + } + loadedItems + ..clear() + ..addAll(shifted); + totalSize = (totalSize - 1).clamp(0, totalSize); + } + + /// Discard the "fetch in flight" markers. In-flight network requests keep + /// running but are no longer considered for dedupe — the next + /// [ensureRangeLoaded] / [prefetchAhead] will re-scan the visible range. + /// Used by scroll-idle handlers after a fast scroll where earlier eager + /// prefetches are aimed at a now-irrelevant region. + void clearPendingRanges() { + _loadingRanges.clear(); + } + + /// Ensure the page containing [index] is fetched (or already fetched). + /// For callers that don't track viewport geometry — trigger this when a + /// skeleton for [index] is built, and the containing page will backfill. + /// Dedupes so multiple skeletons in the same page share one fetch. + void ensureIndexLoaded(int index, {int pageSize = 200}) { + if (totalSize == 0 || index >= totalSize || index < 0) return; + if (loadedItems.containsKey(index) || _loadingRanges.contains(index)) return; + final pageStart = (index ~/ pageSize) * pageSize; + // Wire up the backoff retry: if this fetch fails, the retry timer in + // _fetchRange needs something to call. Without this, a failed fetch on a + // skeleton-only screen leaves the skeleton stuck until something else + // triggers a rebuild. + _scheduledRetry = () => ensureIndexLoaded(index, pageSize: pageSize); + _fetchRange(pageStart, pageSize); + } + + /// Aborts in-flight requests and cancels timers. Call from `dispose()`. + void disposePagination() { + _requestId++; + _cancelToken?.abort(); + _cancelToken = null; + _retryTimer?.cancel(); + _retryTimer = null; + _loadingRanges.clear(); + _scheduledRetry = null; + } + + Future _fetchRange(int start, int size) async { + if (start >= totalSize) return false; + final clampedSize = size.clamp(0, totalSize - start); + if (clampedSize == 0) return false; + + final indices = List.generate(clampedSize, (i) => start + i); + if (indices.every((i) => _loadingRanges.contains(i) || loadedItems.containsKey(i))) return true; + _loadingRanges.addAll(indices); + + final generation = _requestId; + + try { + final result = await fetchPage(start, clampedSize, _cancelToken); + if (generation != _requestId || !mounted) return false; + + setState(() { + for (var i = 0; i < result.items.length; i++) { + loadedItems[start + i] = result.items[i]; + } + if (result.totalSize != totalSize) totalSize = result.totalSize; + }); + + _retryCount = 0; + onPageLoaded(start, result.items); + return true; + } catch (e) { + if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false; + _retryCount++; + final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4))); + _retryTimer?.cancel(); + _retryTimer = Timer(delay, () { + if (mounted && generation == _requestId) _scheduledRetry?.call(); + }); + return false; + } finally { + _loadingRanges.removeAll(indices); + } + } +} diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 924c5eea..84f01f67 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -58,7 +58,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen @override Future> fetchItems() async { - return await client.getPersonMedia(widget.personId); + return await client.fetchAllPersonMedia(widget.personId); } @override diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 4795eb92..845426e3 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -2,14 +2,17 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../mixins/paginated_item_loader.dart'; import '../models/plex_metadata.dart'; import '../providers/download_provider.dart'; +import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; +import '../utils/dialogs.dart'; import '../utils/download_utils.dart'; +import '../utils/plex_http_client.dart'; +import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../i18n/strings.g.dart'; -import '../utils/dialogs.dart'; -import '../utils/app_logger.dart'; -import '../utils/snackbar_helper.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; @@ -26,9 +29,11 @@ class CollectionDetailScreen extends StatefulWidget { class _CollectionDetailScreenState extends BaseMediaListDetailScreen with - StandardItemLoader, GridFocusNodeMixin, - FocusableDetailScreenMixin { + FocusableDetailScreenMixin, + PaginatedItemLoader { + static const int _pageSize = 200; + @override PlexMetadata get mediaItem => widget.collection; @@ -39,33 +44,60 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen t.collections.empty; @override - bool get hasItems => items.isNotEmpty; + bool get hasItems => totalSize > 0; @override void dispose() { + disposePagination(); disposeFocusResources(); super.dispose(); } @override - Future> fetchItems() async { - return await client.getCollectionItems(widget.collection.ratingKey); + Future fetchPage(int start, int size, AbortController? abort) => + client.getCollectionItems(widget.collection.ratingKey, start: start, size: size, abort: abort); + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + // Search [loadedItems] (not the flat [items] snapshot, which only has + // the first page) so refreshing an item at a scrolled-in position updates + // the grid in place. + for (final entry in loadedItems.entries) { + if (entry.value.ratingKey == ratingKey) { + loadedItems[entry.key] = updatedMetadata; + return; + } + } } @override Future loadItems() async { - await super.loadItems(); - autoFocusFirstItemAfterLoad(); - } - - @override - String getLoadErrorMessage(Object error) { - return t.collections.failedToLoadItems(error: error.toString()); - } - - @override - String getLoadSuccessMessage(int itemCount) { - return 'Loaded $itemCount items for collection: ${widget.collection.title}'; + setState(() { + isLoading = true; + errorMessage = null; + items = []; + resetPaginationState(); + }); + try { + await loadInitialPage(_pageSize); + if (!mounted) return; + // Mirror loadedItems into base-class [items] once so state-sliver checks + // (items.isEmpty vs items.isEmpty && isLoading) pick the right branch. + // Further pages only update loadedItems; items.isEmpty stays false. + setState(() { + items = loadedItems.values.toList(); + isLoading = false; + }); + appLogger.d('Loaded ${loadedItems.length} of $totalSize items for collection: ${widget.collection.title}'); + autoFocusFirstItemAfterLoad(); + } catch (e) { + appLogger.e('Failed to load collection items', error: e); + if (!mounted) return; + setState(() { + errorMessage = t.collections.failedToLoadItems(error: e.toString()); + isLoading = false; + }); + } } @override @@ -75,7 +107,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen((p) => p.hasSyncRule(widget.collection.globalKey)); return [ - if (items.isNotEmpty) ...[ + if (hasItems) ...[ FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), ], @@ -101,17 +133,19 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _downloadCollection() async { - if (items.isEmpty) { + if (!hasItems) { showErrorSnackBar(context, t.collections.empty); return; } final downloadProvider = context.read(); try { + final allItems = await client.fetchAllCollectionItems(widget.collection.ratingKey); + if (!mounted) return; final result = await showCollectionDownloadOptionsAndQueue( context, collectionMetadata: widget.collection, - items: items, + items: allItems, client: client, downloadProvider: downloadProvider, ); @@ -140,8 +174,8 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _deleteCollection() async { int? sectionId = widget.collection.librarySectionID; - if (sectionId == null && items.isNotEmpty) { - sectionId = items.first.librarySectionID; + if (sectionId == null && loadedItems.isNotEmpty) { + sectionId = loadedItems.values.first.librarySectionID; } if (sectionId == null) { @@ -185,10 +219,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadedItems[index], onRefresh: updateItem, + onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize), collectionId: widget.collection.ratingKey, onListRefresh: loadItems, ), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 155b0c02..100c8efe 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -4,11 +4,13 @@ import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; +import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart' show ViewMode; import '../utils/grid_size_calculator.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; +import '../widgets/skeleton_media_card.dart'; /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. @@ -224,4 +226,76 @@ mixin FocusableDetailScreenMixin on State, GridFocu }, ); } + + /// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems] + /// slots; for each, [itemAt] returns the loaded item or null if not yet + /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so + /// the caller can kick off a page fetch containing that index. + Widget buildSparseFocusableGrid({ + required int totalItems, + required PlexMetadata? Function(int index) itemAt, + required void Function(String ratingKey) onRefresh, + void Function(int index)? onSkeletonVisible, + String? collectionId, + VoidCallback? onListRefresh, + }) { + return Consumer( + builder: (context, settingsProvider, child) { + final isListMode = settingsProvider.viewMode == ViewMode.list; + + Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) { + final item = itemAt(index); + if (item == null) { + onSkeletonVisible?.call(index); + return const SkeletonMediaCard(); + } + final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + focusNode: focusNode, + disableScale: disableScale, + onRefresh: onRefresh, + collectionId: collectionId, + onListRefresh: onListRefresh, + onNavigateUp: inFirstRow ? navigateToAppBar : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + ); + } + + if (isListMode) { + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverList.builder( + itemCount: totalItems, + itemBuilder: (context, index) => buildTile(index, inFirstRow: index == 0, disableScale: true), + ), + ); + } + + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); + return SliverGrid.builder( + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: settingsProvider.libraryDensity, + ), + itemCount: totalItems, + itemBuilder: (context, index) => buildTile( + index, + inFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), + disableScale: false, + ), + ); + }, + ), + ); + }, + ); + } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 4b766907..6a56b411 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -23,7 +23,6 @@ import '../alpha_jump_bar.dart'; import '../alpha_jump_helper.dart'; import '../alpha_scroll_handle.dart'; import '../../../widgets/focusable_media_card.dart'; -import '../../../widgets/media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/overlay_sheet.dart'; @@ -39,6 +38,8 @@ import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMod import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; import '../../../mixins/deletion_aware.dart'; +import '../../../mixins/paginated_item_loader.dart'; +import '../../../widgets/skeleton_media_card.dart'; import '../../../utils/deletion_notifier.dart'; import '../../../utils/global_key_utils.dart'; import '../../../utils/platform_detector.dart'; @@ -65,7 +66,7 @@ class LibraryBrowseTab extends BaseLibraryTab { } class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware { + with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader { @override PlexClient get client => getClientForLibrary(); @@ -76,14 +77,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; @override - Set? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet(); + Set? get deletionRatingKeys => loadedItems.values.map((e) => e.ratingKey).toSet(); @override Set? get deletionGlobalKeys { - if (_loadedItems.isEmpty) return {}; + if (loadedItems.isEmpty) return {}; final keys = {}; - for (final item in _loadedItems.values) { + for (final item in loadedItems.values) { final serverId = item.serverId ?? widget.library.serverId; if (serverId == null) return null; keys.add(_toGlobalKey(item.ratingKey, serverId: serverId)); @@ -94,10 +95,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState e.value.ratingKey == event.ratingKey).firstOrNull; + final matchEntry = loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull; if (matchEntry != null) { setState(() { - _removeLoadedItemAndShift(matchEntry.key); + removeLoadedItemAndShift(matchEntry.key); }); return; } @@ -106,17 +107,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState e.value.ratingKey == parentKey).firstOrNull; + final parentEntry = loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull; if (parentEntry != null) { final item = parentEntry.value; final newLeafCount = (item.leafCount ?? 1) - event.leafCount; if (newLeafCount <= 0) { setState(() { - _removeLoadedItemAndShift(parentEntry.key); + removeLoadedItemAndShift(parentEntry.key); }); } else { setState(() { - _loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount); + loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount); }); } return; @@ -126,39 +127,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState{}; - for (final entry in _loadedItems.entries) { - if (entry.key < index) { - shifted[entry.key] = entry.value; - } else { - shifted[entry.key - 1] = entry.value; - } - } - _loadedItems.clear(); - _loadedItems.addAll(shifted); - _totalSize = (_totalSize - 1).clamp(0, _totalSize); + // The grid self-corrects when the next page fetch updates totalSize from + // the server response on the next scroll. } @override String get focusNodeDebugLabel => 'browse_first_item'; @override - int get itemCount => _totalSize; + int get itemCount => totalSize; @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { setState(() { - for (final entry in _loadedItems.entries) { + for (final entry in loadedItems.entries) { if (entry.value.ratingKey == ratingKey) { - _loadedItems[entry.key] = updatedMetadata; + loadedItems[entry.key] = updatedMetadata; break; } } @@ -194,25 +178,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _isScrollActive = ValueNotifier(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 _loadedItems = {}; - final Set _loadingRanges = {}; - AbortController? _cancelToken; - int _requestId = 0; + /// Generation counter for the filter/sort loading phase of [_loadContent]. + /// Separate from the mixin's pagination generation so a filter reload can + /// invalidate in-flight filter/sort fetches without touching item pagination. + int _contentRequestId = 0; int _firstCharactersRequestId = 0; static const int _fetchSize = 200; Timer? _scrollIdleTimer; - Timer? _retryTimer; - int _retryCount = 0; bool _rangeLoadScheduled = false; - bool _visibleRangeLoading = false; // Focus nodes for filter chips final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); @@ -230,8 +207,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadContent() async { - // Cancel any pending request - _cancelToken?.abort(); - _retryTimer?.cancel(); - _cancelToken = AbortController(); - // Use a generation counter for the filter/sort loading phase - final generation = ++_requestId; + final generation = ++_contentRequestId; final firstCharactersGeneration = ++_firstCharactersRequestId; _resetForFullReload(); @@ -370,9 +341,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadItems() async { - final currentRequestId = ++_requestId; - _cancelToken?.abort(); - _retryTimer?.cancel(); - _cancelToken = AbortController(); - + final generation = _contentRequestId; setState(() { isLoading = true; items = []; - _totalSize = 0; - _loadedItems.clear(); - _loadingRanges.clear(); + resetPaginationState(); // Increment content version when loading fresh content // This invalidates the last focused index gridContentVersion++; @@ -476,27 +439,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _fetchRange(int start, int size) async { - // Clamp to totalSize - if (start >= _totalSize) return false; - final clampedSize = size.clamp(0, _totalSize - start); - if (clampedSize == 0) return false; - - // Deduplicate: track every index in-flight to prevent overlapping fetches - final indices = List.generate(clampedSize, (i) => start + i); - if (indices.every((i) => _loadingRanges.contains(i) || _loadedItems.containsKey(i))) return true; - _loadingRanges.addAll(indices); - - final currentRequestId = _requestId; - - try { - final client = getClientForLibrary(); - final filterParams = _buildFilterParams(); - - final result = await client.getLibraryContent( - widget.library.key, - start: start, - size: clampedSize, - filters: filterParams, - abort: _cancelToken, - ); - - if (currentRequestId != _requestId || !mounted) return false; - + if (generation != _contentRequestId || !mounted) return; setState(() { - for (var i = 0; i < result.items.length; i++) { - _loadedItems[start + i] = result.items[i]; - } - if (result.totalSize != _totalSize) { - _totalSize = result.totalSize; - } + errorMessage = _getErrorMessage(e); + isLoading = false; }); - - _retryCount = 0; - _prefetchImages(start, result.items); - return true; - } catch (e) { - if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false; - _retryCount++; - final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4))); - _retryTimer?.cancel(); - _retryTimer = Timer(delay, () { - if (mounted && currentRequestId == _requestId) { - _loadVisibleRange(); - } - }); - return false; - } finally { - _loadingRanges.removeAll(indices); } } - void _handleLoadError(dynamic error, int currentRequestId) { - if (currentRequestId != _requestId) return; + @override + Future fetchPage(int start, int size, AbortController? abort) { + return getClientForLibrary().getLibraryContent( + widget.library.key, + start: start, + size: size, + filters: _buildFilterParams(), + abort: abort, + ); + } - setState(() { - errorMessage = _getErrorMessage(error); - isLoading = false; - }); + @override + void onPageLoaded(int start, List pageItems) { + _prefetchImages(start, pageItems); } String _getDefaultGrouping() { @@ -776,10 +676,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= 0; i--) { - if (_loadedItems.containsKey(i)) { + if (loadedItems.containsKey(i)) { found = i; break; } } // Then search forwards if (found == null) { - for (var i = targetIndex + 1; i < _totalSize; i++) { - if (_loadedItems.containsKey(i)) { + for (var i = targetIndex + 1; i < totalSize; i++) { + if (loadedItems.containsKey(i)) { found = i; break; } @@ -905,17 +805,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; + final maxIndex = totalSize > 0 ? totalSize - 1 : 0; final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex); if (lastInRow != _currentFirstVisibleIndex.value) { _currentFirstVisibleIndex.value = lastInRow; @@ -970,7 +875,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; + final maxIndex = totalSize > 0 ? totalSize - 1 : 0; return (row * _currentColumnCount).clamp(0, maxIndex); } @@ -983,7 +888,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0); + final clamped = targetIndex.clamp(0, totalSize > 0 ? totalSize - 1 : 0); _currentFirstVisibleIndex.value = clamped; _scrollToItemIndex(clamped); @@ -1126,103 +1031,29 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadVisibleRange() async { - if (_visibleRangeLoading) return; - if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return; - if (_lastCrossAxisExtent <= 0) return; - + /// Returns the first-visible index and visible count from the scroll + /// controller + grid metrics, or null if the viewport isn't measured yet. + ({int firstIndex, int visibleCount})? _computeVisibleRange() { + if (_currentColumnCount < 1 || !_scrollController.hasClients || _lastCrossAxisExtent <= 0) return null; final offset = _scrollController.offset; final viewportHeight = _scrollController.position.viewportDimension; - if (!viewportHeight.isFinite) return; - final firstIndex = _itemIndexFromScrollOffset(offset); + if (!viewportHeight.isFinite) return null; final itemWidth = _lastCrossAxisExtent / _currentColumnCount; final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing; - if (rowHeight <= 0) return; + if (rowHeight <= 0) return null; final visibleRows = (viewportHeight / rowHeight).ceil() + 1; - final visibleCount = visibleRows * _currentColumnCount; - - final buffer = _fetchSize ~/ 2; - final rangeStart = (firstIndex - buffer).clamp(0, _totalSize); - final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, _totalSize); - - int? fetchStart; - int? fetchEnd; - for (var i = rangeStart; i < rangeEnd; i++) { - if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) { - fetchStart ??= i; - fetchEnd = i + 1; - } - } - if (fetchStart == null || fetchEnd == null) return; - - _retryTimer?.cancel(); - _visibleRangeLoading = true; - try { - final success = await _fetchRange(fetchStart, fetchEnd - fetchStart); - if (success && mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadVisibleRange(); - }); - } - } finally { - _visibleRangeLoading = false; - } - } - - /// 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; - if (!viewportHeight.isFinite) return; - 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; - } - } + return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleRows * _currentColumnCount); } /// Compute initial fetch size based on viewport dimensions. @@ -1245,21 +1076,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 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; @@ -1391,11 +1207,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildContentSlivers() { - if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) { + if (isLoading && totalSize == 0 && loadedItems.isEmpty) { return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; } - if (errorMessage != null && _loadedItems.isEmpty) { + if (errorMessage != null && loadedItems.isEmpty) { return [ SliverFillRemaining( child: ErrorStateWidget( @@ -1408,7 +1224,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> fetchItems() async { - return await client.getPlaylist(widget.playlist.ratingKey); + return await client.fetchAllPlaylistItems(widget.playlist.ratingKey); } @override diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 2d1ed6ea..47f31848 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -599,26 +599,32 @@ class PlexClient { Map? filters, AbortController? abort, }) async { - final queryParams = {}; - if (start != null) queryParams['X-Plex-Container-Start'] = start; - if (size != null) queryParams['X-Plex-Container-Size'] = size; - - // Add filter parameters - if (filters != null) { - queryParams.addAll(filters); - } - + final queryParams = _buildPaginationParams(start, size); + if (filters != null) queryParams.addAll(filters); final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all'; - final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort); + return _extractLibraryContentResult(response); + } + Map _buildPaginationParams(int? start, int? size) { + final params = {}; + if (start != null) params['X-Plex-Container-Start'] = start; + if (size != null) params['X-Plex-Container-Size'] = size; + return params; + } + + LibraryContentResult _extractLibraryContentResult(PlexResponse response) { final items = _extractMetadataList(response); final container = _getMediaContainer(response); final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length; - return LibraryContentResult(items: items, totalSize: totalSize); } + Future _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}) async { + final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort); + return _extractLibraryContentResult(response); + } + /// Parse list of PlexMetadata from a cached response List _parseMetadataListFromCachedResponse(Map cached) { final metadataList = PlexCacheParser.extractMetadataList(cached); @@ -837,6 +843,33 @@ class PlexClient { } } + /// Page size for iterating all items via [_fetchAllPages]. Also the cap + /// for endpoints that send `X-Plex-Container-Size` but aren't truly paginated + /// (collections listing, playlists listing, search). + static const int _defaultListContainerSize = 1000; + + /// Page size used when walking all pages of a paginated endpoint. + static const int _fetchAllPageSize = 200; + + /// Iterate every page of a paginated endpoint and concatenate the results. + /// Stops as soon as [LibraryContentResult.totalSize] is reached or a page + /// returns no items. Errors propagate. + Future> _fetchAllPages( + Future Function(int start, int size, AbortController? abort) fetchPage, { + AbortController? abort, + }) async { + final all = []; + var start = 0; + while (true) { + final page = await fetchPage(start, _fetchAllPageSize, abort); + all.addAll(page.items); + start += page.items.length; + if (page.items.isEmpty) break; + if (start >= page.totalSize) break; + } + return all; + } + /// Parse audio/subtitle tracks and the video stream's frame rate from a /// raw Part.Stream list in a single pass. ({List audio, List subtitles, double? frameRate}) _parseStreams( @@ -1042,6 +1075,7 @@ class PlexClient { 'searchTypes': 'movies,tv', 'includeCollections': 1, 'includeExternalMedia': 1, + 'X-Plex-Container-Size': limit, }, ); @@ -1712,21 +1746,23 @@ class PlexClient { }, 'Failed to get hub content'); } - /// Get playlist content by playlist ID - /// Returns the list of metadata items in the playlist - Future> getPlaylist(String playlistId) { - return _wrapListApiCall( - () => _http.get('/playlists/$playlistId/items'), - _extractMetadataList, - 'Failed to get playlist', - ); - } + /// Get playlist content by playlist ID, paginated. + Future getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => + _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); + + /// Fetch every page of a playlist's items. For callers that need the full list + /// (downloads, sync rules, context-menu shuffle). + Future> fetchAllPlaylistItems(String playlistId) => + _fetchAllPages((start, size, abort) => getPlaylist(playlistId, start: start, size: size, abort: abort)); /// Get all playlists /// Filters by playlistType=video by default /// Set smart to true/false to filter smart playlists, or null for all Future> getPlaylists({String playlistType = 'video', bool? smart}) { - final queryParams = {'playlistType': playlistType}; + final queryParams = { + 'playlistType': playlistType, + 'X-Plex-Container-Size': _defaultListContainerSize, + }; if (smart != null) { queryParams['smart'] = smart ? '1' : '0'; } @@ -1965,7 +2001,10 @@ class PlexClient { /// Returns collections as PlexMetadata objects with type="collection" Future> getLibraryCollections(String sectionId) async { return _wrapListApiCall( - () => _http.get('/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1}), + () => _http.get( + '/library/sections/$sectionId/collections', + queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize}, + ), (response) { final allItems = _extractMetadataList(response); // Collections should have type="collection" @@ -1977,24 +2016,25 @@ class PlexClient { ); } - /// Get items in a collection - /// Returns the list of metadata items in the collection - Future> getCollectionItems(String collectionId) { - return _wrapListApiCall( - () => _http.get('/library/collections/$collectionId/children'), - _extractMetadataList, - 'Failed to get collection items', - ); - } + /// Get items in a collection, paginated. + Future getCollectionItems( + String collectionId, { + int? start, + int? size, + AbortController? abort, + }) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort); - /// Get media featuring a specific person (actor/director) - Future> getPersonMedia(String personId) { - return _wrapListApiCall( - () => _http.get('/library/people/$personId/media'), - _extractMetadataList, - 'Failed to get person media', - ); - } + /// Fetch every item in a collection (downloads, sync rules, context-menu shuffle). + Future> fetchAllCollectionItems(String collectionId) => + _fetchAllPages((start, size, abort) => getCollectionItems(collectionId, start: start, size: size, abort: abort)); + + /// Get media featuring a specific person (actor/director), paginated. + Future getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) => + _fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort); + + /// Fetch every media item featuring a given person. + Future> fetchAllPersonMedia(String personId) => + _fetchAllPages((start, size, abort) => getPersonMedia(personId, start: start, size: size, abort: abort)); /// Delete a collection /// Deletes a library collection from the server diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index c0eebbdd..0fa8e5c1 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -292,8 +292,8 @@ class SyncRuleExecutor { final List rootItems; try { rootItems = rule.targetType == ContentTypes.collection - ? await client.getCollectionItems(rule.ratingKey) - : await client.getPlaylist(rule.ratingKey); + ? await client.fetchAllCollectionItems(rule.ratingKey) + : await client.fetchAllPlaylistItems(rule.ratingKey); } catch (e) { appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e'); return null; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index f8ee89a6..7c514e12 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1168,7 +1168,7 @@ class MediaContextMenuState extends State { final client = _getClientForItem(); try { - final items = await client.getCollectionItems(collection.ratingKey); + final items = await client.fetchAllCollectionItems(collection.ratingKey); if (!context.mounted) return; final result = await showCollectionDownloadOptionsAndQueue( @@ -1200,7 +1200,7 @@ class MediaContextMenuState extends State { final client = _getClientForItem(); try { - final items = await client.getPlaylist(playlist.ratingKey); + final items = await client.fetchAllPlaylistItems(playlist.ratingKey); if (!context.mounted) return; final playlistMetadata = PlexMetadata( diff --git a/lib/widgets/skeleton_media_card.dart b/lib/widgets/skeleton_media_card.dart new file mode 100644 index 00000000..f3e55c3d --- /dev/null +++ b/lib/widgets/skeleton_media_card.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; + +import 'media_card.dart'; + +/// Placeholder that mirrors the poster + title + subtitle layout of a real +/// media card. Rendered in a sparse grid while items for that slot are in +/// flight. Not focusable — dpad navigation skips over these. +class SkeletonMediaCard extends StatelessWidget { + const SkeletonMediaCard({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: SkeletonLoader(child: SizedBox.expand()), + ), + ), + SizedBox(height: 4), + SkeletonLoader( + borderRadius: BorderRadius.all(Radius.circular(4)), + child: SizedBox(height: 13, width: double.infinity), + ), + SizedBox(height: 3), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: 0.6, + child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)), + ), + ], + ), + ); + } +}