fix(media): paginate large server lists
This commit is contained in:
@@ -167,6 +167,17 @@ abstract class MediaServerClient {
|
||||
/// show, tracks of an album, items of a collection.
|
||||
Future<List<MediaItem>> fetchChildren(String parentId);
|
||||
|
||||
/// Page through playable descendants of [parentId]. Used by large show /
|
||||
/// season detail views so episodes can render before the full list has
|
||||
/// loaded. [fetchPlayableDescendants] remains the complete-list helper for
|
||||
/// playback/download/sync paths.
|
||||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||||
String parentId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Playable descendants of [parentId] in one server-side query — for a
|
||||
/// show this returns every episode across every season; for a season the
|
||||
/// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
|
||||
@@ -228,12 +239,19 @@ abstract class MediaServerClient {
|
||||
/// Media featuring a specific person/actor.
|
||||
Future<List<MediaItem>> fetchPersonMedia(String personId);
|
||||
|
||||
/// Page through media featuring a specific person/actor.
|
||||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(String personId, {int? start, int? size, AbortController? abort});
|
||||
|
||||
/// Page through items in [hubId] when the hub previewed only the first N
|
||||
/// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
|
||||
/// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
|
||||
/// (Latest / Resume / NextUp) without the preview limit.
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit});
|
||||
|
||||
/// Page through expanded hub content where the backend exposes a paged
|
||||
/// endpoint. Backends without true hub pagination may return a single page.
|
||||
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(String hubId, {int? start, int? size, AbortController? abort});
|
||||
|
||||
/// Mark [item] as watched. The full item is passed (not just an id) so
|
||||
/// implementations can fire a [WatchStateEvent] on [WatchStateNotifier]
|
||||
/// for UI invalidation — episode/season/show parent chain, library
|
||||
@@ -255,11 +273,26 @@ abstract class MediaServerClient {
|
||||
|
||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart});
|
||||
|
||||
/// Page through server playlists. Used by the library Playlists tab so large
|
||||
/// servers can render incrementally; [fetchPlaylists] remains the complete
|
||||
/// list helper for dialogs and bulk operations.
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Metadata only — items are fetched via [fetchPlaylistItems].
|
||||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id);
|
||||
|
||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100});
|
||||
|
||||
/// Page through items in [id]. Backends preserve playlist order and include
|
||||
/// per-playlist item ids where the server exposes them.
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort});
|
||||
|
||||
/// Create a new playlist seeded with [items]. Returns the created
|
||||
/// playlist on success, `null` on failure. Plex builds a metadata URI
|
||||
/// from the item ids; Jellyfin posts `Ids=<comma-joined>`.
|
||||
@@ -293,15 +326,26 @@ abstract class MediaServerClient {
|
||||
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});
|
||||
|
||||
/// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
|
||||
/// Jellyfin resolves its top-level `boxsets` view and queries that root.
|
||||
/// Jellyfin resolves its top-level `boxsets` view and walks that root in
|
||||
/// bounded pages.
|
||||
/// Each result carries `kind == MediaKind.collection`.
|
||||
Future<List<MediaItem>> fetchCollections(String libraryId);
|
||||
|
||||
/// Page through collections in [libraryId]. Used by the library Collections
|
||||
/// tab so large Jellyfin/Plex servers can render incrementally while the
|
||||
/// user scrolls. [fetchCollections] remains the complete-list helper for
|
||||
/// dialogs and bulk operations.
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||||
String libraryId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Page through items in [collectionId]. Plex paginates server-side via
|
||||
/// `/library/collections/{id}/children`; Jellyfin's API has no
|
||||
/// pagination knob for collection children, so its impl fetches the full
|
||||
/// list once (cached on the client) and slices locally. Callers can rely
|
||||
/// on [LibraryPage.totalCount] either way.
|
||||
/// `/library/collections/{id}/children`; Jellyfin uses `/Items` with
|
||||
/// `ParentId`, `StartIndex`, and `Limit`. Callers can rely on
|
||||
/// [LibraryPage.totalCount] either way.
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
int? start,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
|
||||
@@ -20,9 +19,9 @@ import '../exceptions/media_server_exceptions.dart';
|
||||
/// 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> {
|
||||
mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
/// Sparse map of loaded items, keyed by position.
|
||||
final Map<int, MediaItem> loadedItems = {};
|
||||
final Map<int, T> loadedItems = {};
|
||||
|
||||
/// Total items on the server. 0 until the first page completes.
|
||||
int totalSize = 0;
|
||||
@@ -43,12 +42,12 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
VoidCallback? _scheduledRetry;
|
||||
|
||||
/// Fetch a page of items. Subclass implements this — typically delegating
|
||||
/// to a paginated client method that returns a [LibraryPage] of [MediaItem].
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort);
|
||||
/// to a paginated client method that returns a [LibraryPage].
|
||||
Future<LibraryPage<T>> 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<MediaItem> items) {}
|
||||
void onPageLoaded(int _, List<T> __) {}
|
||||
|
||||
/// Synchronously clear pagination state and bump the generation counter.
|
||||
/// Call from inside the subclass's `setState` before awaiting
|
||||
@@ -69,17 +68,32 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
|
||||
/// Fetch the first page. Await from outside `setState`. Mutates
|
||||
/// [loadedItems] and [totalSize] on success; throws on failure.
|
||||
Future<LibraryPage<MediaItem>> loadInitialPage(int pageSize) async {
|
||||
Future<LibraryPage<T>> loadInitialPage(int pageSize) async {
|
||||
final result = await loadInitialPageWithStatus(pageSize);
|
||||
return result.page;
|
||||
}
|
||||
|
||||
/// Like [loadInitialPage], but reports whether the fetched page was still
|
||||
/// current and actually merged into [loadedItems].
|
||||
Future<({LibraryPage<T> page, bool applied})> loadInitialPageWithStatus(int pageSize) async {
|
||||
final generation = _requestId;
|
||||
final result = await fetchPage(0, pageSize, _cancelToken);
|
||||
if (generation != _requestId || !mounted) return result;
|
||||
late final LibraryPage<T> result;
|
||||
try {
|
||||
result = await fetchPage(0, pageSize, _cancelToken);
|
||||
} catch (_) {
|
||||
if (generation != _requestId || !mounted) {
|
||||
return (page: LibraryPage<T>(items: const [], totalCount: 0), applied: false);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (generation != _requestId || !mounted) return (page: result, applied: false);
|
||||
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[i] = result.items[i];
|
||||
}
|
||||
totalSize = result.totalCount;
|
||||
onPageLoaded(0, result.items);
|
||||
return result;
|
||||
return (page: result, applied: true);
|
||||
}
|
||||
|
||||
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
||||
@@ -160,7 +174,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// [totalSize] even if [index] wasn't in the sparse map (evicted).
|
||||
void removeLoadedItemAndShift(int index) {
|
||||
loadedItems.remove(index);
|
||||
final shifted = <int, MediaItem>{};
|
||||
final shifted = <int, T>{};
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.key > index) {
|
||||
shifted[entry.key - 1] = entry.value;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/optimized_media_image.dart';
|
||||
@@ -41,9 +45,11 @@ class ActorMediaScreen extends StatefulWidget {
|
||||
|
||||
class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
with
|
||||
StandardItemLoader<ActorMediaScreen>,
|
||||
GridFocusNodeMixin<ActorMediaScreen>,
|
||||
FocusableDetailScreenMixin<ActorMediaScreen> {
|
||||
FocusableDetailScreenMixin<ActorMediaScreen>,
|
||||
PaginatedItemLoader<MediaItem, ActorMediaScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
MediaItem get mediaItem => MediaItem(
|
||||
id: '',
|
||||
@@ -63,10 +69,11 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
String get emptyMessage => t.discover.noContentAvailable;
|
||||
|
||||
@override
|
||||
bool get hasItems => items.isNotEmpty;
|
||||
bool get hasItems => totalSize > 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposePagination();
|
||||
disposeFocusResources();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -74,12 +81,46 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId);
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchItems() => _mediaClient.fetchPersonMedia(widget.personId);
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
return _mediaClient.fetchPersonMediaPage(widget.personId, start: start, size: size, abort: abort);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.id == itemId) {
|
||||
loadedItems[entry.key] = updatedItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
await super.loadItems();
|
||||
autoFocusFirstItemAfterLoad();
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
||||
if (!initialPage.applied || !mounted) return;
|
||||
setState(() {
|
||||
items = loadedItems.values.toList();
|
||||
isLoading = false;
|
||||
});
|
||||
appLogger.d('Loaded ${loadedItems.length} of $totalSize items for actor: ${widget.actorName}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to load actor media', error: e, stackTrace: st);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorMessage = t.messages.errorLoading(error: e.toString());
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -126,10 +167,10 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (items.isNotEmpty) ...[
|
||||
if (totalSize > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${items.length} ${items.length == 1 ? 'title' : 'titles'}',
|
||||
'$totalSize ${totalSize == 1 ? 'title' : 'titles'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
@@ -149,7 +190,13 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()),
|
||||
_buildActorHeader(),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem),
|
||||
if (hasItems)
|
||||
buildSparseFocusableGrid(
|
||||
totalItems: totalSize,
|
||||
itemAt: (index) => loadedItems[index],
|
||||
onRefresh: updateItem,
|
||||
onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
with
|
||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||
PaginatedItemLoader<CollectionDetailScreen> {
|
||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
@@ -92,8 +92,8 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
resetPaginationState();
|
||||
});
|
||||
try {
|
||||
await loadInitialPage(_pageSize);
|
||||
if (!mounted) return;
|
||||
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
||||
if (!initialPage.applied || !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.
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../media/media_sort.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
@@ -45,13 +48,20 @@ class HubDetailScreen extends StatefulWidget {
|
||||
|
||||
class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
List<MediaItem> _items = [];
|
||||
List<MediaItem> _filteredItems = [];
|
||||
List<MediaSort> _sortOptions = [];
|
||||
MediaSort? _selectedSort;
|
||||
bool _isSortDescending = false;
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingMore = false;
|
||||
String? _errorMessage;
|
||||
String? _continuationErrorMessage;
|
||||
int? _continuationOffset;
|
||||
int? _continuationTotal;
|
||||
int _loadGeneration = 0;
|
||||
|
||||
/// Key for getting a context below OverlaySheetHost
|
||||
final GlobalKey _overlayChildKey = GlobalKey();
|
||||
@@ -225,6 +235,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
Future<void> _loadMoreItems() async {
|
||||
if (_isLoading) return;
|
||||
final generation = ++_loadGeneration;
|
||||
|
||||
final serverId = widget.hub.serverId;
|
||||
if (widget.loadItems == null && serverId == null) {
|
||||
@@ -234,23 +245,33 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoadingMore = false;
|
||||
_errorMessage = null;
|
||||
_continuationErrorMessage = null;
|
||||
_continuationOffset = null;
|
||||
_continuationTotal = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final loader = widget.loadItems;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
||||
var items = loader == null
|
||||
? (client == null ? const <MediaItem>[] : await client.fetchMoreHubItems(widget.hub.id))
|
||||
: await loader();
|
||||
|
||||
// Filter to specific library if this hub was split from a multi-library hub
|
||||
final sectionFilter = int.tryParse(widget.hub.libraryId ?? '');
|
||||
if (sectionFilter != null) {
|
||||
items = items.where((item) => int.tryParse(item.libraryId ?? '') == sectionFilter).toList();
|
||||
final List<MediaItem> items;
|
||||
int totalCount;
|
||||
int loadedCount;
|
||||
if (loader == null) {
|
||||
final page = client == null
|
||||
? const LibraryPage<MediaItem>(items: [], totalCount: 0)
|
||||
: await client.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize);
|
||||
items = _applySectionFilter(page.items);
|
||||
totalCount = page.totalCount;
|
||||
loadedCount = page.items.length;
|
||||
} else {
|
||||
items = _applySectionFilter(await loader());
|
||||
totalCount = items.length;
|
||||
loadedCount = items.length;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_filteredItems = items;
|
||||
@@ -258,6 +279,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
});
|
||||
|
||||
_applySort();
|
||||
if (loader == null && client != null && loadedCount < totalCount) {
|
||||
if (client.backend == MediaBackend.plex) {
|
||||
unawaited(_loadFullHubContent(client, generation));
|
||||
} else {
|
||||
unawaited(_loadRemainingHubPages(client, generation, loadedCount, totalCount));
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
|
||||
} catch (e) {
|
||||
@@ -270,6 +298,106 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFullHubContent(MediaServerClient client, int generation) async {
|
||||
if (mounted && generation == _loadGeneration) {
|
||||
setState(() {
|
||||
_isLoadingMore = true;
|
||||
_continuationErrorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final items = _applySectionFilter(await client.fetchMoreHubItems(widget.hub.id));
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
if (items.isEmpty && _items.isNotEmpty) {
|
||||
throw StateError('Hub continuation returned no items');
|
||||
}
|
||||
setState(() {
|
||||
_items = items;
|
||||
_filteredItems = items;
|
||||
_isLoadingMore = false;
|
||||
_continuationErrorMessage = null;
|
||||
_continuationOffset = null;
|
||||
_continuationTotal = null;
|
||||
});
|
||||
_applySort();
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to finish loading hub content', error: e, stackTrace: st);
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
setState(() {
|
||||
_isLoadingMore = false;
|
||||
_continuationErrorMessage = t.messages.errorLoading(error: e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRemainingHubPages(MediaServerClient client, int generation, int startOffset, int totalCount) async {
|
||||
var offset = startOffset;
|
||||
var total = totalCount;
|
||||
if (mounted && generation == _loadGeneration) {
|
||||
setState(() {
|
||||
_isLoadingMore = true;
|
||||
_continuationErrorMessage = null;
|
||||
_continuationOffset = offset;
|
||||
_continuationTotal = total;
|
||||
});
|
||||
}
|
||||
try {
|
||||
while (offset < total) {
|
||||
final page = await client.fetchMoreHubItemsPage(widget.hub.id, start: offset, size: _pageSize);
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
if (page.items.isEmpty) break;
|
||||
final items = _applySectionFilter(page.items);
|
||||
setState(() {
|
||||
_items.addAll(items);
|
||||
_filteredItems = List.of(_items);
|
||||
});
|
||||
_applySort();
|
||||
offset += page.items.length;
|
||||
total = page.totalCount;
|
||||
_continuationOffset = offset;
|
||||
_continuationTotal = total;
|
||||
}
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
setState(() {
|
||||
_isLoadingMore = false;
|
||||
_continuationErrorMessage = null;
|
||||
_continuationOffset = null;
|
||||
_continuationTotal = null;
|
||||
});
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to finish loading hub content', error: e, stackTrace: st);
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
setState(() {
|
||||
_isLoadingMore = false;
|
||||
_continuationErrorMessage = t.messages.errorLoading(error: e.toString());
|
||||
_continuationOffset = offset;
|
||||
_continuationTotal = total;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _retryHubContinuation() {
|
||||
final serverId = widget.hub.serverId;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
||||
if (client == null || _isLoadingMore) return;
|
||||
final generation = _loadGeneration;
|
||||
if (client.backend == MediaBackend.plex) {
|
||||
unawaited(_loadFullHubContent(client, generation));
|
||||
return;
|
||||
}
|
||||
final offset = _continuationOffset;
|
||||
final total = _continuationTotal;
|
||||
if (offset == null || total == null) return;
|
||||
unawaited(_loadRemainingHubPages(client, generation, offset, total));
|
||||
}
|
||||
|
||||
List<MediaItem> _applySectionFilter(List<MediaItem> items) {
|
||||
final sectionFilter = int.tryParse(widget.hub.libraryId ?? '');
|
||||
if (sectionFilter == null) return items;
|
||||
return items.where((item) => int.tryParse(item.libraryId ?? '') == sectionFilter).toList();
|
||||
}
|
||||
|
||||
Future<void> _handleItemRefresh(String ratingKey) async {
|
||||
final itemIndex = _items.indexWhere((item) => item.id == ratingKey);
|
||||
final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
|
||||
@@ -302,6 +430,27 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
unawaited(_loadMoreItems());
|
||||
}
|
||||
|
||||
Widget _buildContinuationStatusSliver() {
|
||||
final error = _continuationErrorMessage;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadMoreItems();
|
||||
@@ -437,6 +586,8 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null))
|
||||
_buildContinuationStatusSliver(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../widgets/settings_builder.dart';
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../utils/layout_constants.dart';
|
||||
import '../main_screen.dart';
|
||||
|
||||
/// Context passed to the item builder with navigation information.
|
||||
class GridItemContext {
|
||||
/// Whether this item is in the first row of the grid.
|
||||
final bool isFirstRow;
|
||||
|
||||
/// Whether this item is in the first column of the grid.
|
||||
final bool isFirstColumn;
|
||||
|
||||
/// Whether items are displayed in list mode (single column).
|
||||
final bool isListMode;
|
||||
|
||||
/// Callback to navigate to the sidebar (for first-column items).
|
||||
final VoidCallback? navigateToSidebar;
|
||||
|
||||
const GridItemContext({
|
||||
required this.isFirstRow,
|
||||
required this.isFirstColumn,
|
||||
this.isListMode = false,
|
||||
this.navigateToSidebar,
|
||||
});
|
||||
}
|
||||
|
||||
/// A widget that automatically switches between grid and list view
|
||||
/// based on user settings, providing a consistent layout pattern
|
||||
/// across all library screens.
|
||||
///
|
||||
/// Generic type T: The type of items being displayed
|
||||
class AdaptiveMediaGrid<T> extends StatelessWidget {
|
||||
/// The list of items to display
|
||||
final List<T> items;
|
||||
|
||||
/// Builder function for each item in the grid/list.
|
||||
/// Receives the item, index, and optional grid context with navigation info.
|
||||
final Widget Function(BuildContext context, T item, int index, [GridItemContext? gridContext]) itemBuilder;
|
||||
|
||||
/// Callback when the list needs to be refreshed
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
/// Optional padding around the grid/list
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Child aspect ratio for grid items (width / height)
|
||||
final double? childAspectRatio;
|
||||
|
||||
/// Optional focus node for the first item (for programmatic focus)
|
||||
final FocusNode? firstItemFocusNode;
|
||||
|
||||
/// Callback when back button is pressed (for hierarchical navigation)
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Whether to enable sidebar navigation for first-column items.
|
||||
final bool enableSidebarNavigation;
|
||||
|
||||
const AdaptiveMediaGrid({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
this.onRefresh,
|
||||
this.padding,
|
||||
this.childAspectRatio,
|
||||
this.firstItemFocusNode,
|
||||
this.onBack,
|
||||
this.enableSidebarNavigation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
|
||||
builder: (context) {
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
return _buildItemsView(context, svc.read(SettingsService.viewMode), svc.read(SettingsService.libraryDensity));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Extra top padding for focus decoration (scale + border extends beyond item bounds)
|
||||
static const double _focusDecorationPadding = 3.0;
|
||||
|
||||
/// Navigate focus to the sidebar
|
||||
void _navigateToSidebar(BuildContext context) {
|
||||
MainScreenFocusScope.of(context)?.focusSidebar();
|
||||
}
|
||||
|
||||
/// Builds either a list or grid view based on the view mode
|
||||
Widget _buildItemsView(BuildContext context, ViewMode viewMode, int density) {
|
||||
final basePadding = padding ?? GridLayoutConstants.gridPadding;
|
||||
// Add extra top padding for focus decoration of first row items
|
||||
final effectivePadding = basePadding.copyWith(top: basePadding.top + _focusDecorationPadding);
|
||||
final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
|
||||
|
||||
return CustomScrollView(
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
slivers: [
|
||||
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
||||
if (viewMode == ViewMode.list)
|
||||
SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverList.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, index) {
|
||||
final gridContext = enableSidebarNavigation
|
||||
? GridItemContext(
|
||||
isFirstRow: index == 0,
|
||||
isFirstColumn: true, // List view = single column
|
||||
isListMode: true,
|
||||
navigateToSidebar: () => _navigateToSidebar(context),
|
||||
)
|
||||
: null;
|
||||
return itemBuilder(ctx, items[index], index, gridContext);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildGridSliver(context, density, effectivePadding, effectiveAspectRatio),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridSliver(BuildContext context, int density, EdgeInsets effectivePadding, double effectiveAspectRatio) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
|
||||
return SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// crossAxisExtent is the post-padding inner width.
|
||||
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent);
|
||||
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
childAspectRatio: effectiveAspectRatio,
|
||||
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing,
|
||||
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, index) {
|
||||
final gridContext = enableSidebarNavigation
|
||||
? GridItemContext(
|
||||
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
|
||||
navigateToSidebar: () => _navigateToSidebar(context),
|
||||
)
|
||||
: null;
|
||||
return itemBuilder(ctx, items[index], index, gridContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
GridFocusNodeMixin,
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
PaginatedItemLoader<LibraryBrowseTab> {
|
||||
PaginatedItemLoader<MediaItem, LibraryBrowseTab> {
|
||||
@override
|
||||
String? get itemServerId => widget.library.serverId;
|
||||
|
||||
@@ -598,9 +598,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
|
||||
try {
|
||||
await loadInitialPage(_calculateInitialFetchSize());
|
||||
final initialPage = await loadInitialPageWithStatus(_calculateInitialFetchSize());
|
||||
|
||||
if (generation != _contentRequestId || !mounted) return;
|
||||
if (!initialPage.applied || generation != _contentRequestId || !mounted) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/grid_size_calculator.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/library_refresh_notifier.dart';
|
||||
import '../../../utils/media_server_http_client.dart';
|
||||
import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_grid_delegate.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../adaptive_media_grid.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
import 'library_grid_tab_state.dart';
|
||||
|
||||
/// Collections tab for library screen.
|
||||
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
|
||||
@@ -26,10 +36,16 @@ class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
|
||||
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState extends LibraryGridTabState<MediaItem, LibraryCollectionsTab> {
|
||||
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
|
||||
with LibraryTabFocusMixin<LibraryCollectionsTab>, PaginatedItemLoader<MediaItem, LibraryCollectionsTab> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'collections_first_item';
|
||||
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.collections_rounded;
|
||||
|
||||
@@ -43,21 +59,129 @@ class _LibraryCollectionsTabState extends LibraryGridTabState<MediaItem, Library
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> loadData() async {
|
||||
Future<List<MediaItem>> loadData() async => const [];
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
final client = getMediaClientForLibrary();
|
||||
return client.fetchCollections(widget.library.id);
|
||||
return client.fetchCollectionsPage(widget.library.id, start: start, size: size, abort: abort);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildGridItem(BuildContext context, MediaItem item, int index, [GridItemContext? gridContext]) {
|
||||
Future<void> loadItems() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
||||
if (!initialPage.applied || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
items = loadedItems.values.toList();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.e('Error loading $errorContext', error: e, stackTrace: st);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorMessage = 'Failed to load $errorContext: ${e.toString()}';
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<MediaItem> items) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
|
||||
builder: (context) {
|
||||
final settings = SettingsService.instanceOrNull!;
|
||||
final viewMode = settings.read(SettingsService.viewMode);
|
||||
final density = settings.read(SettingsService.libraryDensity);
|
||||
return CustomScrollView(
|
||||
clipBehavior: Clip.none,
|
||||
slivers: [
|
||||
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
||||
if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const double _focusDecorationPadding = 3.0;
|
||||
|
||||
EdgeInsets get _effectivePadding {
|
||||
final base = GridLayoutConstants.gridPadding;
|
||||
return base.copyWith(top: base.top + _focusDecorationPadding);
|
||||
}
|
||||
|
||||
Widget _buildListSliver(int density) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverList.builder(
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(index, isFirstColumn: true, disableScale: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridSliver(int density) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density),
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildMediaCardItem(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMediaCardItem(int index, {required bool isFirstColumn, bool disableScale = false}) {
|
||||
final item = loadedItems[index];
|
||||
if (item == null) {
|
||||
ensureIndexLoaded(index, pageSize: _pageSize);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
focusNode: index == 0 ? firstItemFocusNode : null,
|
||||
disableScale: gridContext?.isListMode ?? false,
|
||||
disableScale: disableScale,
|
||||
onListRefresh: loadItems,
|
||||
onBack: widget.onBack,
|
||||
onNavigateLeft: gridContext?.isFirstColumn == true ? gridContext?.navigateToSidebar : null,
|
||||
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSidebar() {
|
||||
MainScreenFocusScope.of(context)?.focusSidebar();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposePagination();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../adaptive_media_grid.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
/// Shared state implementation for simple grid-based library tabs.
|
||||
///
|
||||
/// Handles focus, item counting, and grid wiring so individual tabs only
|
||||
/// implement data loading and per-item rendering.
|
||||
abstract class LibraryGridTabState<T, W extends BaseLibraryTab<T>> extends BaseLibraryTabState<T, W>
|
||||
with LibraryTabFocusMixin {
|
||||
/// Build a single grid item.
|
||||
/// [gridContext] provides information about the item's position in the grid
|
||||
/// and callbacks for navigation (e.g., navigating to sidebar from first column).
|
||||
Widget buildGridItem(BuildContext context, T item, int index, [GridItemContext? gridContext]);
|
||||
|
||||
@override
|
||||
int get itemCount => items.length;
|
||||
|
||||
@override
|
||||
Widget buildContent(List<T> items) {
|
||||
return AdaptiveMediaGrid<T>(
|
||||
items: items,
|
||||
itemBuilder: (context, item, index, [gridContext]) => buildGridItem(context, item, index, gridContext),
|
||||
onRefresh: loadItems,
|
||||
firstItemFocusNode: firstItemFocusNode,
|
||||
onBack: widget.onBack,
|
||||
enableSidebarNavigation: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_playlist.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/grid_size_calculator.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/library_refresh_notifier.dart';
|
||||
import '../../../utils/media_server_http_client.dart';
|
||||
import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_grid_delegate.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../adaptive_media_grid.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
import 'library_grid_tab_state.dart';
|
||||
|
||||
/// Playlists tab for library screen
|
||||
/// Shows playlists that contain items from the current library
|
||||
@@ -26,10 +36,16 @@ class LibraryPlaylistsTab extends BaseLibraryTab<MediaPlaylist> {
|
||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState extends LibraryGridTabState<MediaPlaylist, LibraryPlaylistsTab> {
|
||||
class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, LibraryPlaylistsTab>
|
||||
with LibraryTabFocusMixin<LibraryPlaylistsTab>, PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'playlists_first_item';
|
||||
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.playlist_play_rounded;
|
||||
|
||||
@@ -43,23 +59,131 @@ class _LibraryPlaylistsTabState extends LibraryGridTabState<MediaPlaylist, Libra
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
|
||||
|
||||
@override
|
||||
Future<List<MediaPlaylist>> loadData() async {
|
||||
Future<List<MediaPlaylist>> loadData() async => const [];
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPage(int start, int size, AbortController? abort) {
|
||||
// Both backends return playlists scoped to the server (not the library) —
|
||||
// neither Plex nor Jellyfin's API filters playlists by section.
|
||||
final client = getMediaClientForLibrary();
|
||||
return client.fetchPlaylists(playlistType: 'video');
|
||||
return client.fetchPlaylistsPage(playlistType: 'video', start: start, size: size, abort: abort);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildGridItem(BuildContext context, MediaPlaylist playlist, int index, [GridItemContext? gridContext]) {
|
||||
Future<void> loadItems() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
||||
if (!initialPage.applied || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
items = loadedItems.values.toList();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.e('Error loading $errorContext', error: e, stackTrace: st);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorMessage = 'Failed to load $errorContext: ${e.toString()}';
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<MediaPlaylist> items) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
|
||||
builder: (context) {
|
||||
final settings = SettingsService.instanceOrNull!;
|
||||
final viewMode = settings.read(SettingsService.viewMode);
|
||||
final density = settings.read(SettingsService.libraryDensity);
|
||||
return CustomScrollView(
|
||||
clipBehavior: Clip.none,
|
||||
slivers: [
|
||||
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
||||
if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const double _focusDecorationPadding = 3.0;
|
||||
|
||||
EdgeInsets get _effectivePadding {
|
||||
final base = GridLayoutConstants.gridPadding;
|
||||
return base.copyWith(top: base.top + _focusDecorationPadding);
|
||||
}
|
||||
|
||||
Widget _buildListSliver(int density) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverList.builder(
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) => _buildPlaylistCard(index, isFirstColumn: true, disableScale: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridSliver(int density) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density),
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildPlaylistCard(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistCard(int index, {required bool isFirstColumn, bool disableScale = false}) {
|
||||
final playlist = loadedItems[index];
|
||||
if (playlist == null) {
|
||||
ensureIndexLoaded(index, pageSize: _pageSize);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(playlist.id),
|
||||
item: playlist,
|
||||
focusNode: index == 0 ? firstItemFocusNode : null,
|
||||
disableScale: gridContext?.isListMode ?? false,
|
||||
disableScale: disableScale,
|
||||
onListRefresh: loadItems,
|
||||
onBack: widget.onBack,
|
||||
onNavigateLeft: gridContext?.isFirstColumn == true ? gridContext?.navigateToSidebar : null,
|
||||
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSidebar() {
|
||||
MainScreenFocusScope.of(context)?.focusSidebar();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposePagination();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
Completer<void>? _seasonsCompleter;
|
||||
List<MediaItem> _episodes = [];
|
||||
bool _isLoadingEpisodes = false;
|
||||
bool _isLoadingAllEpisodes = false;
|
||||
int _episodesLoadGeneration = 0;
|
||||
bool _showEpisodesDirectly = false;
|
||||
MediaItem? _fullMetadata;
|
||||
MediaItem? _onDeckEpisode;
|
||||
@@ -149,6 +151,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final ScrollController _seasonTabsScrollController = ScrollController();
|
||||
final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode');
|
||||
final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode');
|
||||
static const int _episodesPageSize = 200;
|
||||
|
||||
late final FocusNode _playButtonFocusNode;
|
||||
late final FocusNode _ratingChipFocusNode;
|
||||
@@ -2246,8 +2249,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _episodes.length,
|
||||
itemCount: _episodes.length + (_isLoadingAllEpisodes ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _episodes.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final episode = _episodes[index];
|
||||
String? localPosterPath;
|
||||
if (widget.isOffline && episode.serverId != null) {
|
||||
@@ -2326,9 +2335,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _fetchAllEpisodes() async {
|
||||
final generation = ++_episodesLoadGeneration;
|
||||
if (_seasons.isEmpty) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
_isLoadingAllEpisodes = false;
|
||||
_hasLoadedEpisodes = true;
|
||||
});
|
||||
return;
|
||||
@@ -2337,6 +2348,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (serverId == null) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
_isLoadingAllEpisodes = false;
|
||||
_hasLoadedEpisodes = true;
|
||||
});
|
||||
return;
|
||||
@@ -2345,54 +2357,95 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (client == null) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
_isLoadingAllEpisodes = false;
|
||||
_hasLoadedEpisodes = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = true;
|
||||
_isLoadingAllEpisodes = false;
|
||||
_hasLoadedEpisodes = false;
|
||||
});
|
||||
try {
|
||||
// One-shot recursive expansion — Plex `/grandchildren`, Jellyfin
|
||||
// Recursive=true. Replaces the previous per-season fan-out so a
|
||||
// many-season show flatten doesn't fan out N parallel HTTP calls.
|
||||
// Enrich each episode with serverId/serverName/grandparent fields —
|
||||
// Jellyfin's recursive query doesn't always populate them, and the
|
||||
// copy is a no-op for Plex where the mapper already does.
|
||||
final episodes = await client.fetchPlayableDescendants(_metadata.id);
|
||||
final fallbackGrandparentId = _metadata.isSeason ? (_metadata.grandparentId ?? _metadata.parentId) : _metadata.id;
|
||||
final fallbackGrandparentTitle = _metadata.isSeason
|
||||
? (_metadata.grandparentTitle ?? _metadata.parentTitle)
|
||||
: _metadata.title;
|
||||
final enriched = episodes
|
||||
.map(
|
||||
(e) => _withFallbackLibrary(
|
||||
e.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: e.grandparentId ?? fallbackGrandparentId,
|
||||
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
|
||||
),
|
||||
_metadata,
|
||||
),
|
||||
)
|
||||
.map(_applyLocalProgress)
|
||||
.toList();
|
||||
final firstPage = await client.fetchPlayableDescendantsPage(_metadata.id, start: 0, size: _episodesPageSize);
|
||||
if (!mounted || generation != _episodesLoadGeneration) return;
|
||||
final enriched = _enrichPlayableEpisodes(firstPage.items, serverId);
|
||||
setStateIfMounted(() {
|
||||
_episodes = enriched;
|
||||
_isLoadingEpisodes = false;
|
||||
_isLoadingAllEpisodes = firstPage.items.length < firstPage.totalCount;
|
||||
_hasLoadedEpisodes = true;
|
||||
});
|
||||
if (firstPage.items.length < firstPage.totalCount) {
|
||||
unawaited(_fetchRemainingEpisodes(client, serverId, generation, firstPage.items.length, firstPage.totalCount));
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
_isLoadingAllEpisodes = false;
|
||||
_hasLoadedEpisodes = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaItem> _enrichPlayableEpisodes(List<MediaItem> episodes, String serverId) {
|
||||
// Enrich each episode with serverId/serverName/grandparent fields —
|
||||
// Jellyfin's recursive query doesn't always populate them, and the copy is
|
||||
// a no-op for Plex where the mapper already does.
|
||||
final fallbackGrandparentId = _metadata.isSeason ? (_metadata.grandparentId ?? _metadata.parentId) : _metadata.id;
|
||||
final fallbackGrandparentTitle = _metadata.isSeason
|
||||
? (_metadata.grandparentTitle ?? _metadata.parentTitle)
|
||||
: _metadata.title;
|
||||
return episodes
|
||||
.map(
|
||||
(e) => _withFallbackLibrary(
|
||||
e.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: e.grandparentId ?? fallbackGrandparentId,
|
||||
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
|
||||
),
|
||||
_metadata,
|
||||
),
|
||||
)
|
||||
.map(_applyLocalProgress)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _fetchRemainingEpisodes(
|
||||
MediaServerClient client,
|
||||
String serverId,
|
||||
int generation,
|
||||
int startOffset,
|
||||
int totalCount,
|
||||
) async {
|
||||
var offset = startOffset;
|
||||
var total = totalCount;
|
||||
try {
|
||||
while (offset < total) {
|
||||
final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize);
|
||||
if (!mounted || generation != _episodesLoadGeneration) return;
|
||||
if (page.items.isEmpty) break;
|
||||
final enriched = _enrichPlayableEpisodes(page.items, serverId);
|
||||
setStateIfMounted(() {
|
||||
_episodes.addAll(enriched);
|
||||
});
|
||||
offset += page.items.length;
|
||||
total = page.totalCount;
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to finish loading all episodes', error: e, stackTrace: st);
|
||||
} finally {
|
||||
if (mounted && generation == _episodesLoadGeneration) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingAllEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the next unwatched episode for offline mode (offline OnDeck)
|
||||
Future<void> _loadOfflineOnDeckEpisode() async {
|
||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -40,6 +42,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
StandardItemLoader<PlaylistDetailScreen>,
|
||||
GridFocusNodeMixin<PlaylistDetailScreen>,
|
||||
FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
||||
static const int _pageSize = 100;
|
||||
|
||||
@override
|
||||
Object get mediaItem => widget.playlist;
|
||||
|
||||
@@ -141,6 +145,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
int? _movingIndex;
|
||||
int? _originalIndex;
|
||||
List<MediaItem>? _originalOrder;
|
||||
int? _playlistTotalSize;
|
||||
int _playlistLoadGeneration = 0;
|
||||
bool _isLoadingFullPlaylist = false;
|
||||
String? _playlistContinuationErrorMessage;
|
||||
|
||||
bool get _isPlaylistFullyLoaded => _playlistTotalSize != null && items.length >= _playlistTotalSize!;
|
||||
|
||||
bool get _canEditPlaylist => !_isReadOnly && _isPlaylistFullyLoaded;
|
||||
|
||||
// Estimated item height for scroll-into-view (card + vertical margins)
|
||||
static const double _estimatedItemHeight = 114.0;
|
||||
@@ -159,9 +171,106 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
await super.loadItems();
|
||||
final generation = ++_playlistLoadGeneration;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
_playlistTotalSize = null;
|
||||
_isLoadingFullPlaylist = false;
|
||||
_playlistContinuationErrorMessage = null;
|
||||
_focusedIndex = 0;
|
||||
_focusedColumn = 0;
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-focus after load if in keyboard mode
|
||||
try {
|
||||
final firstPage = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: 0, size: _pageSize);
|
||||
if (!mounted || generation != _playlistLoadGeneration) return;
|
||||
|
||||
setState(() {
|
||||
items = firstPage.items;
|
||||
_playlistTotalSize = firstPage.totalCount;
|
||||
isLoading = false;
|
||||
_isLoadingFullPlaylist = firstPage.items.length < firstPage.totalCount;
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Loaded ${firstPage.items.length} of ${firstPage.totalCount} items for playlist: ${widget.playlist.title}',
|
||||
);
|
||||
_autoFocusAfterLoad();
|
||||
|
||||
if (firstPage.items.length < firstPage.totalCount) {
|
||||
unawaited(_loadRemainingPlaylistPages(generation, firstPage.items.length, firstPage.totalCount));
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load playlist items', error: e);
|
||||
if (!mounted || generation != _playlistLoadGeneration) return;
|
||||
setState(() {
|
||||
errorMessage = getLoadErrorMessage(e);
|
||||
isLoading = false;
|
||||
_isLoadingFullPlaylist = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRemainingPlaylistPages(int generation, int startOffset, int totalCount) async {
|
||||
var offset = startOffset;
|
||||
var total = totalCount;
|
||||
if (mounted && generation == _playlistLoadGeneration) {
|
||||
setState(() {
|
||||
_isLoadingFullPlaylist = true;
|
||||
_playlistContinuationErrorMessage = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
while (offset < total) {
|
||||
final page = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: offset, size: _pageSize);
|
||||
if (!mounted || generation != _playlistLoadGeneration) return;
|
||||
if (page.items.isEmpty) break;
|
||||
setState(() {
|
||||
items.addAll(page.items);
|
||||
total = page.totalCount;
|
||||
_playlistTotalSize = page.totalCount;
|
||||
});
|
||||
offset += page.items.length;
|
||||
}
|
||||
appLogger.d(
|
||||
'Loaded ${items.length} of ${_playlistTotalSize ?? items.length} items for playlist: ${widget.playlist.title}',
|
||||
);
|
||||
if (mounted && generation == _playlistLoadGeneration) {
|
||||
setState(() {
|
||||
_playlistContinuationErrorMessage = null;
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to finish loading playlist items', error: e, stackTrace: st);
|
||||
if (mounted && generation == _playlistLoadGeneration) {
|
||||
setState(() {
|
||||
_playlistContinuationErrorMessage = t.messages.errorLoading(error: e.toString());
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted && generation == _playlistLoadGeneration) {
|
||||
setState(() {
|
||||
_isLoadingFullPlaylist = false;
|
||||
if (_focusedColumn != 0 && !_canEditPlaylist) _focusedColumn = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _retryPlaylistContinuation() {
|
||||
final total = _playlistTotalSize;
|
||||
if (_isLoadingFullPlaylist || total == null || items.length >= total) return;
|
||||
unawaited(_loadRemainingPlaylistPages(_playlistLoadGeneration, items.length, total));
|
||||
}
|
||||
|
||||
void _autoFocusAfterLoad() {
|
||||
if (mounted && items.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
@@ -205,10 +314,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
|
||||
try {
|
||||
final allItems = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||
if (!mounted) return;
|
||||
final result = await showPlaylistDownloadOptionsAndQueue(
|
||||
context,
|
||||
playlistMetadata: _playlistAsMetadata(),
|
||||
items: items,
|
||||
items: allItems,
|
||||
client: mediaClient,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
@@ -260,6 +371,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
if (!_canEditPlaylist) return;
|
||||
|
||||
// Adjust newIndex if moving down in the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex--;
|
||||
@@ -343,6 +456,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
|
||||
Future<void> _removeItem(int index) async {
|
||||
if (!_canEditPlaylist) return;
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
final item = items[index];
|
||||
|
||||
@@ -499,7 +613,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
if (key.isLeftKey) {
|
||||
// Navigate left within columns
|
||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
||||
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||
// Go to drag handle (column 1)
|
||||
setState(() => _focusedColumn = 1);
|
||||
return KeyEventResult.handled;
|
||||
@@ -511,7 +625,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
// Navigate right within columns
|
||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
||||
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||
// Go to remove button (column 2)
|
||||
setState(() => _focusedColumn = 2);
|
||||
return KeyEventResult.handled;
|
||||
@@ -525,14 +639,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
if (_focusedColumn == 0) {
|
||||
// Play from this item
|
||||
_playFromItem(_focusedIndex);
|
||||
} else if (_focusedColumn == 1 && !_isReadOnly) {
|
||||
} else if (_focusedColumn == 1 && _canEditPlaylist) {
|
||||
// Enter move mode
|
||||
setState(() {
|
||||
_movingIndex = _focusedIndex;
|
||||
_originalIndex = _focusedIndex;
|
||||
_originalOrder = List.from(items);
|
||||
});
|
||||
} else if (_focusedColumn == 2) {
|
||||
} else if (_focusedColumn == 2 && _canEditPlaylist) {
|
||||
// Remove item
|
||||
_removeItem(_focusedIndex);
|
||||
}
|
||||
@@ -619,7 +733,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
actions: buildFocusableAppBarActions(),
|
||||
),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
if (items.isNotEmpty) ...[
|
||||
if (_isReadOnly)
|
||||
// Smart playlists / Jellyfin playlists: focusable grid view
|
||||
// (read-only, no reordering or removal)
|
||||
@@ -627,6 +741,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
else
|
||||
// Plex regular playlists: sliver reorderable list
|
||||
_buildReorderableList(isKeyboardMode),
|
||||
if (_isLoadingFullPlaylist || _playlistContinuationErrorMessage != null)
|
||||
_buildPlaylistContinuationStatusSliver(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -688,7 +805,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: !_isReadOnly,
|
||||
canReorder: _canEditPlaylist,
|
||||
isFocused: isFocused,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
isMoving: isMoving,
|
||||
@@ -697,4 +814,25 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistContinuationStatusSliver() {
|
||||
final error = _playlistContinuationErrorMessage;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,14 +173,6 @@ class JellyfinClient
|
||||
/// to re-broadcast status so admin-gated UI rebuilds.
|
||||
FutureOr<void> Function(JellyfinConnection connection)? onConnectionUpdated;
|
||||
|
||||
/// Per-collection cache for [fetchCollectionPage]. Jellyfin's API doesn't
|
||||
/// paginate collection children, so the first call materialises the full
|
||||
/// list and subsequent paged calls slice from the same in-memory copy.
|
||||
/// Lifetime is the client's lifetime — collections rarely change in a
|
||||
/// single session, and a stale-but-bounded list is acceptable.
|
||||
@override
|
||||
final Map<String, List<MediaItem>> _collectionItemsCache = {};
|
||||
|
||||
/// Read-only view of the headers attached to every outgoing request.
|
||||
/// Test-only entry point for asserting the SDK-style `MediaBrowser`
|
||||
/// Authorization shape — Findroid (and the official SDK) sends the same
|
||||
|
||||
@@ -40,6 +40,16 @@ const _queueFields = 'UserData';
|
||||
/// bounded while still returning the full series queue.
|
||||
const _episodeQueuePageSize = 200;
|
||||
|
||||
const _childrenPageSize = 500;
|
||||
const _pagedListPageSize = 200;
|
||||
|
||||
int _fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) {
|
||||
if (requestedSize == null || requestedSize <= 0 || itemCount < requestedSize) {
|
||||
return offset + itemCount;
|
||||
}
|
||||
return offset + itemCount + 1;
|
||||
}
|
||||
|
||||
/// `/Items/Filters` is a legacy unpaged endpoint; keep failures isolated from
|
||||
/// the paged Browse tab so very large libraries can still open.
|
||||
const _filtersTimeout = Duration(seconds: 8);
|
||||
@@ -447,23 +457,40 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
// Not a series — fall through to the generic ParentId query.
|
||||
}
|
||||
// Generic direct-children query: works for season → episodes,
|
||||
// collection → items, etc.
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'ParentId': parentId,
|
||||
'Fields': _browseFields,
|
||||
'Limit': '500',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
await cache.put(cacheServerId, childrenKey, data);
|
||||
// collection → items, etc. Page it so large seasons/folders don't truncate
|
||||
// at Jellyfin's per-request limit.
|
||||
final allRaw = <Map<String, dynamic>>[];
|
||||
var startIndex = 0;
|
||||
int? totalRecordCount;
|
||||
while (totalRecordCount == null || startIndex < totalRecordCount) {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'ParentId': parentId,
|
||||
'Fields': _browseFields,
|
||||
'StartIndex': '$startIndex',
|
||||
'Limit': '$_childrenPageSize',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
final page = _itemsArray(data);
|
||||
allRaw.addAll(page);
|
||||
if (data is Map<String, dynamic>) {
|
||||
final rawTotal = data['TotalRecordCount'];
|
||||
if (rawTotal is int) totalRecordCount = rawTotal;
|
||||
}
|
||||
if (page.isEmpty || page.length < _childrenPageSize) break;
|
||||
startIndex += page.length;
|
||||
}
|
||||
return _mapItems(_itemsArray(data));
|
||||
try {
|
||||
await cache.put(cacheServerId, childrenKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length});
|
||||
} catch (e, st) {
|
||||
appLogger.w('JellyfinClient.fetchChildren cache write failed', error: e, stackTrace: st);
|
||||
}
|
||||
return _mapItems(allRaw);
|
||||
}
|
||||
|
||||
/// All directly-playable descendants of [parentId] (Movies + Episodes),
|
||||
@@ -473,10 +500,29 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
|
||||
/// since those preserve the container shape (Series rows, PlaylistItemId).
|
||||
///
|
||||
/// No `Limit` — Jellyfin returns the entire list for this endpoint by
|
||||
/// default, same precedent as [fetchClientSideEpisodeQueue].
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPlayableDescendantsPage(parentId, start: start, size: _pagedListPageSize);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||||
String parentId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final offset = start ?? 0;
|
||||
final pageSize = size ?? _pagedListPageSize;
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
@@ -484,12 +530,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
'ParentId': parentId,
|
||||
'Recursive': 'true',
|
||||
'IncludeItemTypes': 'Movie,Episode',
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
}
|
||||
|
||||
/// All episodes of a series in air order, optimised for queue-building.
|
||||
@@ -551,6 +600,27 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPersonMedia(String personId) async {
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
||||
String personId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final offset = start ?? 0;
|
||||
final pageSize = size ?? _pagedListPageSize;
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
@@ -558,15 +628,18 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
'PersonIds': personId,
|
||||
'IncludeItemTypes': 'Movie,Series',
|
||||
'Recursive': 'true',
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'Fields': _browseFields,
|
||||
'SortBy': 'PremiereDate,ProductionYear,SortName',
|
||||
'SortOrder': 'Descending,Descending,Ascending',
|
||||
'CollapseBoxSetItems': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -794,7 +867,25 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
/// `*.nextup` → NextUp. Unknown ids return an empty list.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
|
||||
final effectiveLimit = (limit ?? 50).toString();
|
||||
try {
|
||||
final page = await fetchMoreHubItemsPage(hubId, start: 0, size: limit ?? 50);
|
||||
return page.items;
|
||||
} catch (e, st) {
|
||||
appLogger.w('JellyfinClient: failed to fetch hub items for $hubId (treating as empty)', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(
|
||||
String hubId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final offset = start ?? 0;
|
||||
final pageSize = size ?? 50;
|
||||
final effectiveLimit = pageSize.toString();
|
||||
String? parentId;
|
||||
if (hubId.startsWith('library.')) {
|
||||
final rest = hubId.substring('library.'.length);
|
||||
@@ -802,42 +893,100 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
if (dot > 0) parentId = rest.substring(0, dot);
|
||||
}
|
||||
final tail = hubId.split('.').last;
|
||||
final List<Map<String, dynamic>> items;
|
||||
switch (tail) {
|
||||
case 'recent':
|
||||
items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
|
||||
...jellyfinImageQueryParameters,
|
||||
});
|
||||
break;
|
||||
// Jellyfin's Latest endpoint has a Limit but no StartIndex. Expose it
|
||||
// as one bounded page so callers don't infer endless fake pages.
|
||||
if (offset > 0) return LibraryPage<MediaItem>(items: const [], totalCount: offset, offset: offset);
|
||||
return _safeFetchMediaPage(
|
||||
'/Users/${_segment(connection.userId)}/Items/Latest',
|
||||
{
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
singlePage: true,
|
||||
abort: abort,
|
||||
);
|
||||
case 'continue':
|
||||
items = await _safeFetchItemsArray('/UserItems/Resume', {
|
||||
'userId': connection.userId,
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'Recursive': 'true',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
|
||||
...jellyfinImageQueryParameters,
|
||||
});
|
||||
break;
|
||||
return _safeFetchMediaPage(
|
||||
'/UserItems/Resume',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'Recursive': 'true',
|
||||
'EnableTotalRecordCount': 'true',
|
||||
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
case 'nextup':
|
||||
items = await _safeFetchItemsArray('/Shows/NextUp', {
|
||||
'userId': connection.userId,
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'ParentId': ?parentId,
|
||||
'EnableResumable': 'false',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
});
|
||||
break;
|
||||
return _safeFetchMediaPage(
|
||||
'/Shows/NextUp',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'ParentId': ?parentId,
|
||||
'EnableResumable': 'false',
|
||||
'EnableTotalRecordCount': 'true',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
default:
|
||||
return const [];
|
||||
return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: offset);
|
||||
}
|
||||
return _mapItems(items);
|
||||
}
|
||||
|
||||
Future<LibraryPage<MediaItem>> _safeFetchMediaPage(
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters, {
|
||||
required int offset,
|
||||
required int requestedSize,
|
||||
bool singlePage = false,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.get(path, queryParameters: queryParameters, abort: abort);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
final rawItems = data is List ? data.whereType<Map<String, dynamic>>().toList() : _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = singlePage
|
||||
? offset + rawItems.length
|
||||
: _fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(rawItems),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
offset: offset,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
LibraryPage<MediaItem> _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) {
|
||||
final rawItems = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = _fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(rawItems),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -3,47 +3,84 @@ part of '../../jellyfin_client.dart';
|
||||
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
MediaServerHttpClient get _http;
|
||||
Map<String, List<MediaItem>> get _collectionItemsCache;
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
static const int _collectionsPageSize = 200;
|
||||
|
||||
String? _boxSetsViewId;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
||||
// Jellyfin keeps BoxSets under a dedicated top-level view, not under each
|
||||
// movie/show library. Query that root to avoid recursively scanning media.
|
||||
final boxSetsViewId = await _fetchBoxSetsViewId();
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize);
|
||||
all.addAll(page.items);
|
||||
if (page.items.isEmpty) break;
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||||
String libraryId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final s = start ?? 0;
|
||||
final pageSize = size ?? _collectionsPageSize;
|
||||
final boxSetsViewId = await _fetchBoxSetsViewId(abort: abort);
|
||||
if (boxSetsViewId == null) {
|
||||
return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: s);
|
||||
}
|
||||
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?boxSetsViewId,
|
||||
'ParentId': boxSetsViewId,
|
||||
'IncludeItemTypes': 'BoxSet',
|
||||
'Recursive': 'true',
|
||||
'StartIndex': s.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'SortBy': 'SortName',
|
||||
'SortOrder': 'Ascending',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
return _itemsPage(response.data, offset: s, requestedSize: pageSize);
|
||||
}
|
||||
|
||||
Future<String?> _fetchBoxSetsViewId() async {
|
||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
|
||||
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
|
||||
if (_boxSetsViewId != null) return _boxSetsViewId;
|
||||
|
||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort);
|
||||
throwIfHttpError(response);
|
||||
for (final view in _itemsArray(response.data)) {
|
||||
final collectionType = (view['CollectionType'] as String?)?.toLowerCase();
|
||||
final id = view['Id'] as String?;
|
||||
if (collectionType == 'boxsets' && id != null && id.isNotEmpty) return id;
|
||||
if (collectionType == 'boxsets' && id != null && id.isNotEmpty) {
|
||||
_boxSetsViewId = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Jellyfin has no pagination knob for collection children, so the first
|
||||
/// call materialises the full list via [fetchChildren] and subsequent
|
||||
/// paged calls slice from the same in-memory copy ([_collectionItemsCache]).
|
||||
/// The [abort] hook is unused on this backend — the slice path is
|
||||
/// synchronous and the underlying fetch is short-lived.
|
||||
LibraryPage<MediaItem> _itemsPage(Object? data, {required int offset, int? requestedSize}) {
|
||||
final rawItems = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = _fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
final total = rawTotal is int ? rawTotal : fallbackTotal;
|
||||
return LibraryPage<MediaItem>(items: _mapItems(rawItems), totalCount: total, offset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
@@ -53,18 +90,21 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId);
|
||||
final s = start ?? 0;
|
||||
final fullSize = cached.length;
|
||||
final from = s.clamp(0, fullSize);
|
||||
final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize);
|
||||
return LibraryPage<MediaItem>(items: cached.sublist(from, to), totalCount: fullSize, offset: s);
|
||||
}
|
||||
|
||||
Future<List<MediaItem>> _loadAndCacheCollectionItems(String collectionId) async {
|
||||
final items = await fetchChildren(collectionId);
|
||||
_collectionItemsCache[collectionId] = items;
|
||||
return items;
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'ParentId': collectionId,
|
||||
'StartIndex': s.toString(),
|
||||
if (size != null) 'Limit': size.toString(),
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _itemsPage(response.data, offset: s, requestedSize: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -6,25 +6,85 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
String? _absolutizeImagePath(String? path);
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
static const int _playlistsPageSize = 200;
|
||||
|
||||
@override
|
||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'IncludeItemTypes': 'Playlist',
|
||||
'Recursive': 'true',
|
||||
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final all = <MediaPlaylist>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPlaylistsPage(
|
||||
playlistType: playlistType,
|
||||
smart: smart,
|
||||
start: start,
|
||||
size: _playlistsPageSize,
|
||||
);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
if (smart == true) {
|
||||
return LibraryPage<MediaPlaylist>(items: const [], totalCount: 0, offset: start ?? 0);
|
||||
}
|
||||
|
||||
final offset = start ?? 0;
|
||||
final pageSize = size ?? _playlistsPageSize;
|
||||
final requestedType = playlistType.toLowerCase();
|
||||
return _itemsArray(response.data).map(_playlistFromJson).where((playlist) {
|
||||
if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false;
|
||||
if (smart != null && playlist.smart != smart) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
final items = <MediaPlaylist>[];
|
||||
var rawOffset = 0;
|
||||
var filteredSeen = 0;
|
||||
int? rawTotal;
|
||||
var rawFinished = false;
|
||||
|
||||
while (items.length < pageSize && !rawFinished) {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'IncludeItemTypes': 'Playlist',
|
||||
'Recursive': 'true',
|
||||
'StartIndex': rawOffset.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final rawItems = _itemsArray(response.data);
|
||||
final rawTotalValue = response.data is Map<String, dynamic>
|
||||
? (response.data as Map<String, dynamic>)['TotalRecordCount']
|
||||
: null;
|
||||
if (rawTotalValue is int) rawTotal = rawTotalValue;
|
||||
|
||||
for (final item in rawItems.map(_playlistFromJson)) {
|
||||
if (!_matchesPlaylistFilters(item, requestedType: requestedType, smart: smart)) continue;
|
||||
if (filteredSeen >= offset && items.length < pageSize) {
|
||||
items.add(item);
|
||||
}
|
||||
filteredSeen++;
|
||||
}
|
||||
|
||||
rawOffset += rawItems.length;
|
||||
rawFinished = rawItems.isEmpty || rawItems.length < pageSize || (rawTotal != null && rawOffset >= rawTotal);
|
||||
}
|
||||
|
||||
final fallbackTotal = rawFinished
|
||||
? filteredSeen
|
||||
: _fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize);
|
||||
return LibraryPage<MediaPlaylist>(items: items, totalCount: fallbackTotal, offset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -50,18 +110,36 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
||||
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
||||
return page.items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
||||
final offset = start ?? 0;
|
||||
final pageSize = size ?? 100;
|
||||
final response = await _http.get(
|
||||
'/Playlists/${_segment(id)}/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': limit.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
final items = _itemsArray(response.data);
|
||||
final rawTotal = response.data is Map<String, dynamic>
|
||||
? (response.data as Map<String, dynamic>)['TotalRecordCount']
|
||||
: null;
|
||||
final fallbackTotal = _fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(items),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -171,6 +249,12 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
bool _matchesPlaylistFilters(MediaPlaylist playlist, {required String requestedType, required bool? smart}) {
|
||||
if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false;
|
||||
if (smart != null && playlist.smart != smart) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int? _epochSecondsFromJson(String? iso) {
|
||||
if (iso == null || iso.isEmpty) return null;
|
||||
final dt = DateTime.tryParse(iso);
|
||||
|
||||
@@ -6,11 +6,11 @@ Future<List<MediaItem>> fetchAllPlaylistItems(MediaServerClient client, String p
|
||||
final all = <MediaItem>[];
|
||||
var offset = 0;
|
||||
while (true) {
|
||||
final page = await client.fetchPlaylistItems(playlistId, offset: offset, limit: pageSize);
|
||||
if (page.isEmpty) break;
|
||||
all.addAll(page);
|
||||
if (page.length < pageSize) break;
|
||||
offset += page.length;
|
||||
final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
if (all.length >= page.totalCount) break;
|
||||
offset += page.items.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
+272
-75
@@ -764,6 +764,47 @@ class PlexClient
|
||||
return [];
|
||||
}
|
||||
|
||||
int? _responseHeaderInt(MediaServerResponse response, String name) {
|
||||
final lowerName = name.toLowerCase();
|
||||
for (final entry in response.headers.entries) {
|
||||
if (entry.key.toLowerCase() == lowerName) return flexibleInt(entry.value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int _fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) {
|
||||
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
||||
return offset + itemCount + (fullPage ? 1 : 0);
|
||||
}
|
||||
|
||||
int _responseTotalSize(MediaServerResponse response, {required int itemCount, int? start, int? requestedSize}) {
|
||||
final headerTotal = _responseHeaderInt(response, 'X-Plex-Container-Total-Size');
|
||||
if (headerTotal != null) return headerTotal;
|
||||
|
||||
final container = _getMediaContainer(response);
|
||||
final bodyTotal = flexibleInt(container?['totalSize']);
|
||||
if (bodyTotal != null) return bodyTotal;
|
||||
|
||||
final offset = start ?? flexibleInt(container?['offset']) ?? 0;
|
||||
if (start == null && requestedSize == null) {
|
||||
return flexibleInt(container?['size']) ?? itemCount;
|
||||
}
|
||||
|
||||
return _fallbackPageTotal(offset: offset, itemCount: itemCount, requestedSize: requestedSize);
|
||||
}
|
||||
|
||||
({List<PlexPlaylistDto> items, int totalSize}) _extractPlaylistListResult(
|
||||
MediaServerResponse response, {
|
||||
int? start,
|
||||
int? size,
|
||||
}) {
|
||||
final items = _extractPlaylistList(response);
|
||||
return (
|
||||
items: items,
|
||||
totalSize: _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: size),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||
final response = await _getWithFailover('/identity');
|
||||
return response.data;
|
||||
@@ -839,7 +880,12 @@ class PlexClient
|
||||
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, librarySectionID: _librarySectionIdFromString(sectionId));
|
||||
return _extractLibraryContentResult(
|
||||
response,
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
start: start,
|
||||
requestedSize: size,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
|
||||
@@ -853,14 +899,15 @@ class PlexClient
|
||||
MediaServerResponse response, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
int? start,
|
||||
int? requestedSize,
|
||||
}) {
|
||||
final items = _extractMetadataListWithLibrary(
|
||||
response,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
|
||||
final totalSize = _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: requestedSize);
|
||||
return _LibraryContentResult(items: items, totalSize: totalSize);
|
||||
}
|
||||
|
||||
@@ -877,6 +924,8 @@ class PlexClient
|
||||
response,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
start: start,
|
||||
requestedSize: size,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1082,9 +1131,7 @@ 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).
|
||||
/// Default cap for list-style endpoints when a caller doesn't pass a size.
|
||||
static const int _defaultListContainerSize = 1000;
|
||||
|
||||
/// Page size used when walking all pages of a paginated endpoint.
|
||||
@@ -1322,27 +1369,15 @@ class PlexClient
|
||||
[];
|
||||
}
|
||||
|
||||
/// Get every episode beneath a show or season in one call — for a show
|
||||
/// this returns episodes across every season (no per-season walk), for a
|
||||
/// season the episodes directly. Mirrors [_getChildren]'s cache-fallback
|
||||
/// behaviour.
|
||||
///
|
||||
/// Uses `/grandchildren` rather than `/allLeaves` because the live server
|
||||
/// returns 0 items for `/allLeaves` on a season — `/grandchildren` is the
|
||||
/// only endpoint Plex serves that one-shots both levels (and is also the
|
||||
/// recommended path for mini-series shows that set `skipChildren=true`,
|
||||
/// per the API docs).
|
||||
Future<List<PlexMetadataDto>> _getGrandchildren(String ratingKey) async {
|
||||
final endpoint = '/library/metadata/$ratingKey/grandchildren';
|
||||
|
||||
return await fetchWithCacheFallback<List<PlexMetadataDto>>(
|
||||
cacheKey: endpoint,
|
||||
networkCall: () => _http.get(endpoint),
|
||||
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
|
||||
parseResponse: (response) => _extractMetadataList(response),
|
||||
) ??
|
||||
[];
|
||||
}
|
||||
/// Page through playable episodes beneath a show or season. Uses
|
||||
/// `/grandchildren` rather than `/allLeaves` because the live server returns
|
||||
/// 0 items for `/allLeaves` on a season.
|
||||
Future<_LibraryContentResult> _getGrandchildrenPage(
|
||||
String ratingKey, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) => _fetchPaginatedList('/library/metadata/$ratingKey/grandchildren', start: start, size: size, abort: abort);
|
||||
|
||||
/// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
|
||||
/// Uses cache when offline or as fallback on network error
|
||||
@@ -1810,14 +1845,62 @@ class PlexClient
|
||||
/// Get full content from a hub using its hub key
|
||||
/// Returns the complete list of metadata items in the hub
|
||||
Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
|
||||
try {
|
||||
final hubSectionID = _librarySectionIdFromString(hubKey);
|
||||
final items = await _fetchAllPages(
|
||||
(start, size, abort) =>
|
||||
_fetchPaginatedList(hubKey, start: start, size: size, abort: abort, librarySectionID: hubSectionID),
|
||||
);
|
||||
return items.where(_isVideoMetadata).toList();
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to get hub content', error: e, stackTrace: st);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
bool _isVideoMetadata(PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
||||
|
||||
Future<_LibraryContentResult> _getHubContentPage(
|
||||
String hubKey, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final filteredOffset = start ?? 0;
|
||||
final pageSize = size ?? _fetchAllPageSize;
|
||||
final rawPageSize = pageSize > _fetchAllPageSize ? pageSize : _fetchAllPageSize;
|
||||
final hubSectionID = _librarySectionIdFromString(hubKey);
|
||||
return _wrapListApiCall<PlexMetadataDto>(() => _http.get(hubKey), (response) {
|
||||
final allItems = _extractMetadataListWithLibrary(response, librarySectionID: hubSectionID);
|
||||
// Filter to only video content (movies, shows, seasons, episodes)
|
||||
return allItems.where((item) {
|
||||
return ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
||||
}).toList();
|
||||
}, 'Failed to get hub content');
|
||||
final pageItems = <PlexMetadataDto>[];
|
||||
var rawOffset = 0;
|
||||
var filteredSeen = 0;
|
||||
var rawTotal = 0;
|
||||
var rawFinished = false;
|
||||
|
||||
while (pageItems.length < pageSize && !rawFinished) {
|
||||
final result = await _fetchPaginatedList(
|
||||
hubKey,
|
||||
start: rawOffset,
|
||||
size: rawPageSize,
|
||||
abort: abort,
|
||||
librarySectionID: hubSectionID,
|
||||
);
|
||||
rawTotal = result.totalSize;
|
||||
final rawItems = result.items;
|
||||
rawOffset += rawItems.length;
|
||||
|
||||
for (final item in rawItems) {
|
||||
if (!_isVideoMetadata(item)) continue;
|
||||
if (filteredSeen >= filteredOffset && pageItems.length < pageSize) {
|
||||
pageItems.add(item);
|
||||
}
|
||||
filteredSeen++;
|
||||
}
|
||||
|
||||
rawFinished = rawItems.isEmpty || rawOffset >= rawTotal;
|
||||
}
|
||||
|
||||
final totalSize = rawFinished ? filteredSeen : filteredOffset + pageItems.length + 1;
|
||||
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
|
||||
}
|
||||
|
||||
/// Get playlist content by playlist ID, paginated.
|
||||
@@ -1829,23 +1912,50 @@ class PlexClient
|
||||
Future<List<PlexMetadataDto>> _fetchAllPlaylistItemsDto(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<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) {
|
||||
/// Get all playlists.
|
||||
/// Filters by playlistType=video by default.
|
||||
/// Set smart to true/false to filter smart playlists, or null for all.
|
||||
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
|
||||
try {
|
||||
final all = <PlexPlaylistDto>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await _getPlaylistsPage(
|
||||
playlistType: playlistType,
|
||||
smart: smart,
|
||||
start: start,
|
||||
size: _fetchAllPageSize,
|
||||
);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalSize) break;
|
||||
}
|
||||
return all;
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final pageSize = size ?? _defaultListContainerSize;
|
||||
final queryParams = <String, dynamic>{
|
||||
'playlistType': playlistType,
|
||||
'X-Plex-Container-Size': _defaultListContainerSize,
|
||||
if (playlistType.isNotEmpty) 'playlistType': playlistType,
|
||||
..._buildPaginationParams(start, pageSize),
|
||||
};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
|
||||
return _wrapListApiCall<PlexPlaylistDto>(
|
||||
() => _http.get('/playlists', queryParameters: queryParams),
|
||||
_extractPlaylistList,
|
||||
'Failed to get playlists',
|
||||
);
|
||||
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
|
||||
return _extractPlaylistListResult(response, start: start, size: pageSize);
|
||||
}
|
||||
|
||||
/// Get playlist metadata by playlist ID
|
||||
@@ -2143,26 +2253,38 @@ class PlexClient
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all collections for a library section
|
||||
/// Returns collections as PlexMetadataDto objects with type="collection"
|
||||
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
||||
return _wrapListApiCall<PlexMetadataDto>(
|
||||
() => _http.get(
|
||||
'/library/sections/$sectionId/collections',
|
||||
queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize},
|
||||
),
|
||||
(response) {
|
||||
final allItems = _extractMetadataListWithLibrary(
|
||||
response,
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
);
|
||||
// Collections should have type="collection"
|
||||
return allItems.where((item) {
|
||||
return item.type?.toLowerCase() == ContentTypes.collection;
|
||||
}).toList();
|
||||
},
|
||||
'Failed to get library collections',
|
||||
/// Get one page of collections for a library section.
|
||||
Future<_LibraryContentResult> _getLibraryCollectionsPage(
|
||||
String sectionId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
|
||||
final response = await _getWithFailover(
|
||||
'/library/sections/$sectionId/collections',
|
||||
queryParameters: queryParameters,
|
||||
abort: abort,
|
||||
);
|
||||
final result = _extractLibraryContentResult(
|
||||
response,
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
start: start,
|
||||
requestedSize: size,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get all collections for a library section.
|
||||
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
||||
try {
|
||||
return _fetchAllPages((start, size, abort) {
|
||||
return _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort);
|
||||
});
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get items in a collection, paginated.
|
||||
@@ -2996,10 +3118,27 @@ class PlexClient
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
final leaves = await _getGrandchildren(parentId);
|
||||
final leaves = await _fetchAllPages(
|
||||
(start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort),
|
||||
);
|
||||
return leaves.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||||
String parentId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getGrandchildrenPage(parentId, start: start, size: size, abort: abort);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Plex maintains episode queues server-side via `/playQueues`, so the
|
||||
/// client-side window EpisodeNavigationService builds for Jellyfin isn't
|
||||
/// needed here.
|
||||
@@ -3366,6 +3505,28 @@ class PlexClient
|
||||
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getPlaylistsPage(
|
||||
playlistType: playlistType,
|
||||
smart: smart,
|
||||
start: start,
|
||||
size: size,
|
||||
abort: abort,
|
||||
);
|
||||
return LibraryPage<MediaPlaylist>(
|
||||
items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
|
||||
final p = await _getPlaylistMetadata(id);
|
||||
@@ -3374,21 +3535,23 @@ class PlexClient
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
||||
final result = await _getPlaylist(id, start: offset, size: limit);
|
||||
return result.items.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
||||
return page.items;
|
||||
}
|
||||
|
||||
/// Plex-specific: paginated playlist content. Returns neutral [MediaItem]s.
|
||||
/// The total size from the server is needed for paginated UI; tests in
|
||||
/// `playlist_detail_screen.dart` rely on this.
|
||||
Future<({List<MediaItem> items, int totalSize})> fetchPlaylistPage(
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
|
||||
String playlistId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
|
||||
return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3397,6 +3560,21 @@ class PlexClient
|
||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||||
String libraryId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
@@ -3441,15 +3619,19 @@ class PlexClient
|
||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
/// Plex-specific: paginated person-media listing.
|
||||
Future<({List<MediaItem> items, int totalSize})> fetchPersonMediaPage(
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
||||
String personId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
|
||||
return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3470,6 +3652,21 @@ class PlexClient
|
||||
@override
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId);
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(
|
||||
String hubId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final result = await _getHubContentPage(hubId, start: start, size: size, abort: abort);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
offset: start ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Plex-specific: top-level folders in a library.
|
||||
Future<List<MediaItem>> fetchLibraryFolders(String sectionId) async {
|
||||
final raw = await _getLibraryFolders(sectionId);
|
||||
|
||||
@@ -30,6 +30,7 @@ import '../profiles/profile.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/library_refresh_notifier.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
@@ -945,13 +946,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
try {
|
||||
final item = _mediaItem!;
|
||||
|
||||
final playlists = await client.fetchPlaylists(playlistType: 'video');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _PlaylistSelectionDialog(playlists: playlists),
|
||||
builder: (context) => _PlaylistSelectionDialog(client: client),
|
||||
);
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
@@ -1050,14 +1047,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final collections = await client.fetchCollections(libraryId);
|
||||
|
||||
final resolvedLibraryId = libraryId;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _CollectionSelectionDialog(collections: collections),
|
||||
builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId),
|
||||
);
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
@@ -1076,7 +1071,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}');
|
||||
final newCollectionId = await client.createCollection(
|
||||
libraryId: libraryId,
|
||||
libraryId: resolvedLibraryId,
|
||||
title: collectionName,
|
||||
items: [item],
|
||||
itemKind: itemKind,
|
||||
@@ -1520,11 +1515,78 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog to select a playlist or create a new one
|
||||
class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
final List<MediaPlaylist> playlists;
|
||||
/// Dialog to select a playlist or create a new one.
|
||||
class _PlaylistSelectionDialog extends StatefulWidget {
|
||||
final MediaServerClient client;
|
||||
|
||||
const _PlaylistSelectionDialog({required this.playlists});
|
||||
const _PlaylistSelectionDialog({required this.client});
|
||||
|
||||
@override
|
||||
State<_PlaylistSelectionDialog> createState() => _PlaylistSelectionDialogState();
|
||||
}
|
||||
|
||||
class _PlaylistSelectionDialogState extends State<_PlaylistSelectionDialog> {
|
||||
static const int _pageSize = 100;
|
||||
|
||||
final AbortController _abortController = AbortController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<MediaPlaylist> _playlists = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
int? _totalCount;
|
||||
|
||||
bool get _hasMore => _totalCount == null || _playlists.length < _totalCount!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
unawaited(_loadNextPage());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_abortController.abort();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients || !_hasMore || _isLoading) return;
|
||||
final position = _scrollController.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 240) {
|
||||
unawaited(_loadNextPage());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNextPage() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
final page = await widget.client.fetchPlaylistsPage(
|
||||
playlistType: 'video',
|
||||
smart: false,
|
||||
start: _playlists.length,
|
||||
size: _pageSize,
|
||||
abort: _abortController,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_playlists.addAll(page.items);
|
||||
_totalCount = page.totalCount;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1533,8 +1595,9 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length + 1,
|
||||
itemCount: _playlists.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
// Create new playlist option (always shown first)
|
||||
@@ -1545,10 +1608,28 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final playlist = playlists[index - 1];
|
||||
final subtitleText = playlist.leafCount == 1
|
||||
? t.playlists.oneItem
|
||||
: t.playlists.itemCount(count: playlist.leafCount!);
|
||||
if (index > _playlists.length) {
|
||||
if (_errorMessage != null) {
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
||||
title: Text(t.messages.errorLoading(error: _errorMessage!)),
|
||||
trailing: TextButton(onPressed: _loadNextPage, child: Text(t.common.retry)),
|
||||
);
|
||||
}
|
||||
if (_hasMore && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_loadNextPage());
|
||||
});
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final playlist = _playlists[index - 1];
|
||||
final leafCount = playlist.leafCount;
|
||||
final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0);
|
||||
return ListTile(
|
||||
leading: playlist.smart
|
||||
? const AppIcon(Symbols.auto_awesome_rounded, fill: 1)
|
||||
@@ -1576,34 +1657,104 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
|
||||
/// Dialog to select a collection or create a new one
|
||||
class _CollectionSelectionDialog extends StatefulWidget {
|
||||
final List<MediaItem> collections;
|
||||
final MediaServerClient client;
|
||||
final String libraryId;
|
||||
|
||||
const _CollectionSelectionDialog({required this.collections});
|
||||
const _CollectionSelectionDialog({required this.client, required this.libraryId});
|
||||
|
||||
@override
|
||||
State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState();
|
||||
}
|
||||
|
||||
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
||||
static const int _pageSize = 100;
|
||||
|
||||
late final _filterController = createTextEditingController();
|
||||
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
|
||||
final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem');
|
||||
late List<MediaItem> _filteredCollections = widget.collections;
|
||||
final AbortController _abortController = AbortController();
|
||||
final _scrollController = ScrollController();
|
||||
final List<MediaItem> _collections = [];
|
||||
List<MediaItem> _filteredCollections = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
int? _totalCount;
|
||||
|
||||
bool get _hasMore => _totalCount == null || _collections.length < _totalCount!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
unawaited(_loadNextPage());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_abortController.abort();
|
||||
_scrollController.dispose();
|
||||
_filterFocusNode.dispose();
|
||||
_firstCollectionFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFilterChanged(String query) {
|
||||
final lower = query.toLowerCase();
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients || !_hasMore || _isLoading) return;
|
||||
final position = _scrollController.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 240) {
|
||||
unawaited(_loadNextPage());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNextPage() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
setState(() {
|
||||
_filteredCollections = lower.isEmpty
|
||||
? widget.collections
|
||||
: widget.collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList();
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
while (mounted && _hasMore) {
|
||||
final page = await widget.client.fetchCollectionsPage(
|
||||
widget.libraryId,
|
||||
start: _collections.length,
|
||||
size: _pageSize,
|
||||
abort: _abortController,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_collections.addAll(page.items);
|
||||
_totalCount = page.totalCount;
|
||||
_applyFilter(_filterController.text);
|
||||
});
|
||||
if (_filterController.text.isEmpty || page.items.isEmpty) break;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onFilterChanged(String query) {
|
||||
setState(() {
|
||||
_applyFilter(query);
|
||||
});
|
||||
if (query.isNotEmpty && _hasMore) {
|
||||
unawaited(_loadNextPage());
|
||||
}
|
||||
}
|
||||
|
||||
void _applyFilter(String query) {
|
||||
final lower = query.toLowerCase();
|
||||
_filteredCollections = lower.isEmpty
|
||||
? List.of(_collections)
|
||||
: _collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1615,7 +1766,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.collections.length >= 10) ...[
|
||||
if (_collections.length >= 10) ...[
|
||||
FocusableTextField(
|
||||
controller: _filterController,
|
||||
focusNode: _filterFocusNode,
|
||||
@@ -1632,19 +1783,39 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
],
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredCollections.length + 1,
|
||||
itemCount: _filteredCollections.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return FocusableListTile(
|
||||
focusNode: _firstCollectionFocusNode,
|
||||
autofocus: widget.collections.length < 10,
|
||||
autofocus: _collections.length < 10,
|
||||
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||
title: Text(t.common.createNew),
|
||||
onTap: () => Navigator.pop(context, '_create_new'),
|
||||
);
|
||||
}
|
||||
|
||||
if (index > _filteredCollections.length) {
|
||||
if (_errorMessage != null) {
|
||||
return FocusableListTile(
|
||||
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
||||
title: Text(t.messages.errorLoading(error: _errorMessage!)),
|
||||
onTap: _loadNextPage,
|
||||
);
|
||||
}
|
||||
if (_hasMore && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_loadNextPage());
|
||||
});
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final collection = _filteredCollections[index - 1];
|
||||
return FocusableListTile(
|
||||
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
||||
|
||||
@@ -26,7 +26,7 @@ class _PaginatedProbe extends StatefulWidget {
|
||||
State<_PaginatedProbe> createState() => _PaginatedProbeState();
|
||||
}
|
||||
|
||||
class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader {
|
||||
class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader<MediaItem, _PaginatedProbe> {
|
||||
int fetchCalls = 0;
|
||||
final List<({int start, int size})> fetchArgs = [];
|
||||
|
||||
@@ -515,7 +515,7 @@ void main() {
|
||||
|
||||
// Kick off the initial load — its future is `staleFetch` and won't
|
||||
// resolve until we say so.
|
||||
final firstLoad = state.loadInitialPage(10);
|
||||
final firstLoad = state.loadInitialPageWithStatus(10);
|
||||
|
||||
// Reset state mid-flight; the next loadInitialPage should be authoritative.
|
||||
// ignore: invalid_use_of_protected_member
|
||||
@@ -524,15 +524,53 @@ void main() {
|
||||
// Resolve the *stale* future — the generation has been bumped, so this
|
||||
// result must be discarded.
|
||||
staleFetch!.complete(_result(start: 0, size: 10, totalSize: 50));
|
||||
await firstLoad;
|
||||
final staleResult = await firstLoad;
|
||||
await tester.pump();
|
||||
|
||||
expect(staleResult.applied, isFalse);
|
||||
expect(state.totalSize, 0); // stale result was dropped
|
||||
expect(state.loadedItems, isEmpty);
|
||||
|
||||
// Now run a fresh load that resolves with totalSize=99.
|
||||
await state.loadInitialPage(10);
|
||||
final freshResult = await state.loadInitialPageWithStatus(10);
|
||||
await tester.pump();
|
||||
expect(freshResult.applied, isTrue);
|
||||
expect(state.totalSize, 99);
|
||||
});
|
||||
|
||||
testWidgets('a stale in-flight failure from before resetPaginationState is dropped', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
Completer<LibraryPage<MediaItem>>? staleFetch;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) {
|
||||
if (staleFetch == null) {
|
||||
staleFetch = Completer<LibraryPage<MediaItem>>();
|
||||
return staleFetch!.future;
|
||||
}
|
||||
return Future.value(_result(start: start, size: size, totalSize: 99));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final firstLoad = state.loadInitialPageWithStatus(10);
|
||||
|
||||
// ignore: invalid_use_of_protected_member
|
||||
state.setState(() => state.resetPaginationState());
|
||||
|
||||
staleFetch!.completeError(MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'aborted'));
|
||||
final staleResult = await firstLoad;
|
||||
await tester.pump();
|
||||
|
||||
expect(staleResult.applied, isFalse);
|
||||
expect(state.totalSize, 0);
|
||||
expect(state.loadedItems, isEmpty);
|
||||
|
||||
final freshResult = await state.loadInitialPageWithStatus(10);
|
||||
await tester.pump();
|
||||
expect(freshResult.applied, isTrue);
|
||||
expect(state.totalSize, 99);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -821,7 +821,7 @@ void main() {
|
||||
libraryKind: MediaKind.show,
|
||||
);
|
||||
|
||||
expect(captured[0].queryParameters['IncludeItemTypes'], 'Series');
|
||||
expect(captured.first.queryParameters['IncludeItemTypes'], 'Series');
|
||||
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
|
||||
});
|
||||
|
||||
@@ -1263,9 +1263,10 @@ void main() {
|
||||
expect(captured!.path, '/UserItems/Resume');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['Limit'], '50');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['MediaTypes'], 'Video');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
@@ -1280,9 +1281,10 @@ void main() {
|
||||
expect(captured!.path, '/Shows/NextUp');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['Limit'], '25');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
@@ -1313,8 +1315,9 @@ void main() {
|
||||
expect(captured!.path, '/UserItems/Resume');
|
||||
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
client.close();
|
||||
@@ -1328,8 +1331,9 @@ void main() {
|
||||
expect(captured!.path, '/Shows/NextUp');
|
||||
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
@@ -1344,6 +1348,91 @@ void main() {
|
||||
expect(captured, isNull);
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('paged Resume hub sends requested offset and parses total count', () async {
|
||||
Uri? requestUri;
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 30,
|
||||
'Items': [
|
||||
{'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, 'resume-20');
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.path, '/UserItems/Resume');
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
});
|
||||
|
||||
test('Latest hub is treated as a single page when offset is requested', () async {
|
||||
var requestCount = 0;
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
requestCount++;
|
||||
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchMoreHubItemsPage('home.recent', start: 20, size: 10);
|
||||
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.totalCount, 20);
|
||||
expect(page.offset, 20);
|
||||
expect(requestCount, 0);
|
||||
});
|
||||
|
||||
test('paged hub first-page errors throw while list helper keeps empty fallback', () async {
|
||||
var requestCount = 0;
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
requestCount++;
|
||||
return http.Response('server error', 500);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 0, size: 10), throwsA(isA<Exception>()));
|
||||
|
||||
final items = await client.fetchMoreHubItems('home.continue');
|
||||
expect(items, isEmpty);
|
||||
expect(requestCount, 2);
|
||||
});
|
||||
|
||||
test('paged hub later-page errors throw instead of truncating', () async {
|
||||
var requestCount = 0;
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
requestCount++;
|
||||
return http.Response('server error', 500);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10), throwsA(isA<Exception>()));
|
||||
|
||||
expect(requestCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('JellyfinClient.fetchCollections', () {
|
||||
@@ -1366,6 +1455,7 @@ void main() {
|
||||
if (req.url.path == '/Items') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 1,
|
||||
'Items': [
|
||||
{'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
|
||||
],
|
||||
@@ -1389,12 +1479,97 @@ void main() {
|
||||
expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies'));
|
||||
expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet');
|
||||
expect(itemsRequest.queryParameters['Recursive'], 'true');
|
||||
expect(itemsRequest.queryParameters['StartIndex'], '0');
|
||||
expect(itemsRequest.queryParameters['Limit'], '200');
|
||||
expect(itemsRequest.queryParameters['SortBy'], 'SortName');
|
||||
expect(itemsRequest.queryParameters['SortOrder'], 'Ascending');
|
||||
});
|
||||
|
||||
test('falls back to global BoxSet query when boxsets view is missing', () async {
|
||||
test('fetchCollectionsPage uses requested collection page bounds', () async {
|
||||
Uri? itemsRequest;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Users/user-1/Views') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (req.url.path == '/Items') {
|
||||
itemsRequest = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 30,
|
||||
'Items': [
|
||||
{'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchCollectionsPage('lib-movies', start: 20, size: 10);
|
||||
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
expect(page.items.single.id, 'collection-20');
|
||||
expect(itemsRequest, isNotNull);
|
||||
expect(itemsRequest!.queryParameters['ParentId'], 'lib-boxsets');
|
||||
expect(itemsRequest!.queryParameters['StartIndex'], '20');
|
||||
expect(itemsRequest!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
test('walks boxsets view in pages', () async {
|
||||
final itemRequests = <Uri>[];
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Users/user-1/Views') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (req.url.path == '/Items') {
|
||||
itemRequests.add(req.url);
|
||||
final start = req.url.queryParameters['StartIndex'];
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 2,
|
||||
'Items': [
|
||||
{'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final collections = await client.fetchCollections('lib-movies');
|
||||
|
||||
expect(collections.map((c) => c.id).toList(), ['collection-1', 'collection-2']);
|
||||
expect(itemRequests.map((u) => u.queryParameters['StartIndex']).toList(), ['0', '1']);
|
||||
expect(itemRequests.every((u) => u.queryParameters['Limit'] == '200'), isTrue);
|
||||
});
|
||||
|
||||
test('returns empty when boxsets view is missing', () async {
|
||||
var itemsRequested = false;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Users/user-1/Views') {
|
||||
return http.Response(
|
||||
@@ -1408,7 +1583,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
if (req.url.path == '/Items') {
|
||||
itemsRequest = req.url;
|
||||
itemsRequested = true;
|
||||
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
@@ -1416,12 +1591,43 @@ void main() {
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
await client.fetchCollections('lib-movies');
|
||||
final collections = await client.fetchCollections('lib-movies');
|
||||
|
||||
expect(collections, isEmpty);
|
||||
expect(itemsRequested, isFalse);
|
||||
});
|
||||
|
||||
test('fetchCollectionPage uses Jellyfin item paging', () async {
|
||||
Uri? itemsRequest;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
itemsRequest = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 25,
|
||||
'Items': [
|
||||
{'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchCollectionPage('collection-1', start: 20, size: 5);
|
||||
|
||||
expect(page.totalCount, 25);
|
||||
expect(page.offset, 20);
|
||||
expect(page.items.single.id, 'movie-1');
|
||||
expect(itemsRequest, isNotNull);
|
||||
expect(itemsRequest!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
expect(itemsRequest!.queryParameters['IncludeItemTypes'], 'BoxSet');
|
||||
expect(itemsRequest!.queryParameters['Recursive'], 'true');
|
||||
expect(itemsRequest!.queryParameters['ParentId'], 'collection-1');
|
||||
expect(itemsRequest!.queryParameters['StartIndex'], '20');
|
||||
expect(itemsRequest!.queryParameters['Limit'], '5');
|
||||
expect(itemsRequest!.queryParameters.containsKey('Recursive'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1460,18 +1666,124 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('JellyfinClient paged media lists', () {
|
||||
test('fetchPersonMediaPage uses requested page bounds', () async {
|
||||
Uri? requestUri;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'},
|
||||
],
|
||||
'TotalRecordCount': 40,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPersonMediaPage('person-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, 'movie-1');
|
||||
expect(page.totalCount, 40);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['PersonIds'], 'person-1');
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
test('fetchPlayableDescendantsPage uses requested page bounds', () async {
|
||||
Uri? requestUri;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'},
|
||||
],
|
||||
'TotalRecordCount': 40,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, 'episode-1');
|
||||
expect(page.totalCount, 40);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['ParentId'], 'show-1');
|
||||
expect(requestUri!.queryParameters['Recursive'], 'true');
|
||||
expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode');
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
test('fetchChildren walks generic children pages', () async {
|
||||
final itemRequests = <Uri>[];
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Shows/season-1/Seasons') {
|
||||
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
|
||||
}
|
||||
if (req.url.path == '/Items') {
|
||||
itemRequests.add(req.url);
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
final count = start == 0 ? 500 : 1;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': List.generate(
|
||||
count,
|
||||
(i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'},
|
||||
),
|
||||
'TotalRecordCount': 501,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final items = await client.fetchChildren('season-1');
|
||||
|
||||
expect(items.length, 501);
|
||||
expect(itemRequests.map((u) => u.queryParameters['StartIndex']), ['0', '500']);
|
||||
expect(itemRequests.every((u) => u.queryParameters['Limit'] == '500'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('JellyfinClient.fetchPlaylists filtering', () {
|
||||
JellyfinClient buildClient() {
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
final requestedMediaType = req.url.queryParameters['MediaTypes']?.toLowerCase();
|
||||
final items =
|
||||
[
|
||||
{'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
||||
{'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
|
||||
{'Id': 'photo-1', 'Name': 'Photo Playlist', 'Type': 'Playlist', 'MediaType': 'Photo'},
|
||||
],
|
||||
}),
|
||||
].where((item) {
|
||||
if (requestedMediaType == null) return true;
|
||||
return (item['MediaType'] as String).toLowerCase() == requestedMediaType;
|
||||
}).toList();
|
||||
return http.Response(
|
||||
jsonEncode({'Items': items, 'TotalRecordCount': items.length}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
@@ -1490,6 +1802,125 @@ void main() {
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('fetchPlaylistsPage uses filtered playlist offsets', () async {
|
||||
final requests = <Uri>[];
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
requests.add(req.url);
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': List.generate(
|
||||
10,
|
||||
(i) => {'Id': 'video-${start + i}', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
||||
),
|
||||
'TotalRecordCount': 50,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 20, size: 10);
|
||||
|
||||
expect(page.items.map((item) => item.id), List.generate(10, (i) => 'video-${20 + i}'));
|
||||
expect(page.totalCount, 31);
|
||||
expect(page.offset, 20);
|
||||
expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '10', '20']);
|
||||
expect(requests.every((uri) => uri.queryParameters['IncludeItemTypes'] == 'Playlist'), isTrue);
|
||||
expect(requests.every((uri) => uri.queryParameters.containsKey('MediaTypes')), isFalse);
|
||||
expect(requests.every((uri) => uri.queryParameters['Limit'] == '10'), isTrue);
|
||||
});
|
||||
|
||||
test('fetchPlaylistsPage filters playlist type client-side', () async {
|
||||
final requests = <Uri>[];
|
||||
final allItems = [
|
||||
{'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
|
||||
{'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
||||
{'Id': 'audio-2', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
|
||||
{'Id': 'video-2', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
||||
];
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
requests.add(req.url);
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
final limit = int.parse(req.url.queryParameters['Limit'] ?? '2');
|
||||
return http.Response(
|
||||
jsonEncode({'Items': allItems.skip(start).take(limit).toList(), 'TotalRecordCount': allItems.length}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 0, size: 2);
|
||||
|
||||
expect(page.items.map((item) => item.id), ['video-1', 'video-2']);
|
||||
expect(page.totalCount, 2);
|
||||
expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '2']);
|
||||
});
|
||||
|
||||
test('fetchPlaylistPage uses requested item page bounds', () async {
|
||||
Uri? requestUri;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Playlists/pl-1/Items') {
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'},
|
||||
],
|
||||
'TotalRecordCount': 40,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, 'movie-1');
|
||||
expect(page.totalCount, 40);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async {
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Playlists/pl-1/Items') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}),
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.length, 10);
|
||||
expect(page.totalCount, 31);
|
||||
expect(page.offset, 20);
|
||||
});
|
||||
|
||||
test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async {
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/jellyfin/Items') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:plezy/providers/playback_state_provider.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/jellyfin_sequential_launcher.dart';
|
||||
import 'package:plezy/services/media_list_playback_launcher.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
/// Recording fake that satisfies [JellyfinClient] via `implements` +
|
||||
/// `noSuchMethod`. The launcher only needs the
|
||||
@@ -42,10 +44,24 @@ class _RecordingJellyfinClient implements JellyfinClient {
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
||||
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
||||
return page.items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? 100;
|
||||
fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit));
|
||||
if (offset >= playlistItemsResponse.length) return const [];
|
||||
if (offset >= playlistItemsResponse.length) {
|
||||
return LibraryPage<MediaItem>(items: const [], totalCount: playlistItemsResponse.length, offset: offset);
|
||||
}
|
||||
final end = (offset + limit).clamp(0, playlistItemsResponse.length);
|
||||
return playlistItemsResponse.sublist(offset, end);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: playlistItemsResponse.sublist(offset, end),
|
||||
totalCount: playlistItemsResponse.length,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -219,7 +235,7 @@ void main() {
|
||||
expect(result, isA<PlayQueueSuccess>());
|
||||
expect(playback.loadedItems.length, 150);
|
||||
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(2));
|
||||
expect(fakeClient.fetchPlaylistItemsCalls[0].offset, 0);
|
||||
expect(fakeClient.fetchPlaylistItemsCalls.first.offset, 0);
|
||||
expect(fakeClient.fetchPlaylistItemsCalls[1].offset, 100);
|
||||
});
|
||||
|
||||
|
||||
@@ -180,6 +180,394 @@ void main() {
|
||||
expect(page.items.single.libraryId, '7');
|
||||
expect(page.items.single.libraryTitle, 'Movies');
|
||||
});
|
||||
|
||||
test('library collections are fetched in pages', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/sections/7/collections') {
|
||||
requests.add(request.url);
|
||||
final start = request.url.queryParameters['X-Plex-Container-Start'];
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 2,
|
||||
'Metadata': [
|
||||
{'ratingKey': start == '0' ? '99' : '100', 'type': 'collection', 'title': 'Collection'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final collections = await client.fetchCollections('7');
|
||||
|
||||
expect(collections.map((item) => item.id).toList(), ['99', '100']);
|
||||
expect(requests.map((u) => u.queryParameters['X-Plex-Container-Start']).toList(), ['0', '1']);
|
||||
expect(requests.every((u) => u.queryParameters['X-Plex-Container-Size'] == '200'), isTrue);
|
||||
expect(requests.every((u) => u.queryParameters['includeGuids'] == '1'), isTrue);
|
||||
});
|
||||
|
||||
test('library collection page passes requested pagination params', () async {
|
||||
Uri? requestUri;
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/sections/7/collections') {
|
||||
requestUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 50,
|
||||
'Metadata': [
|
||||
{'ratingKey': '120', 'type': 'collection', 'title': 'Collection'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchCollectionsPage('7', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '120');
|
||||
expect(page.totalCount, 50);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Start'], '20');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
|
||||
expect(requestUri!.queryParameters['includeGuids'], '1');
|
||||
});
|
||||
|
||||
test('playlist page passes requested pagination params', () async {
|
||||
Uri? requestUri;
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists') {
|
||||
requestUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 50,
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': '120',
|
||||
'key': '/playlists/120/items',
|
||||
'type': 'playlist',
|
||||
'playlistType': 'video',
|
||||
'title': 'Playlist',
|
||||
'smart': false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistsPage(start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '120');
|
||||
expect(page.totalCount, 50);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['playlistType'], 'video');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Start'], '20');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
|
||||
});
|
||||
|
||||
test('playlist page fallback total only exposes one possible next item', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 10,
|
||||
'Metadata': List.generate(
|
||||
10,
|
||||
(i) => {
|
||||
'ratingKey': '${120 + i}',
|
||||
'key': '/playlists/${120 + i}/items',
|
||||
'type': 'playlist',
|
||||
'playlistType': 'video',
|
||||
'title': 'Playlist',
|
||||
'smart': false,
|
||||
},
|
||||
),
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistsPage(start: 20, size: 10);
|
||||
|
||||
expect(page.items.length, 10);
|
||||
expect(page.totalCount, 31);
|
||||
expect(page.offset, 20);
|
||||
});
|
||||
|
||||
test('playlist page uses X-Plex-Container-Total-Size header when body total is absent', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': '120',
|
||||
'key': '/playlists/120/items',
|
||||
'type': 'playlist',
|
||||
'playlistType': 'video',
|
||||
'title': 'Playlist',
|
||||
'smart': false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json', 'X-Plex-Container-Total-Size': '50'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistsPage(start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '120');
|
||||
expect(page.totalCount, 50);
|
||||
expect(page.offset, 20);
|
||||
});
|
||||
|
||||
test('fetchPlaylists walks playlist pages', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists') {
|
||||
requests.add(request.url);
|
||||
final start = int.parse(request.url.queryParameters['X-Plex-Container-Start'] ?? '0');
|
||||
final metadata = start == 0
|
||||
? [
|
||||
{'ratingKey': '1', 'type': 'playlist', 'playlistType': 'video', 'title': 'One'},
|
||||
{'ratingKey': '2', 'type': 'playlist', 'playlistType': 'video', 'title': 'Two'},
|
||||
]
|
||||
: [
|
||||
{'ratingKey': '3', 'type': 'playlist', 'playlistType': 'video', 'title': 'Three'},
|
||||
];
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'size': metadata.length, 'totalSize': 3, 'Metadata': metadata},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final playlists = await client.fetchPlaylists();
|
||||
|
||||
expect(playlists.map((p) => p.id), ['1', '2', '3']);
|
||||
expect(requests.map((u) => u.queryParameters['X-Plex-Container-Start']), ['0', '2']);
|
||||
expect(requests.every((u) => u.queryParameters['X-Plex-Container-Size'] == '200'), isTrue);
|
||||
});
|
||||
|
||||
test('fetchPlaylists returns empty on list failure', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists') {
|
||||
return http.Response('server error', 500);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final playlists = await client.fetchPlaylists();
|
||||
|
||||
expect(playlists, isEmpty);
|
||||
});
|
||||
|
||||
test('playlist item page passes requested pagination params', () async {
|
||||
Uri? requestUri;
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists/42/items') {
|
||||
requestUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 30,
|
||||
'Metadata': [
|
||||
{'ratingKey': '99', 'type': 'movie', 'title': 'Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistPage('42', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '99');
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Start'], '20');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
|
||||
});
|
||||
|
||||
test('playlist item page uses X-Plex-Container-Total-Size header when body total is absent', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/playlists/42/items') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': '99', 'type': 'movie', 'title': 'Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json', 'X-Plex-Container-Total-Size': '30'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlaylistPage('42', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '99');
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
});
|
||||
|
||||
test('person media page passes requested pagination params', () async {
|
||||
Uri? requestUri;
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/people/person-1/media') {
|
||||
requestUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 30,
|
||||
'Metadata': [
|
||||
{'ratingKey': '99', 'type': 'movie', 'title': 'Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPersonMediaPage('person-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, '99');
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Start'], '20');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
|
||||
});
|
||||
|
||||
test('playable descendants page passes requested pagination params', () async {
|
||||
Uri? requestUri;
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/metadata/show-1/grandchildren') {
|
||||
requestUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 30,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'ep-1', 'type': 'episode', 'title': 'Episode'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10);
|
||||
|
||||
expect(page.items.single.id, 'ep-1');
|
||||
expect(page.totalCount, 30);
|
||||
expect(page.offset, 20);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Start'], '20');
|
||||
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
|
||||
});
|
||||
|
||||
test('hub content pages by filtered video item offset', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/hubs/sections/7/recent') {
|
||||
requests.add(request.url);
|
||||
final start = request.url.queryParameters['X-Plex-Container-Start'] ?? '0';
|
||||
final metadata = start == '0'
|
||||
? [
|
||||
{'ratingKey': 'collection-1', 'type': 'collection', 'title': 'Collection'},
|
||||
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie'},
|
||||
]
|
||||
: [
|
||||
{'ratingKey': 'episode-1', 'type': 'episode', 'title': 'Episode'},
|
||||
];
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'size': metadata.length, 'totalSize': 3, 'Metadata': metadata},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final firstPage = await client.fetchMoreHubItemsPage('/hubs/sections/7/recent', start: 0, size: 1);
|
||||
final secondPage = await client.fetchMoreHubItemsPage('/hubs/sections/7/recent', start: 1, size: 1);
|
||||
|
||||
expect(firstPage.items.single.id, 'movie-1');
|
||||
expect(firstPage.totalCount, 2);
|
||||
expect(firstPage.offset, 0);
|
||||
expect(secondPage.items.single.id, 'episode-1');
|
||||
expect(secondPage.totalCount, 2);
|
||||
expect(secondPage.offset, 1);
|
||||
expect(requests.map((u) => u.queryParameters['X-Plex-Container-Start']).toList(), ['0', '0', '2']);
|
||||
expect(requests.every((u) => u.queryParameters['X-Plex-Container-Size'] == '200'), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _filtersPayload() => {
|
||||
|
||||
Reference in New Issue
Block a user