fix: paginate collection/playlist/person endpoints

close #903
This commit is contained in:
edde746
2026-04-21 21:51:20 +02:00
parent e2be6a0093
commit eb49ead83b
10 changed files with 603 additions and 385 deletions
+251
View File
@@ -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<W extends StatefulWidget> on State<W> {
/// Sparse map of loaded items, keyed by position.
final Map<int, PlexMetadata> loadedItems = {};
/// Total items on the server. 0 until the first page completes.
int totalSize = 0;
final Set<int> _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<LibraryContentResult> 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<PlexMetadata> 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<LibraryContentResult> 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<void> 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 = <int, PlexMetadata>{};
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<bool> _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);
}
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getPersonMedia(widget.personId);
return await client.fetchAllPersonMedia(widget.personId);
}
@override
+64 -28
View File
@@ -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<CollectionDetailScreen>
with
StandardItemLoader<CollectionDetailScreen>,
GridFocusNodeMixin<CollectionDetailScreen>,
FocusableDetailScreenMixin<CollectionDetailScreen> {
FocusableDetailScreenMixin<CollectionDetailScreen>,
PaginatedItemLoader<CollectionDetailScreen> {
static const int _pageSize = 200;
@override
PlexMetadata get mediaItem => widget.collection;
@@ -39,33 +44,60 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
String get emptyMessage => t.collections.empty;
@override
bool get hasItems => items.isNotEmpty;
bool get hasItems => totalSize > 0;
@override
void dispose() {
disposePagination();
disposeFocusResources();
super.dispose();
}
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getCollectionItems(widget.collection.ratingKey);
Future<LibraryContentResult> 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<void> 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<CollectionD
final hasRule = context.select<DownloadProvider, bool>((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<CollectionD
}
Future<void> _downloadCollection() async {
if (items.isEmpty) {
if (!hasItems) {
showErrorSnackBar(context, t.collections.empty);
return;
}
final downloadProvider = context.read<DownloadProvider>();
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<CollectionD
Future<void> _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<CollectionD
slivers: [
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
...buildStateSlivers(),
if (items.isNotEmpty)
buildFocusableGrid(
items: items,
if (hasItems)
buildSparseFocusableGrid(
totalItems: totalSize,
itemAt: (index) => loadedItems[index],
onRefresh: updateItem,
onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize),
collectionId: widget.collection.ratingKey,
onListRefresh: loadItems,
),
@@ -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<T extends StatefulWidget> on State<T>, 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<SettingsProvider>(
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,
),
);
},
),
);
},
);
}
}
@@ -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<PlexMetadata> {
}
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware {
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader<LibraryBrowseTab> {
@override
PlexClient get client => getClientForLibrary();
@@ -76,14 +77,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
String? get deletionServerId => widget.library.serverId;
@override
Set<String>? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet();
Set<String>? get deletionRatingKeys => loadedItems.values.map((e) => e.ratingKey).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (_loadedItems.isEmpty) return <String>{};
if (loadedItems.isEmpty) return <String>{};
final keys = <String>{};
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<PlexMetadata, LibraryBr
@override
void onDeletionEvent(DeletionEvent event) {
// If we have an item that matches the rating key exactly, remove it and rebuild indices
final matchEntry = _loadedItems.entries.where((e) => 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<PlexMetadata, LibraryBr
// If all children were deleted, remove our item.
// Otherwise, just update the counts.
for (final parentKey in event.parentChain) {
final parentEntry = _loadedItems.entries.where((e) => 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<PlexMetadata, LibraryBr
// If neither the item nor its parents are loaded (evicted), the event
// was already filtered by DeletionAware's upstream check against
// deletionGlobalKeys/deletionRatingKeys, so this point is unreachable.
// The grid self-corrects when _fetchRange updates _totalSize from the
// server response on the next scroll.
}
/// Remove an item at [index] and shift all higher indices down by 1
void _removeLoadedItemAndShift(int index) {
_loadedItems.remove(index);
// Rebuild map with shifted indices for items above the removed one
final shifted = <int, PlexMetadata>{};
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<PlexMetadata, LibraryBr
final ValueNotifier<bool> _isScrollActive = ValueNotifier<bool>(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 = {};
final Set<int> _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<PlexMetadata, LibraryBr
@override
void dispose() {
_cancelToken?.abort();
_retryTimer?.cancel();
disposePagination();
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_alphaUpdateTimer?.cancel();
@@ -247,14 +223,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
super.dispose();
}
// Override tryFocus to use _loadedItems instead of base class items list
// Override tryFocus to use loadedItems instead of base class items list
@override
void tryFocus() {
if (widget.suppressAutoFocus) return;
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
if (widget.isActive && hasLoadedData && !hasFocused && loadedItems.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusFirstItem();
@@ -306,11 +282,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (_loadedItems.isNotEmpty) {
if (loadedItems.isNotEmpty) {
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && _loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
if (mounted && loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -353,12 +329,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
Future<void> _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<PlexMetadata, LibraryBr
isLoading = true;
errorMessage = null;
items = [];
_totalSize = 0;
_loadedItems.clear();
_loadingRanges.clear();
resetPaginationState();
// Clear filter/sort state while loading to prevent showing stale options
_filters = [];
_sortOptions = [];
@@ -398,7 +367,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
// Check if request was superseded
if (generation != _requestId) return;
if (generation != _contentRequestId) return;
if (!mounted) return;
setState(() {
@@ -458,17 +427,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
Future<void> _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<PlexMetadata, LibraryBr
});
try {
// Use server-specific client for this library
final client = getClientForLibrary();
final filterParams = _buildFilterParams();
await loadInitialPage(_calculateInitialFetchSize());
// Items are automatically tagged with server info by PlexClient
final result = await client.getLibraryContent(
widget.library.key,
start: 0,
size: _calculateInitialFetchSize(),
filters: filterParams,
abort: _cancelToken,
);
if (currentRequestId != _requestId) return;
if (!mounted) return;
if (generation != _contentRequestId || !mounted) return;
setState(() {
_totalSize = result.totalSize;
for (var i = 0; i < result.items.length; i++) {
_loadedItems[i] = result.items[i];
}
isLoading = false;
});
@@ -510,74 +456,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
});
}
} catch (e) {
_handleLoadError(e, currentRequestId);
}
}
/// Fetch a range of items from the API and store them in the sparse map.
/// After a successful fetch, re-checks for remaining gaps in the visible range.
Future<bool> _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<LibraryContentResult> 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<PlexMetadata> pageItems) {
_prefetchImages(start, pageItems);
}
String _getDefaultGrouping() {
@@ -776,10 +676,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (_totalSize == 0) return;
if (totalSize == 0) return;
final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!)
shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
? lastFocusedGridIndex!
: 0;
@@ -796,25 +696,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// FocusNode detached), so we target the last-column item in the first
/// visible row — the grid cell closest to the alpha bar.
void _navigateToGridNearScroll() {
if (_totalSize == 0 || _currentColumnCount < 1) return;
if (totalSize == 0 || _currentColumnCount < 1) return;
final row = _currentFirstVisibleIndex.value ~/ _currentColumnCount;
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, totalSize - 1);
// Find nearest loaded item — skeleton cards have no FocusNode
if (!_loadedItems.containsKey(targetIndex)) {
if (!loadedItems.containsKey(targetIndex)) {
// Search backwards first (items above are more likely visible)
int? found;
for (var i = targetIndex - 1; i >= 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<PlexMetadata, LibraryBr
_scrollIdleTimer?.cancel();
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
if (!mounted) return;
// Discard stale in-flight tracking from eager prefetch during scroll
// so _loadVisibleRange sees the full gap at the settled position.
_loadingRanges.clear();
_loadVisibleRange();
_evictDistantItems();
final firstVisible = _itemIndexFromScrollOffset(_scrollController.offset);
evictDistantFocusNodes(firstVisible);
// Discard stale in-flight tracking from eager prefetch during scroll so
// ensureRangeLoaded sees the full gap at the settled position.
clearPendingRanges();
final range = _computeVisibleRange();
if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _fetchSize ~/ 2);
evictDistantItems(range.firstIndex, maxKeep: 500, threshold: 600);
evictDistantFocusNodes(range.firstIndex);
}
});
// Eager prefetch: fetch data before scroll stops
_checkEagerPrefetch();
final range = _computeVisibleRange();
if (range != null) {
prefetchAhead(range.firstIndex, range.visibleCount, pageSize: _fetchSize);
}
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
@@ -948,7 +853,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final firstInRow = _itemIndexFromScrollOffset(offset);
// Use the last item in the first visible row so the highlighted letter
// updates as soon as items with a new letter appear in that row.
final maxIndex = _totalSize > 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<PlexMetadata, LibraryBr
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
final row = (contentOffset / rowHeight).floor();
final maxIndex = _totalSize > 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<PlexMetadata, LibraryBr
_isJumpScrolling = true;
_hasJumpPin = true;
final clamped = targetIndex.clamp(0, _totalSize > 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<PlexMetadata, LibraryBr
_rangeLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_rangeLoadScheduled = false;
if (mounted) _loadVisibleRange();
if (!mounted) return;
final range = _computeVisibleRange();
if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _fetchSize ~/ 2);
}
});
}
/// Determine the visible range and fetch any unloaded items within it.
/// Serialized: only one visible-range fetch runs at a time to prevent
/// concurrent calls from seeing each other's _loadingRanges and producing
/// tiny fetch sizes.
Future<void> _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<PlexMetadata, LibraryBr
}
}
/// 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;
@@ -1391,11 +1207,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds content as slivers for the CustomScrollView
List<Widget> _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<PlexMetadata, LibraryBr
];
}
if (_totalSize == 0 && !isLoading) {
if (totalSize == 0 && !isLoading) {
return [
SliverFillRemaining(
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
@@ -1436,7 +1252,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
final itemCount = _totalSize;
final itemCount = totalSize;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
_effectiveTopPadding = topPadding;
@@ -1497,12 +1313,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
bool isLastColumn = false,
bool disableScale = false,
}) {
final item = _loadedItems[index];
final item = loadedItems[index];
// Show skeleton placeholder for unloaded items
if (item == null) {
_scheduleRangeLoad();
return const _SkeletonCard();
return const SkeletonMediaCard();
}
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
@@ -1524,41 +1340,3 @@ 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();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster area — matches the Expanded poster in _buildGridCard
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: SkeletonLoader(child: SizedBox.expand()),
),
),
SizedBox(height: 4),
// Title bar
SkeletonLoader(
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(height: 13, width: double.infinity),
),
SizedBox(height: 3),
// Subtitle bar
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.6,
child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)),
),
],
),
);
}
}
@@ -137,7 +137,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getPlaylist(widget.playlist.ratingKey);
return await client.fetchAllPlaylistItems(widget.playlist.ratingKey);
}
@override
+79 -39
View File
@@ -599,26 +599,32 @@ class PlexClient {
Map<String, String>? filters,
AbortController? abort,
}) async {
final queryParams = <String, dynamic>{};
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<String, dynamic> _buildPaginationParams(int? start, int? size) {
final params = <String, dynamic>{};
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<LibraryContentResult> _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<PlexMetadata> _parseMetadataListFromCachedResponse(Map<String, dynamic> 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<List<PlexMetadata>> _fetchAllPages(
Future<LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
AbortController? abort,
}) async {
final all = <PlexMetadata>[];
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<PlexAudioTrack> audio, List<PlexSubtitleTrack> 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<List<PlexMetadata>> getPlaylist(String playlistId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/playlists/$playlistId/items'),
_extractMetadataList,
'Failed to get playlist',
);
}
/// Get playlist content by playlist ID, paginated.
Future<LibraryContentResult> 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<List<PlexMetadata>> 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<List<PlexPlaylist>> getPlaylists({String playlistType = 'video', bool? smart}) {
final queryParams = <String, dynamic>{'playlistType': playlistType};
final queryParams = <String, dynamic>{
'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<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
return _wrapListApiCall<PlexMetadata>(
() => _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<List<PlexMetadata>> getCollectionItems(String collectionId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/library/collections/$collectionId/children'),
_extractMetadataList,
'Failed to get collection items',
);
}
/// Get items in a collection, paginated.
Future<LibraryContentResult> 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<List<PlexMetadata>> getPersonMedia(String personId) {
return _wrapListApiCall<PlexMetadata>(
() => _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<List<PlexMetadata>> 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<LibraryContentResult> 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<List<PlexMetadata>> 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
+2 -2
View File
@@ -292,8 +292,8 @@ class SyncRuleExecutor {
final List<PlexMetadata> 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;
+2 -2
View File
@@ -1168,7 +1168,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
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(
+39
View File
@@ -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)),
),
],
),
);
}
}