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.
|
/// show, tracks of an album, items of a collection.
|
||||||
Future<List<MediaItem>> fetchChildren(String parentId);
|
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
|
/// Playable descendants of [parentId] in one server-side query — for a
|
||||||
/// show this returns every episode across every season; for a season the
|
/// show this returns every episode across every season; for a season the
|
||||||
/// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
|
/// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
|
||||||
@@ -228,12 +239,19 @@ abstract class MediaServerClient {
|
|||||||
/// Media featuring a specific person/actor.
|
/// Media featuring a specific person/actor.
|
||||||
Future<List<MediaItem>> fetchPersonMedia(String personId);
|
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
|
/// Page through items in [hubId] when the hub previewed only the first N
|
||||||
/// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
|
/// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
|
||||||
/// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
|
/// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
|
||||||
/// (Latest / Resume / NextUp) without the preview limit.
|
/// (Latest / Resume / NextUp) without the preview limit.
|
||||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? 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
|
/// Mark [item] as watched. The full item is passed (not just an id) so
|
||||||
/// implementations can fire a [WatchStateEvent] on [WatchStateNotifier]
|
/// implementations can fire a [WatchStateEvent] on [WatchStateNotifier]
|
||||||
/// for UI invalidation — episode/season/show parent chain, library
|
/// 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});
|
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].
|
/// Metadata only — items are fetched via [fetchPlaylistItems].
|
||||||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id);
|
Future<MediaPlaylist?> fetchPlaylistMetadata(String id);
|
||||||
|
|
||||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100});
|
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
|
/// Create a new playlist seeded with [items]. Returns the created
|
||||||
/// playlist on success, `null` on failure. Plex builds a metadata URI
|
/// playlist on success, `null` on failure. Plex builds a metadata URI
|
||||||
/// from the item ids; Jellyfin posts `Ids=<comma-joined>`.
|
/// 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});
|
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});
|
||||||
|
|
||||||
/// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
|
/// 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`.
|
/// Each result carries `kind == MediaKind.collection`.
|
||||||
Future<List<MediaItem>> fetchCollections(String libraryId);
|
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
|
/// Page through items in [collectionId]. Plex paginates server-side via
|
||||||
/// `/library/collections/{id}/children`; Jellyfin's API has no
|
/// `/library/collections/{id}/children`; Jellyfin uses `/Items` with
|
||||||
/// pagination knob for collection children, so its impl fetches the full
|
/// `ParentId`, `StartIndex`, and `Limit`. Callers can rely on
|
||||||
/// list once (cached on the client) and slices locally. Callers can rely
|
/// [LibraryPage.totalCount] either way.
|
||||||
/// on [LibraryPage.totalCount] either way.
|
|
||||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||||
String collectionId, {
|
String collectionId, {
|
||||||
int? start,
|
int? start,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../media/library_query.dart';
|
import '../media/library_query.dart';
|
||||||
import '../media/media_item.dart';
|
|
||||||
import '../utils/media_server_http_client.dart';
|
import '../utils/media_server_http_client.dart';
|
||||||
import '../exceptions/media_server_exceptions.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
|
/// 2. On scroll, subclass calls [ensureRangeLoaded] with the visible index
|
||||||
/// range. Eager prefetch ahead of the viewport via [prefetchAhead].
|
/// range. Eager prefetch ahead of the viewport via [prefetchAhead].
|
||||||
/// 3. On dispose, subclass calls [disposePagination].
|
/// 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.
|
/// 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.
|
/// Total items on the server. 0 until the first page completes.
|
||||||
int totalSize = 0;
|
int totalSize = 0;
|
||||||
@@ -43,12 +42,12 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
|||||||
VoidCallback? _scheduledRetry;
|
VoidCallback? _scheduledRetry;
|
||||||
|
|
||||||
/// Fetch a page of items. Subclass implements this — typically delegating
|
/// Fetch a page of items. Subclass implements this — typically delegating
|
||||||
/// to a paginated client method that returns a [LibraryPage] of [MediaItem].
|
/// to a paginated client method that returns a [LibraryPage].
|
||||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort);
|
Future<LibraryPage<T>> fetchPage(int start, int size, AbortController? abort);
|
||||||
|
|
||||||
/// Hook fired after each successful page merge. Default: no-op.
|
/// Hook fired after each successful page merge. Default: no-op.
|
||||||
/// Override for image prefetch, syncing a base-class `items` list, etc.
|
/// 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.
|
/// Synchronously clear pagination state and bump the generation counter.
|
||||||
/// Call from inside the subclass's `setState` before awaiting
|
/// 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
|
/// Fetch the first page. Await from outside `setState`. Mutates
|
||||||
/// [loadedItems] and [totalSize] on success; throws on failure.
|
/// [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 generation = _requestId;
|
||||||
final result = await fetchPage(0, pageSize, _cancelToken);
|
late final LibraryPage<T> result;
|
||||||
if (generation != _requestId || !mounted) return 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++) {
|
for (var i = 0; i < result.items.length; i++) {
|
||||||
loadedItems[i] = result.items[i];
|
loadedItems[i] = result.items[i];
|
||||||
}
|
}
|
||||||
totalSize = result.totalCount;
|
totalSize = result.totalCount;
|
||||||
onPageLoaded(0, result.items);
|
onPageLoaded(0, result.items);
|
||||||
return result;
|
return (page: result, applied: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
/// 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).
|
/// [totalSize] even if [index] wasn't in the sparse map (evicted).
|
||||||
void removeLoadedItemAndShift(int index) {
|
void removeLoadedItemAndShift(int index) {
|
||||||
loadedItems.remove(index);
|
loadedItems.remove(index);
|
||||||
final shifted = <int, MediaItem>{};
|
final shifted = <int, T>{};
|
||||||
for (final entry in loadedItems.entries) {
|
for (final entry in loadedItems.entries) {
|
||||||
if (entry.key > index) {
|
if (entry.key > index) {
|
||||||
shifted[entry.key - 1] = entry.value;
|
shifted[entry.key - 1] = entry.value;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import '../media/library_query.dart';
|
||||||
import '../media/media_backend.dart';
|
import '../media/media_backend.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_kind.dart';
|
import '../media/media_kind.dart';
|
||||||
import '../media/media_server_client.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 '../utils/provider_extensions.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../widgets/optimized_media_image.dart';
|
import '../widgets/optimized_media_image.dart';
|
||||||
@@ -41,9 +45,11 @@ class ActorMediaScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||||
with
|
with
|
||||||
StandardItemLoader<ActorMediaScreen>,
|
|
||||||
GridFocusNodeMixin<ActorMediaScreen>,
|
GridFocusNodeMixin<ActorMediaScreen>,
|
||||||
FocusableDetailScreenMixin<ActorMediaScreen> {
|
FocusableDetailScreenMixin<ActorMediaScreen>,
|
||||||
|
PaginatedItemLoader<MediaItem, ActorMediaScreen> {
|
||||||
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MediaItem get mediaItem => MediaItem(
|
MediaItem get mediaItem => MediaItem(
|
||||||
id: '',
|
id: '',
|
||||||
@@ -63,10 +69,11 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
|||||||
String get emptyMessage => t.discover.noContentAvailable;
|
String get emptyMessage => t.discover.noContentAvailable;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get hasItems => items.isNotEmpty;
|
bool get hasItems => totalSize > 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
disposePagination();
|
||||||
disposeFocusResources();
|
disposeFocusResources();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -74,12 +81,46 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
|||||||
MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId);
|
MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId);
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Future<void> loadItems() async {
|
Future<void> loadItems() async {
|
||||||
await super.loadItems();
|
setState(() {
|
||||||
autoFocusFirstItemAfterLoad();
|
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
|
@override
|
||||||
@@ -126,10 +167,10 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (items.isNotEmpty) ...[
|
if (totalSize > 0) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${items.length} ${items.length == 1 ? 'title' : 'titles'}',
|
'$totalSize ${totalSize == 1 ? 'title' : 'titles'}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
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()),
|
CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()),
|
||||||
_buildActorHeader(),
|
_buildActorHeader(),
|
||||||
...buildStateSlivers(),
|
...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
|
with
|
||||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||||
PaginatedItemLoader<CollectionDetailScreen> {
|
PaginatedItemLoader<MediaItem, CollectionDetailScreen> {
|
||||||
static const int _pageSize = 200;
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -92,8 +92,8 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
|||||||
resetPaginationState();
|
resetPaginationState();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await loadInitialPage(_pageSize);
|
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
||||||
if (!mounted) return;
|
if (!initialPage.applied || !mounted) return;
|
||||||
// Mirror loadedItems into base-class [items] once so state-sliver checks
|
// Mirror loadedItems into base-class [items] once so state-sliver checks
|
||||||
// (items.isEmpty vs items.isEmpty && isLoading) pick the right branch.
|
// (items.isEmpty vs items.isEmpty && isLoading) pick the right branch.
|
||||||
// Further pages only update loadedItems; items.isEmpty stays false.
|
// Further pages only update loadedItems; items.isEmpty stays false.
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.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_hub.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
|
import '../media/media_server_client.dart';
|
||||||
import '../media/media_sort.dart';
|
import '../media/media_sort.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
@@ -45,13 +48,20 @@ class HubDetailScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _HubDetailScreenState extends State<HubDetailScreen>
|
class _HubDetailScreenState extends State<HubDetailScreen>
|
||||||
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
|
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
|
||||||
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
List<MediaItem> _items = [];
|
List<MediaItem> _items = [];
|
||||||
List<MediaItem> _filteredItems = [];
|
List<MediaItem> _filteredItems = [];
|
||||||
List<MediaSort> _sortOptions = [];
|
List<MediaSort> _sortOptions = [];
|
||||||
MediaSort? _selectedSort;
|
MediaSort? _selectedSort;
|
||||||
bool _isSortDescending = false;
|
bool _isSortDescending = false;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
String? _continuationErrorMessage;
|
||||||
|
int? _continuationOffset;
|
||||||
|
int? _continuationTotal;
|
||||||
|
int _loadGeneration = 0;
|
||||||
|
|
||||||
/// Key for getting a context below OverlaySheetHost
|
/// Key for getting a context below OverlaySheetHost
|
||||||
final GlobalKey _overlayChildKey = GlobalKey();
|
final GlobalKey _overlayChildKey = GlobalKey();
|
||||||
@@ -225,6 +235,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
|
|
||||||
Future<void> _loadMoreItems() async {
|
Future<void> _loadMoreItems() async {
|
||||||
if (_isLoading) return;
|
if (_isLoading) return;
|
||||||
|
final generation = ++_loadGeneration;
|
||||||
|
|
||||||
final serverId = widget.hub.serverId;
|
final serverId = widget.hub.serverId;
|
||||||
if (widget.loadItems == null && serverId == null) {
|
if (widget.loadItems == null && serverId == null) {
|
||||||
@@ -234,23 +245,33 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
_isLoadingMore = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_continuationErrorMessage = null;
|
||||||
|
_continuationOffset = null;
|
||||||
|
_continuationTotal = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final loader = widget.loadItems;
|
final loader = widget.loadItems;
|
||||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
||||||
var items = loader == null
|
final List<MediaItem> items;
|
||||||
? (client == null ? const <MediaItem>[] : await client.fetchMoreHubItems(widget.hub.id))
|
int totalCount;
|
||||||
: await loader();
|
int loadedCount;
|
||||||
|
if (loader == null) {
|
||||||
// Filter to specific library if this hub was split from a multi-library hub
|
final page = client == null
|
||||||
final sectionFilter = int.tryParse(widget.hub.libraryId ?? '');
|
? const LibraryPage<MediaItem>(items: [], totalCount: 0)
|
||||||
if (sectionFilter != null) {
|
: await client.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize);
|
||||||
items = items.where((item) => int.tryParse(item.libraryId ?? '') == sectionFilter).toList();
|
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(() {
|
setState(() {
|
||||||
_items = items;
|
_items = items;
|
||||||
_filteredItems = items;
|
_filteredItems = items;
|
||||||
@@ -258,6 +279,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
_applySort();
|
_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}');
|
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
|
||||||
} catch (e) {
|
} 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 {
|
Future<void> _handleItemRefresh(String ratingKey) async {
|
||||||
final itemIndex = _items.indexWhere((item) => item.id == ratingKey);
|
final itemIndex = _items.indexWhere((item) => item.id == ratingKey);
|
||||||
final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
|
final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
|
||||||
@@ -302,6 +430,27 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
unawaited(_loadMoreItems());
|
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
|
@override
|
||||||
void refresh() {
|
void refresh() {
|
||||||
_loadMoreItems();
|
_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,
|
GridFocusNodeMixin,
|
||||||
WatchStateAware,
|
WatchStateAware,
|
||||||
DeletionAware,
|
DeletionAware,
|
||||||
PaginatedItemLoader<LibraryBrowseTab> {
|
PaginatedItemLoader<MediaItem, LibraryBrowseTab> {
|
||||||
@override
|
@override
|
||||||
String? get itemServerId => widget.library.serverId;
|
String? get itemServerId => widget.library.serverId;
|
||||||
|
|
||||||
@@ -598,9 +598,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadInitialPage(_calculateInitialFetchSize());
|
final initialPage = await loadInitialPageWithStatus(_calculateInitialFetchSize());
|
||||||
|
|
||||||
if (generation != _contentRequestId || !mounted) return;
|
if (!initialPage.applied || generation != _contentRequestId || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import '../../../media/library_query.dart';
|
||||||
import '../../../media/media_item.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/library_refresh_notifier.dart';
|
||||||
|
import '../../../utils/media_server_http_client.dart';
|
||||||
import '../../../widgets/focusable_media_card.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 '../../../i18n/strings.g.dart';
|
||||||
import '../adaptive_media_grid.dart';
|
import '../../main_screen.dart';
|
||||||
import 'base_library_tab.dart';
|
import 'base_library_tab.dart';
|
||||||
import 'library_grid_tab_state.dart';
|
|
||||||
|
|
||||||
/// Collections tab for library screen.
|
/// Collections tab for library screen.
|
||||||
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
|
/// 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();
|
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
|
@override
|
||||||
String get focusNodeDebugLabel => 'collections_first_item';
|
String get focusNodeDebugLabel => 'collections_first_item';
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get itemCount => totalSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get emptyIcon => Symbols.collections_rounded;
|
IconData get emptyIcon => Symbols.collections_rounded;
|
||||||
|
|
||||||
@@ -43,21 +59,129 @@ class _LibraryCollectionsTabState extends LibraryGridTabState<MediaItem, Library
|
|||||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
||||||
|
|
||||||
@override
|
@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();
|
final client = getMediaClientForLibrary();
|
||||||
return client.fetchCollections(widget.library.id);
|
return client.fetchCollectionsPage(widget.library.id, start: start, size: size, abort: abort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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(
|
return FocusableMediaCard(
|
||||||
key: Key(item.id),
|
key: Key(item.id),
|
||||||
item: item,
|
item: item,
|
||||||
focusNode: index == 0 ? firstItemFocusNode : null,
|
focusNode: index == 0 ? firstItemFocusNode : null,
|
||||||
disableScale: gridContext?.isListMode ?? false,
|
disableScale: disableScale,
|
||||||
onListRefresh: loadItems,
|
onListRefresh: loadItems,
|
||||||
onBack: widget.onBack,
|
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:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import '../../../media/library_query.dart';
|
||||||
import '../../../media/media_playlist.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/library_refresh_notifier.dart';
|
||||||
|
import '../../../utils/media_server_http_client.dart';
|
||||||
import '../../../widgets/focusable_media_card.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 '../../../i18n/strings.g.dart';
|
||||||
import '../adaptive_media_grid.dart';
|
import '../../main_screen.dart';
|
||||||
import 'base_library_tab.dart';
|
import 'base_library_tab.dart';
|
||||||
import 'library_grid_tab_state.dart';
|
|
||||||
|
|
||||||
/// Playlists tab for library screen
|
/// Playlists tab for library screen
|
||||||
/// Shows playlists that contain items from the current library
|
/// Shows playlists that contain items from the current library
|
||||||
@@ -26,10 +36,16 @@ class LibraryPlaylistsTab extends BaseLibraryTab<MediaPlaylist> {
|
|||||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
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
|
@override
|
||||||
String get focusNodeDebugLabel => 'playlists_first_item';
|
String get focusNodeDebugLabel => 'playlists_first_item';
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get itemCount => totalSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get emptyIcon => Symbols.playlist_play_rounded;
|
IconData get emptyIcon => Symbols.playlist_play_rounded;
|
||||||
|
|
||||||
@@ -43,23 +59,131 @@ class _LibraryPlaylistsTabState extends LibraryGridTabState<MediaPlaylist, Libra
|
|||||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
|
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
|
||||||
|
|
||||||
@override
|
@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) —
|
// Both backends return playlists scoped to the server (not the library) —
|
||||||
// neither Plex nor Jellyfin's API filters playlists by section.
|
// neither Plex nor Jellyfin's API filters playlists by section.
|
||||||
final client = getMediaClientForLibrary();
|
final client = getMediaClientForLibrary();
|
||||||
return client.fetchPlaylists(playlistType: 'video');
|
return client.fetchPlaylistsPage(playlistType: 'video', start: start, size: size, abort: abort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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(
|
return FocusableMediaCard(
|
||||||
key: Key(playlist.id),
|
key: Key(playlist.id),
|
||||||
item: playlist,
|
item: playlist,
|
||||||
focusNode: index == 0 ? firstItemFocusNode : null,
|
focusNode: index == 0 ? firstItemFocusNode : null,
|
||||||
disableScale: gridContext?.isListMode ?? false,
|
disableScale: disableScale,
|
||||||
onListRefresh: loadItems,
|
onListRefresh: loadItems,
|
||||||
onBack: widget.onBack,
|
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;
|
Completer<void>? _seasonsCompleter;
|
||||||
List<MediaItem> _episodes = [];
|
List<MediaItem> _episodes = [];
|
||||||
bool _isLoadingEpisodes = false;
|
bool _isLoadingEpisodes = false;
|
||||||
|
bool _isLoadingAllEpisodes = false;
|
||||||
|
int _episodesLoadGeneration = 0;
|
||||||
bool _showEpisodesDirectly = false;
|
bool _showEpisodesDirectly = false;
|
||||||
MediaItem? _fullMetadata;
|
MediaItem? _fullMetadata;
|
||||||
MediaItem? _onDeckEpisode;
|
MediaItem? _onDeckEpisode;
|
||||||
@@ -149,6 +151,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
final ScrollController _seasonTabsScrollController = ScrollController();
|
final ScrollController _seasonTabsScrollController = ScrollController();
|
||||||
final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode');
|
final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode');
|
||||||
final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode');
|
final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode');
|
||||||
|
static const int _episodesPageSize = 200;
|
||||||
|
|
||||||
late final FocusNode _playButtonFocusNode;
|
late final FocusNode _playButtonFocusNode;
|
||||||
late final FocusNode _ratingChipFocusNode;
|
late final FocusNode _ratingChipFocusNode;
|
||||||
@@ -2246,8 +2249,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
itemCount: _episodes.length,
|
itemCount: _episodes.length + (_isLoadingAllEpisodes ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == _episodes.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
final episode = _episodes[index];
|
final episode = _episodes[index];
|
||||||
String? localPosterPath;
|
String? localPosterPath;
|
||||||
if (widget.isOffline && episode.serverId != null) {
|
if (widget.isOffline && episode.serverId != null) {
|
||||||
@@ -2326,9 +2335,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchAllEpisodes() async {
|
Future<void> _fetchAllEpisodes() async {
|
||||||
|
final generation = ++_episodesLoadGeneration;
|
||||||
if (_seasons.isEmpty) {
|
if (_seasons.isEmpty) {
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_isLoadingEpisodes = false;
|
_isLoadingEpisodes = false;
|
||||||
|
_isLoadingAllEpisodes = false;
|
||||||
_hasLoadedEpisodes = true;
|
_hasLoadedEpisodes = true;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -2337,6 +2348,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
if (serverId == null) {
|
if (serverId == null) {
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_isLoadingEpisodes = false;
|
_isLoadingEpisodes = false;
|
||||||
|
_isLoadingAllEpisodes = false;
|
||||||
_hasLoadedEpisodes = true;
|
_hasLoadedEpisodes = true;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -2345,54 +2357,95 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
if (client == null) {
|
if (client == null) {
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_isLoadingEpisodes = false;
|
_isLoadingEpisodes = false;
|
||||||
|
_isLoadingAllEpisodes = false;
|
||||||
_hasLoadedEpisodes = true;
|
_hasLoadedEpisodes = true;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_isLoadingEpisodes = true;
|
_isLoadingEpisodes = true;
|
||||||
|
_isLoadingAllEpisodes = false;
|
||||||
_hasLoadedEpisodes = false;
|
_hasLoadedEpisodes = false;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// One-shot recursive expansion — Plex `/grandchildren`, Jellyfin
|
final firstPage = await client.fetchPlayableDescendantsPage(_metadata.id, start: 0, size: _episodesPageSize);
|
||||||
// Recursive=true. Replaces the previous per-season fan-out so a
|
if (!mounted || generation != _episodesLoadGeneration) return;
|
||||||
// many-season show flatten doesn't fan out N parallel HTTP calls.
|
final enriched = _enrichPlayableEpisodes(firstPage.items, 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 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();
|
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_episodes = enriched;
|
_episodes = enriched;
|
||||||
_isLoadingEpisodes = false;
|
_isLoadingEpisodes = false;
|
||||||
|
_isLoadingAllEpisodes = firstPage.items.length < firstPage.totalCount;
|
||||||
_hasLoadedEpisodes = true;
|
_hasLoadedEpisodes = true;
|
||||||
});
|
});
|
||||||
|
if (firstPage.items.length < firstPage.totalCount) {
|
||||||
|
unawaited(_fetchRemainingEpisodes(client, serverId, generation, firstPage.items.length, firstPage.totalCount));
|
||||||
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
|
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
_isLoadingEpisodes = false;
|
_isLoadingEpisodes = false;
|
||||||
|
_isLoadingAllEpisodes = false;
|
||||||
_hasLoadedEpisodes = true;
|
_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)
|
/// Load the next unwatched episode for offline mode (offline OnDeck)
|
||||||
Future<void> _loadOfflineOnDeckEpisode() async {
|
Future<void> _loadOfflineOnDeckEpisode() async {
|
||||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -40,6 +42,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
StandardItemLoader<PlaylistDetailScreen>,
|
StandardItemLoader<PlaylistDetailScreen>,
|
||||||
GridFocusNodeMixin<PlaylistDetailScreen>,
|
GridFocusNodeMixin<PlaylistDetailScreen>,
|
||||||
FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
||||||
|
static const int _pageSize = 100;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Object get mediaItem => widget.playlist;
|
Object get mediaItem => widget.playlist;
|
||||||
|
|
||||||
@@ -141,6 +145,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
int? _movingIndex;
|
int? _movingIndex;
|
||||||
int? _originalIndex;
|
int? _originalIndex;
|
||||||
List<MediaItem>? _originalOrder;
|
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)
|
// Estimated item height for scroll-into-view (card + vertical margins)
|
||||||
static const double _estimatedItemHeight = 114.0;
|
static const double _estimatedItemHeight = 114.0;
|
||||||
@@ -159,9 +171,106 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> loadItems() async {
|
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) {
|
if (mounted && items.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -205,10 +314,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final allItems = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||||
|
if (!mounted) return;
|
||||||
final result = await showPlaylistDownloadOptionsAndQueue(
|
final result = await showPlaylistDownloadOptionsAndQueue(
|
||||||
context,
|
context,
|
||||||
playlistMetadata: _playlistAsMetadata(),
|
playlistMetadata: _playlistAsMetadata(),
|
||||||
items: items,
|
items: allItems,
|
||||||
client: mediaClient,
|
client: mediaClient,
|
||||||
downloadProvider: downloadProvider,
|
downloadProvider: downloadProvider,
|
||||||
);
|
);
|
||||||
@@ -260,6 +371,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||||
|
if (!_canEditPlaylist) return;
|
||||||
|
|
||||||
// Adjust newIndex if moving down in the list
|
// Adjust newIndex if moving down in the list
|
||||||
if (newIndex > oldIndex) {
|
if (newIndex > oldIndex) {
|
||||||
newIndex--;
|
newIndex--;
|
||||||
@@ -343,6 +456,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _removeItem(int index) async {
|
Future<void> _removeItem(int index) async {
|
||||||
|
if (!_canEditPlaylist) return;
|
||||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
|
|
||||||
@@ -499,7 +613,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
}
|
}
|
||||||
if (key.isLeftKey) {
|
if (key.isLeftKey) {
|
||||||
// Navigate left within columns
|
// Navigate left within columns
|
||||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||||
// Go to drag handle (column 1)
|
// Go to drag handle (column 1)
|
||||||
setState(() => _focusedColumn = 1);
|
setState(() => _focusedColumn = 1);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
@@ -511,7 +625,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
}
|
}
|
||||||
if (key.isRightKey) {
|
if (key.isRightKey) {
|
||||||
// Navigate right within columns
|
// Navigate right within columns
|
||||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||||
// Go to remove button (column 2)
|
// Go to remove button (column 2)
|
||||||
setState(() => _focusedColumn = 2);
|
setState(() => _focusedColumn = 2);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
@@ -525,14 +639,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
if (_focusedColumn == 0) {
|
if (_focusedColumn == 0) {
|
||||||
// Play from this item
|
// Play from this item
|
||||||
_playFromItem(_focusedIndex);
|
_playFromItem(_focusedIndex);
|
||||||
} else if (_focusedColumn == 1 && !_isReadOnly) {
|
} else if (_focusedColumn == 1 && _canEditPlaylist) {
|
||||||
// Enter move mode
|
// Enter move mode
|
||||||
setState(() {
|
setState(() {
|
||||||
_movingIndex = _focusedIndex;
|
_movingIndex = _focusedIndex;
|
||||||
_originalIndex = _focusedIndex;
|
_originalIndex = _focusedIndex;
|
||||||
_originalOrder = List.from(items);
|
_originalOrder = List.from(items);
|
||||||
});
|
});
|
||||||
} else if (_focusedColumn == 2) {
|
} else if (_focusedColumn == 2 && _canEditPlaylist) {
|
||||||
// Remove item
|
// Remove item
|
||||||
_removeItem(_focusedIndex);
|
_removeItem(_focusedIndex);
|
||||||
}
|
}
|
||||||
@@ -619,7 +733,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
actions: buildFocusableAppBarActions(),
|
actions: buildFocusableAppBarActions(),
|
||||||
),
|
),
|
||||||
...buildStateSlivers(),
|
...buildStateSlivers(),
|
||||||
if (items.isNotEmpty)
|
if (items.isNotEmpty) ...[
|
||||||
if (_isReadOnly)
|
if (_isReadOnly)
|
||||||
// Smart playlists / Jellyfin playlists: focusable grid view
|
// Smart playlists / Jellyfin playlists: focusable grid view
|
||||||
// (read-only, no reordering or removal)
|
// (read-only, no reordering or removal)
|
||||||
@@ -627,6 +741,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
else
|
else
|
||||||
// Plex regular playlists: sliver reorderable list
|
// Plex regular playlists: sliver reorderable list
|
||||||
_buildReorderableList(isKeyboardMode),
|
_buildReorderableList(isKeyboardMode),
|
||||||
|
if (_isLoadingFullPlaylist || _playlistContinuationErrorMessage != null)
|
||||||
|
_buildPlaylistContinuationStatusSliver(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -688,7 +805,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
onRemove: () => _removeItem(index),
|
onRemove: () => _removeItem(index),
|
||||||
onTap: () => _playFromItem(index),
|
onTap: () => _playFromItem(index),
|
||||||
onRefresh: updateItem,
|
onRefresh: updateItem,
|
||||||
canReorder: !_isReadOnly,
|
canReorder: _canEditPlaylist,
|
||||||
isFocused: isFocused,
|
isFocused: isFocused,
|
||||||
focusedColumn: isFocused ? _focusedColumn : null,
|
focusedColumn: isFocused ? _focusedColumn : null,
|
||||||
isMoving: isMoving,
|
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.
|
/// to re-broadcast status so admin-gated UI rebuilds.
|
||||||
FutureOr<void> Function(JellyfinConnection connection)? onConnectionUpdated;
|
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.
|
/// Read-only view of the headers attached to every outgoing request.
|
||||||
/// Test-only entry point for asserting the SDK-style `MediaBrowser`
|
/// Test-only entry point for asserting the SDK-style `MediaBrowser`
|
||||||
/// Authorization shape — Findroid (and the official SDK) sends the same
|
/// 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.
|
/// bounded while still returning the full series queue.
|
||||||
const _episodeQueuePageSize = 200;
|
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
|
/// `/Items/Filters` is a legacy unpaged endpoint; keep failures isolated from
|
||||||
/// the paged Browse tab so very large libraries can still open.
|
/// the paged Browse tab so very large libraries can still open.
|
||||||
const _filtersTimeout = Duration(seconds: 8);
|
const _filtersTimeout = Duration(seconds: 8);
|
||||||
@@ -447,23 +457,40 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
// Not a series — fall through to the generic ParentId query.
|
// Not a series — fall through to the generic ParentId query.
|
||||||
}
|
}
|
||||||
// Generic direct-children query: works for season → episodes,
|
// Generic direct-children query: works for season → episodes,
|
||||||
// collection → items, etc.
|
// collection → items, etc. Page it so large seasons/folders don't truncate
|
||||||
final response = await _http.get(
|
// at Jellyfin's per-request limit.
|
||||||
'/Items',
|
final allRaw = <Map<String, dynamic>>[];
|
||||||
queryParameters: {
|
var startIndex = 0;
|
||||||
'userId': connection.userId,
|
int? totalRecordCount;
|
||||||
'ParentId': parentId,
|
while (totalRecordCount == null || startIndex < totalRecordCount) {
|
||||||
'Fields': _browseFields,
|
final response = await _http.get(
|
||||||
'Limit': '500',
|
'/Items',
|
||||||
...jellyfinImageQueryParameters,
|
queryParameters: {
|
||||||
},
|
'userId': connection.userId,
|
||||||
);
|
'ParentId': parentId,
|
||||||
throwIfHttpError(response);
|
'Fields': _browseFields,
|
||||||
final data = response.data;
|
'StartIndex': '$startIndex',
|
||||||
if (data is Map<String, dynamic>) {
|
'Limit': '$_childrenPageSize',
|
||||||
await cache.put(cacheServerId, childrenKey, data);
|
...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),
|
/// All directly-playable descendants of [parentId] (Movies + Episodes),
|
||||||
@@ -473,10 +500,29 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
|
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
|
||||||
/// since those preserve the container shape (Series rows, PlaylistItemId).
|
/// 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
|
@override
|
||||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
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(
|
final response = await _http.get(
|
||||||
'/Items',
|
'/Items',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
@@ -484,12 +530,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
'ParentId': parentId,
|
'ParentId': parentId,
|
||||||
'Recursive': 'true',
|
'Recursive': 'true',
|
||||||
'IncludeItemTypes': 'Movie,Episode',
|
'IncludeItemTypes': 'Movie,Episode',
|
||||||
|
'StartIndex': offset.toString(),
|
||||||
|
'Limit': pageSize.toString(),
|
||||||
'Fields': _browseFields,
|
'Fields': _browseFields,
|
||||||
...jellyfinImageQueryParameters,
|
...jellyfinImageQueryParameters,
|
||||||
},
|
},
|
||||||
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
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.
|
/// All episodes of a series in air order, optimised for queue-building.
|
||||||
@@ -551,6 +600,27 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPersonMedia(String personId) async {
|
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(
|
final response = await _http.get(
|
||||||
'/Items',
|
'/Items',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
@@ -558,15 +628,18 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
'PersonIds': personId,
|
'PersonIds': personId,
|
||||||
'IncludeItemTypes': 'Movie,Series',
|
'IncludeItemTypes': 'Movie,Series',
|
||||||
'Recursive': 'true',
|
'Recursive': 'true',
|
||||||
|
'StartIndex': offset.toString(),
|
||||||
|
'Limit': pageSize.toString(),
|
||||||
'Fields': _browseFields,
|
'Fields': _browseFields,
|
||||||
'SortBy': 'PremiereDate,ProductionYear,SortName',
|
'SortBy': 'PremiereDate,ProductionYear,SortName',
|
||||||
'SortOrder': 'Descending,Descending,Ascending',
|
'SortOrder': 'Descending,Descending,Ascending',
|
||||||
'CollapseBoxSetItems': 'false',
|
'CollapseBoxSetItems': 'false',
|
||||||
...jellyfinImageQueryParameters,
|
...jellyfinImageQueryParameters,
|
||||||
},
|
},
|
||||||
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _mapItems(_itemsArray(response.data));
|
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -794,7 +867,25 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
/// `*.nextup` → NextUp. Unknown ids return an empty list.
|
/// `*.nextup` → NextUp. Unknown ids return an empty list.
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
|
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;
|
String? parentId;
|
||||||
if (hubId.startsWith('library.')) {
|
if (hubId.startsWith('library.')) {
|
||||||
final rest = hubId.substring('library.'.length);
|
final rest = hubId.substring('library.'.length);
|
||||||
@@ -802,42 +893,100 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
if (dot > 0) parentId = rest.substring(0, dot);
|
if (dot > 0) parentId = rest.substring(0, dot);
|
||||||
}
|
}
|
||||||
final tail = hubId.split('.').last;
|
final tail = hubId.split('.').last;
|
||||||
final List<Map<String, dynamic>> items;
|
|
||||||
switch (tail) {
|
switch (tail) {
|
||||||
case 'recent':
|
case 'recent':
|
||||||
items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
|
// Jellyfin's Latest endpoint has a Limit but no StartIndex. Expose it
|
||||||
'Limit': effectiveLimit,
|
// as one bounded page so callers don't infer endless fake pages.
|
||||||
'Fields': _browseFields,
|
if (offset > 0) return LibraryPage<MediaItem>(items: const [], totalCount: offset, offset: offset);
|
||||||
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
|
return _safeFetchMediaPage(
|
||||||
...jellyfinImageQueryParameters,
|
'/Users/${_segment(connection.userId)}/Items/Latest',
|
||||||
});
|
{
|
||||||
break;
|
'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':
|
case 'continue':
|
||||||
items = await _safeFetchItemsArray('/UserItems/Resume', {
|
return _safeFetchMediaPage(
|
||||||
'userId': connection.userId,
|
'/UserItems/Resume',
|
||||||
'Limit': effectiveLimit,
|
{
|
||||||
'Fields': _browseFields,
|
'userId': connection.userId,
|
||||||
'Recursive': 'true',
|
'StartIndex': offset.toString(),
|
||||||
'EnableTotalRecordCount': 'false',
|
'Limit': effectiveLimit,
|
||||||
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
|
'Fields': _browseFields,
|
||||||
...jellyfinImageQueryParameters,
|
'Recursive': 'true',
|
||||||
});
|
'EnableTotalRecordCount': 'true',
|
||||||
break;
|
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
|
||||||
|
...jellyfinImageQueryParameters,
|
||||||
|
},
|
||||||
|
offset: offset,
|
||||||
|
requestedSize: pageSize,
|
||||||
|
abort: abort,
|
||||||
|
);
|
||||||
case 'nextup':
|
case 'nextup':
|
||||||
items = await _safeFetchItemsArray('/Shows/NextUp', {
|
return _safeFetchMediaPage(
|
||||||
'userId': connection.userId,
|
'/Shows/NextUp',
|
||||||
'Limit': effectiveLimit,
|
{
|
||||||
'Fields': _browseFields,
|
'userId': connection.userId,
|
||||||
'ParentId': ?parentId,
|
'StartIndex': offset.toString(),
|
||||||
'EnableResumable': 'false',
|
'Limit': effectiveLimit,
|
||||||
'EnableTotalRecordCount': 'false',
|
'Fields': _browseFields,
|
||||||
...jellyfinImageQueryParameters,
|
'ParentId': ?parentId,
|
||||||
});
|
'EnableResumable': 'false',
|
||||||
break;
|
'EnableTotalRecordCount': 'true',
|
||||||
|
...jellyfinImageQueryParameters,
|
||||||
|
},
|
||||||
|
offset: offset,
|
||||||
|
requestedSize: pageSize,
|
||||||
|
abort: abort,
|
||||||
|
);
|
||||||
default:
|
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
|
@override
|
||||||
|
|||||||
@@ -3,47 +3,84 @@ part of '../../jellyfin_client.dart';
|
|||||||
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||||
JellyfinConnection get connection;
|
JellyfinConnection get connection;
|
||||||
MediaServerHttpClient get _http;
|
MediaServerHttpClient get _http;
|
||||||
Map<String, List<MediaItem>> get _collectionItemsCache;
|
|
||||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||||
|
|
||||||
|
static const int _collectionsPageSize = 200;
|
||||||
|
|
||||||
|
String? _boxSetsViewId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
||||||
// Jellyfin keeps BoxSets under a dedicated top-level view, not under each
|
final all = <MediaItem>[];
|
||||||
// movie/show library. Query that root to avoid recursively scanning media.
|
var start = 0;
|
||||||
final boxSetsViewId = await _fetchBoxSetsViewId();
|
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(
|
final response = await _http.get(
|
||||||
'/Items',
|
'/Items',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'userId': connection.userId,
|
'userId': connection.userId,
|
||||||
'ParentId': ?boxSetsViewId,
|
'ParentId': boxSetsViewId,
|
||||||
'IncludeItemTypes': 'BoxSet',
|
'IncludeItemTypes': 'BoxSet',
|
||||||
'Recursive': 'true',
|
'Recursive': 'true',
|
||||||
|
'StartIndex': s.toString(),
|
||||||
|
'Limit': pageSize.toString(),
|
||||||
'SortBy': 'SortName',
|
'SortBy': 'SortName',
|
||||||
'SortOrder': 'Ascending',
|
'SortOrder': 'Ascending',
|
||||||
'Fields': _browseFields,
|
'Fields': _browseFields,
|
||||||
...jellyfinImageQueryParameters,
|
...jellyfinImageQueryParameters,
|
||||||
},
|
},
|
||||||
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _mapItems(_itemsArray(response.data));
|
return _itemsPage(response.data, offset: s, requestedSize: pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _fetchBoxSetsViewId() async {
|
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
|
||||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
|
if (_boxSetsViewId != null) return _boxSetsViewId;
|
||||||
|
|
||||||
|
final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
for (final view in _itemsArray(response.data)) {
|
for (final view in _itemsArray(response.data)) {
|
||||||
final collectionType = (view['CollectionType'] as String?)?.toLowerCase();
|
final collectionType = (view['CollectionType'] as String?)?.toLowerCase();
|
||||||
final id = view['Id'] as String?;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jellyfin has no pagination knob for collection children, so the first
|
LibraryPage<MediaItem> _itemsPage(Object? data, {required int offset, int? requestedSize}) {
|
||||||
/// call materialises the full list via [fetchChildren] and subsequent
|
final rawItems = _itemsArray(data);
|
||||||
/// paged calls slice from the same in-memory copy ([_collectionItemsCache]).
|
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||||
/// The [abort] hook is unused on this backend — the slice path is
|
final fallbackTotal = _fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||||
/// synchronous and the underlying fetch is short-lived.
|
final total = rawTotal is int ? rawTotal : fallbackTotal;
|
||||||
|
return LibraryPage<MediaItem>(items: _mapItems(rawItems), totalCount: total, offset: offset);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||||
String collectionId, {
|
String collectionId, {
|
||||||
@@ -53,18 +90,21 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
|||||||
String? libraryId,
|
String? libraryId,
|
||||||
String? libraryTitle,
|
String? libraryTitle,
|
||||||
}) async {
|
}) async {
|
||||||
final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId);
|
|
||||||
final s = start ?? 0;
|
final s = start ?? 0;
|
||||||
final fullSize = cached.length;
|
final response = await _http.get(
|
||||||
final from = s.clamp(0, fullSize);
|
'/Items',
|
||||||
final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize);
|
queryParameters: {
|
||||||
return LibraryPage<MediaItem>(items: cached.sublist(from, to), totalCount: fullSize, offset: s);
|
'userId': connection.userId,
|
||||||
}
|
'ParentId': collectionId,
|
||||||
|
'StartIndex': s.toString(),
|
||||||
Future<List<MediaItem>> _loadAndCacheCollectionItems(String collectionId) async {
|
if (size != null) 'Limit': size.toString(),
|
||||||
final items = await fetchChildren(collectionId);
|
'Fields': _browseFields,
|
||||||
_collectionItemsCache[collectionId] = items;
|
...jellyfinImageQueryParameters,
|
||||||
return items;
|
},
|
||||||
|
abort: abort,
|
||||||
|
);
|
||||||
|
throwIfHttpError(response);
|
||||||
|
return _itemsPage(response.data, offset: s, requestedSize: size);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,25 +6,85 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
|||||||
String? _absolutizeImagePath(String? path);
|
String? _absolutizeImagePath(String? path);
|
||||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||||
|
|
||||||
|
static const int _playlistsPageSize = 200;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
|
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
|
||||||
final response = await _http.get(
|
final all = <MediaPlaylist>[];
|
||||||
'/Items',
|
var start = 0;
|
||||||
queryParameters: {
|
while (true) {
|
||||||
'userId': connection.userId,
|
final page = await fetchPlaylistsPage(
|
||||||
'IncludeItemTypes': 'Playlist',
|
playlistType: playlistType,
|
||||||
'Recursive': 'true',
|
smart: smart,
|
||||||
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
|
start: start,
|
||||||
...jellyfinImageQueryParameters,
|
size: _playlistsPageSize,
|
||||||
},
|
);
|
||||||
);
|
if (page.items.isEmpty) break;
|
||||||
throwIfHttpError(response);
|
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();
|
final requestedType = playlistType.toLowerCase();
|
||||||
return _itemsArray(response.data).map(_playlistFromJson).where((playlist) {
|
final items = <MediaPlaylist>[];
|
||||||
if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false;
|
var rawOffset = 0;
|
||||||
if (smart != null && playlist.smart != smart) return false;
|
var filteredSeen = 0;
|
||||||
return true;
|
int? rawTotal;
|
||||||
}).toList();
|
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
|
@override
|
||||||
@@ -50,18 +110,36 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
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(
|
final response = await _http.get(
|
||||||
'/Playlists/${_segment(id)}/Items',
|
'/Playlists/${_segment(id)}/Items',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'userId': connection.userId,
|
'userId': connection.userId,
|
||||||
'StartIndex': offset.toString(),
|
'StartIndex': offset.toString(),
|
||||||
'Limit': limit.toString(),
|
'Limit': pageSize.toString(),
|
||||||
'Fields': _browseFields,
|
'Fields': _browseFields,
|
||||||
...jellyfinImageQueryParameters,
|
...jellyfinImageQueryParameters,
|
||||||
},
|
},
|
||||||
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
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
|
@override
|
||||||
@@ -171,6 +249,12 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
|||||||
return 'video';
|
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) {
|
int? _epochSecondsFromJson(String? iso) {
|
||||||
if (iso == null || iso.isEmpty) return null;
|
if (iso == null || iso.isEmpty) return null;
|
||||||
final dt = DateTime.tryParse(iso);
|
final dt = DateTime.tryParse(iso);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ Future<List<MediaItem>> fetchAllPlaylistItems(MediaServerClient client, String p
|
|||||||
final all = <MediaItem>[];
|
final all = <MediaItem>[];
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
final page = await client.fetchPlaylistItems(playlistId, offset: offset, limit: pageSize);
|
final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize);
|
||||||
if (page.isEmpty) break;
|
if (page.items.isEmpty) break;
|
||||||
all.addAll(page);
|
all.addAll(page.items);
|
||||||
if (page.length < pageSize) break;
|
if (all.length >= page.totalCount) break;
|
||||||
offset += page.length;
|
offset += page.items.length;
|
||||||
}
|
}
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|||||||
+272
-75
@@ -764,6 +764,47 @@ class PlexClient
|
|||||||
return [];
|
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 {
|
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||||
final response = await _getWithFailover('/identity');
|
final response = await _getWithFailover('/identity');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -839,7 +880,12 @@ class PlexClient
|
|||||||
if (filters != null) queryParams.addAll(filters);
|
if (filters != null) queryParams.addAll(filters);
|
||||||
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
|
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
|
||||||
final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
|
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) {
|
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
|
||||||
@@ -853,14 +899,15 @@ class PlexClient
|
|||||||
MediaServerResponse response, {
|
MediaServerResponse response, {
|
||||||
int? librarySectionID,
|
int? librarySectionID,
|
||||||
String? librarySectionTitle,
|
String? librarySectionTitle,
|
||||||
|
int? start,
|
||||||
|
int? requestedSize,
|
||||||
}) {
|
}) {
|
||||||
final items = _extractMetadataListWithLibrary(
|
final items = _extractMetadataListWithLibrary(
|
||||||
response,
|
response,
|
||||||
librarySectionID: librarySectionID,
|
librarySectionID: librarySectionID,
|
||||||
librarySectionTitle: librarySectionTitle,
|
librarySectionTitle: librarySectionTitle,
|
||||||
);
|
);
|
||||||
final container = _getMediaContainer(response);
|
final totalSize = _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: requestedSize);
|
||||||
final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
|
|
||||||
return _LibraryContentResult(items: items, totalSize: totalSize);
|
return _LibraryContentResult(items: items, totalSize: totalSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +924,8 @@ class PlexClient
|
|||||||
response,
|
response,
|
||||||
librarySectionID: librarySectionID,
|
librarySectionID: librarySectionID,
|
||||||
librarySectionTitle: librarySectionTitle,
|
librarySectionTitle: librarySectionTitle,
|
||||||
|
start: start,
|
||||||
|
requestedSize: size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,9 +1131,7 @@ class PlexClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page size for iterating all items via [_fetchAllPages]. Also the cap
|
/// Default cap for list-style endpoints when a caller doesn't pass a size.
|
||||||
/// for endpoints that send `X-Plex-Container-Size` but aren't truly paginated
|
|
||||||
/// (collections listing, playlists listing, search).
|
|
||||||
static const int _defaultListContainerSize = 1000;
|
static const int _defaultListContainerSize = 1000;
|
||||||
|
|
||||||
/// Page size used when walking all pages of a paginated endpoint.
|
/// 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
|
/// Page through playable episodes beneath a show or season. Uses
|
||||||
/// this returns episodes across every season (no per-season walk), for a
|
/// `/grandchildren` rather than `/allLeaves` because the live server returns
|
||||||
/// season the episodes directly. Mirrors [_getChildren]'s cache-fallback
|
/// 0 items for `/allLeaves` on a season.
|
||||||
/// behaviour.
|
Future<_LibraryContentResult> _getGrandchildrenPage(
|
||||||
///
|
String ratingKey, {
|
||||||
/// Uses `/grandchildren` rather than `/allLeaves` because the live server
|
int? start,
|
||||||
/// returns 0 items for `/allLeaves` on a season — `/grandchildren` is the
|
int? size,
|
||||||
/// only endpoint Plex serves that one-shots both levels (and is also the
|
AbortController? abort,
|
||||||
/// recommended path for mini-series shows that set `skipChildren=true`,
|
}) => _fetchPaginatedList('/library/metadata/$ratingKey/grandchildren', start: start, size: size, abort: abort);
|
||||||
/// 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),
|
|
||||||
) ??
|
|
||||||
[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
|
/// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
|
||||||
/// Uses cache when offline or as fallback on network error
|
/// 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
|
/// Get full content from a hub using its hub key
|
||||||
/// Returns the complete list of metadata items in the hub
|
/// Returns the complete list of metadata items in the hub
|
||||||
Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
|
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);
|
final hubSectionID = _librarySectionIdFromString(hubKey);
|
||||||
return _wrapListApiCall<PlexMetadataDto>(() => _http.get(hubKey), (response) {
|
final pageItems = <PlexMetadataDto>[];
|
||||||
final allItems = _extractMetadataListWithLibrary(response, librarySectionID: hubSectionID);
|
var rawOffset = 0;
|
||||||
// Filter to only video content (movies, shows, seasons, episodes)
|
var filteredSeen = 0;
|
||||||
return allItems.where((item) {
|
var rawTotal = 0;
|
||||||
return ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
var rawFinished = false;
|
||||||
}).toList();
|
|
||||||
}, 'Failed to get hub content');
|
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.
|
/// Get playlist content by playlist ID, paginated.
|
||||||
@@ -1829,23 +1912,50 @@ class PlexClient
|
|||||||
Future<List<PlexMetadataDto>> _fetchAllPlaylistItemsDto(String playlistId) =>
|
Future<List<PlexMetadataDto>> _fetchAllPlaylistItemsDto(String playlistId) =>
|
||||||
_fetchAllPages((start, size, abort) => _getPlaylist(playlistId, start: start, size: size, abort: abort));
|
_fetchAllPages((start, size, abort) => _getPlaylist(playlistId, start: start, size: size, abort: abort));
|
||||||
|
|
||||||
/// Get all playlists
|
/// Get all playlists.
|
||||||
/// Filters by playlistType=video by default
|
/// Filters by playlistType=video by default.
|
||||||
/// Set smart to true/false to filter smart playlists, or null for all
|
/// Set smart to true/false to filter smart playlists, or null for all.
|
||||||
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) {
|
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>{
|
final queryParams = <String, dynamic>{
|
||||||
'playlistType': playlistType,
|
if (playlistType.isNotEmpty) 'playlistType': playlistType,
|
||||||
'X-Plex-Container-Size': _defaultListContainerSize,
|
..._buildPaginationParams(start, pageSize),
|
||||||
};
|
};
|
||||||
if (smart != null) {
|
if (smart != null) {
|
||||||
queryParams['smart'] = smart ? '1' : '0';
|
queryParams['smart'] = smart ? '1' : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
return _wrapListApiCall<PlexPlaylistDto>(
|
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
|
||||||
() => _http.get('/playlists', queryParameters: queryParams),
|
return _extractPlaylistListResult(response, start: start, size: pageSize);
|
||||||
_extractPlaylistList,
|
|
||||||
'Failed to get playlists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get playlist metadata by playlist ID
|
/// Get playlist metadata by playlist ID
|
||||||
@@ -2143,26 +2253,38 @@ class PlexClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all collections for a library section
|
/// Get one page of collections for a library section.
|
||||||
/// Returns collections as PlexMetadataDto objects with type="collection"
|
Future<_LibraryContentResult> _getLibraryCollectionsPage(
|
||||||
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
String sectionId, {
|
||||||
return _wrapListApiCall<PlexMetadataDto>(
|
int? start,
|
||||||
() => _http.get(
|
int? size,
|
||||||
'/library/sections/$sectionId/collections',
|
AbortController? abort,
|
||||||
queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize},
|
}) async {
|
||||||
),
|
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
|
||||||
(response) {
|
final response = await _getWithFailover(
|
||||||
final allItems = _extractMetadataListWithLibrary(
|
'/library/sections/$sectionId/collections',
|
||||||
response,
|
queryParameters: queryParameters,
|
||||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
abort: abort,
|
||||||
);
|
|
||||||
// Collections should have type="collection"
|
|
||||||
return allItems.where((item) {
|
|
||||||
return item.type?.toLowerCase() == ContentTypes.collection;
|
|
||||||
}).toList();
|
|
||||||
},
|
|
||||||
'Failed to get library collections',
|
|
||||||
);
|
);
|
||||||
|
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.
|
/// Get items in a collection, paginated.
|
||||||
@@ -2996,10 +3118,27 @@ class PlexClient
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
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();
|
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
|
/// Plex maintains episode queues server-side via `/playQueues`, so the
|
||||||
/// client-side window EpisodeNavigationService builds for Jellyfin isn't
|
/// client-side window EpisodeNavigationService builds for Jellyfin isn't
|
||||||
/// needed here.
|
/// needed here.
|
||||||
@@ -3366,6 +3505,28 @@ class PlexClient
|
|||||||
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
|
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
|
@override
|
||||||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
|
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
|
||||||
final p = await _getPlaylistMetadata(id);
|
final p = await _getPlaylistMetadata(id);
|
||||||
@@ -3374,21 +3535,23 @@ class PlexClient
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
||||||
final result = await _getPlaylist(id, start: offset, size: limit);
|
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
||||||
return result.items.map((m) => PlexMappers.mediaItem(m)).toList();
|
return page.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plex-specific: paginated playlist content. Returns neutral [MediaItem]s.
|
@override
|
||||||
/// The total size from the server is needed for paginated UI; tests in
|
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
|
||||||
/// `playlist_detail_screen.dart` rely on this.
|
|
||||||
Future<({List<MediaItem> items, int totalSize})> fetchPlaylistPage(
|
|
||||||
String playlistId, {
|
String playlistId, {
|
||||||
int? start,
|
int? start,
|
||||||
int? size,
|
int? size,
|
||||||
AbortController? abort,
|
AbortController? abort,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
|
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
|
@override
|
||||||
@@ -3397,6 +3560,21 @@ class PlexClient
|
|||||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
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
|
@override
|
||||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||||
String collectionId, {
|
String collectionId, {
|
||||||
@@ -3441,15 +3619,19 @@ class PlexClient
|
|||||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plex-specific: paginated person-media listing.
|
@override
|
||||||
Future<({List<MediaItem> items, int totalSize})> fetchPersonMediaPage(
|
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
||||||
String personId, {
|
String personId, {
|
||||||
int? start,
|
int? start,
|
||||||
int? size,
|
int? size,
|
||||||
AbortController? abort,
|
AbortController? abort,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
|
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
|
@override
|
||||||
@@ -3470,6 +3652,21 @@ class PlexClient
|
|||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId);
|
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.
|
/// Plex-specific: top-level folders in a library.
|
||||||
Future<List<MediaItem>> fetchLibraryFolders(String sectionId) async {
|
Future<List<MediaItem>> fetchLibraryFolders(String sectionId) async {
|
||||||
final raw = await _getLibraryFolders(sectionId);
|
final raw = await _getLibraryFolders(sectionId);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import '../profiles/profile.dart';
|
|||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/library_refresh_notifier.dart';
|
import '../utils/library_refresh_notifier.dart';
|
||||||
|
import '../utils/media_server_http_client.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
@@ -945,13 +946,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
try {
|
try {
|
||||||
final item = _mediaItem!;
|
final item = _mediaItem!;
|
||||||
|
|
||||||
final playlists = await client.fetchPlaylists(playlistType: 'video');
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final result = await showDialog<String>(
|
final result = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _PlaylistSelectionDialog(playlists: playlists),
|
builder: (context) => _PlaylistSelectionDialog(client: client),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == null || !context.mounted) return;
|
if (result == null || !context.mounted) return;
|
||||||
@@ -1050,14 +1047,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final resolvedLibraryId = libraryId;
|
||||||
final collections = await client.fetchCollections(libraryId);
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final result = await showDialog<String>(
|
final result = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _CollectionSelectionDialog(collections: collections),
|
builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == null || !context.mounted) return;
|
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}');
|
appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}');
|
||||||
final newCollectionId = await client.createCollection(
|
final newCollectionId = await client.createCollection(
|
||||||
libraryId: libraryId,
|
libraryId: resolvedLibraryId,
|
||||||
title: collectionName,
|
title: collectionName,
|
||||||
items: [item],
|
items: [item],
|
||||||
itemKind: itemKind,
|
itemKind: itemKind,
|
||||||
@@ -1520,11 +1515,78 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dialog to select a playlist or create a new one
|
/// Dialog to select a playlist or create a new one.
|
||||||
class _PlaylistSelectionDialog extends StatelessWidget {
|
class _PlaylistSelectionDialog extends StatefulWidget {
|
||||||
final List<MediaPlaylist> playlists;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -1533,8 +1595,9 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
|||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: playlists.length + 1,
|
itemCount: _playlists.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
// Create new playlist option (always shown first)
|
// Create new playlist option (always shown first)
|
||||||
@@ -1545,10 +1608,28 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlist = playlists[index - 1];
|
if (index > _playlists.length) {
|
||||||
final subtitleText = playlist.leafCount == 1
|
if (_errorMessage != null) {
|
||||||
? t.playlists.oneItem
|
return ListTile(
|
||||||
: t.playlists.itemCount(count: playlist.leafCount!);
|
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(
|
return ListTile(
|
||||||
leading: playlist.smart
|
leading: playlist.smart
|
||||||
? const AppIcon(Symbols.auto_awesome_rounded, fill: 1)
|
? 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
|
/// Dialog to select a collection or create a new one
|
||||||
class _CollectionSelectionDialog extends StatefulWidget {
|
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
|
@override
|
||||||
State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState();
|
State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
||||||
|
static const int _pageSize = 100;
|
||||||
|
|
||||||
late final _filterController = createTextEditingController();
|
late final _filterController = createTextEditingController();
|
||||||
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
|
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
|
||||||
final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem');
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_abortController.abort();
|
||||||
|
_scrollController.dispose();
|
||||||
_filterFocusNode.dispose();
|
_filterFocusNode.dispose();
|
||||||
_firstCollectionFocusNode.dispose();
|
_firstCollectionFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFilterChanged(String query) {
|
void _onScroll() {
|
||||||
final lower = query.toLowerCase();
|
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(() {
|
setState(() {
|
||||||
_filteredCollections = lower.isEmpty
|
_isLoading = true;
|
||||||
? widget.collections
|
_errorMessage = null;
|
||||||
: widget.collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList();
|
|
||||||
});
|
});
|
||||||
|
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
|
@override
|
||||||
@@ -1615,7 +1766,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (widget.collections.length >= 10) ...[
|
if (_collections.length >= 10) ...[
|
||||||
FocusableTextField(
|
FocusableTextField(
|
||||||
controller: _filterController,
|
controller: _filterController,
|
||||||
focusNode: _filterFocusNode,
|
focusNode: _filterFocusNode,
|
||||||
@@ -1632,19 +1783,39 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
|||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: _filteredCollections.length + 1,
|
itemCount: _filteredCollections.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
return FocusableListTile(
|
return FocusableListTile(
|
||||||
focusNode: _firstCollectionFocusNode,
|
focusNode: _firstCollectionFocusNode,
|
||||||
autofocus: widget.collections.length < 10,
|
autofocus: _collections.length < 10,
|
||||||
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||||
title: Text(t.common.createNew),
|
title: Text(t.common.createNew),
|
||||||
onTap: () => Navigator.pop(context, '_create_new'),
|
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];
|
final collection = _filteredCollections[index - 1];
|
||||||
return FocusableListTile(
|
return FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class _PaginatedProbe extends StatefulWidget {
|
|||||||
State<_PaginatedProbe> createState() => _PaginatedProbeState();
|
State<_PaginatedProbe> createState() => _PaginatedProbeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader {
|
class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader<MediaItem, _PaginatedProbe> {
|
||||||
int fetchCalls = 0;
|
int fetchCalls = 0;
|
||||||
final List<({int start, int size})> fetchArgs = [];
|
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
|
// Kick off the initial load — its future is `staleFetch` and won't
|
||||||
// resolve until we say so.
|
// 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.
|
// Reset state mid-flight; the next loadInitialPage should be authoritative.
|
||||||
// ignore: invalid_use_of_protected_member
|
// ignore: invalid_use_of_protected_member
|
||||||
@@ -524,15 +524,53 @@ void main() {
|
|||||||
// Resolve the *stale* future — the generation has been bumped, so this
|
// Resolve the *stale* future — the generation has been bumped, so this
|
||||||
// result must be discarded.
|
// result must be discarded.
|
||||||
staleFetch!.complete(_result(start: 0, size: 10, totalSize: 50));
|
staleFetch!.complete(_result(start: 0, size: 10, totalSize: 50));
|
||||||
await firstLoad;
|
final staleResult = await firstLoad;
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(staleResult.applied, isFalse);
|
||||||
expect(state.totalSize, 0); // stale result was dropped
|
expect(state.totalSize, 0); // stale result was dropped
|
||||||
expect(state.loadedItems, isEmpty);
|
expect(state.loadedItems, isEmpty);
|
||||||
|
|
||||||
// Now run a fresh load that resolves with totalSize=99.
|
// Now run a fresh load that resolves with totalSize=99.
|
||||||
await state.loadInitialPage(10);
|
final freshResult = await state.loadInitialPageWithStatus(10);
|
||||||
await tester.pump();
|
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);
|
expect(state.totalSize, 99);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -821,7 +821,7 @@ void main() {
|
|||||||
libraryKind: MediaKind.show,
|
libraryKind: MediaKind.show,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(captured[0].queryParameters['IncludeItemTypes'], 'Series');
|
expect(captured.first.queryParameters['IncludeItemTypes'], 'Series');
|
||||||
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
|
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1263,9 +1263,10 @@ void main() {
|
|||||||
expect(captured!.path, '/UserItems/Resume');
|
expect(captured!.path, '/UserItems/Resume');
|
||||||
expect(captured!.queryParameters['userId'], 'user-1');
|
expect(captured!.queryParameters['userId'], 'user-1');
|
||||||
expect(captured!.queryParameters['Limit'], '50');
|
expect(captured!.queryParameters['Limit'], '50');
|
||||||
|
expect(captured!.queryParameters['StartIndex'], '0');
|
||||||
expect(captured!.queryParameters['MediaTypes'], 'Video');
|
expect(captured!.queryParameters['MediaTypes'], 'Video');
|
||||||
expect(captured!.queryParameters['Recursive'], 'true');
|
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['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||||
@@ -1280,9 +1281,10 @@ void main() {
|
|||||||
expect(captured!.path, '/Shows/NextUp');
|
expect(captured!.path, '/Shows/NextUp');
|
||||||
expect(captured!.queryParameters['userId'], 'user-1');
|
expect(captured!.queryParameters['userId'], 'user-1');
|
||||||
expect(captured!.queryParameters['Limit'], '25');
|
expect(captured!.queryParameters['Limit'], '25');
|
||||||
|
expect(captured!.queryParameters['StartIndex'], '0');
|
||||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
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['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||||
@@ -1313,8 +1315,9 @@ void main() {
|
|||||||
expect(captured!.path, '/UserItems/Resume');
|
expect(captured!.path, '/UserItems/Resume');
|
||||||
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
||||||
expect(captured!.queryParameters['userId'], 'user-1');
|
expect(captured!.queryParameters['userId'], 'user-1');
|
||||||
|
expect(captured!.queryParameters['StartIndex'], '0');
|
||||||
expect(captured!.queryParameters['Recursive'], 'true');
|
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['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||||
client.close();
|
client.close();
|
||||||
@@ -1328,8 +1331,9 @@ void main() {
|
|||||||
expect(captured!.path, '/Shows/NextUp');
|
expect(captured!.path, '/Shows/NextUp');
|
||||||
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
||||||
expect(captured!.queryParameters['userId'], 'user-1');
|
expect(captured!.queryParameters['userId'], 'user-1');
|
||||||
|
expect(captured!.queryParameters['StartIndex'], '0');
|
||||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
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['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||||
@@ -1344,6 +1348,91 @@ void main() {
|
|||||||
expect(captured, isNull);
|
expect(captured, isNull);
|
||||||
client.close();
|
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', () {
|
group('JellyfinClient.fetchCollections', () {
|
||||||
@@ -1366,6 +1455,7 @@ void main() {
|
|||||||
if (req.url.path == '/Items') {
|
if (req.url.path == '/Items') {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
|
'TotalRecordCount': 1,
|
||||||
'Items': [
|
'Items': [
|
||||||
{'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
|
{'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
|
||||||
],
|
],
|
||||||
@@ -1389,12 +1479,97 @@ void main() {
|
|||||||
expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies'));
|
expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies'));
|
||||||
expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet');
|
expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet');
|
||||||
expect(itemsRequest.queryParameters['Recursive'], 'true');
|
expect(itemsRequest.queryParameters['Recursive'], 'true');
|
||||||
|
expect(itemsRequest.queryParameters['StartIndex'], '0');
|
||||||
|
expect(itemsRequest.queryParameters['Limit'], '200');
|
||||||
expect(itemsRequest.queryParameters['SortBy'], 'SortName');
|
expect(itemsRequest.queryParameters['SortBy'], 'SortName');
|
||||||
expect(itemsRequest.queryParameters['SortOrder'], 'Ascending');
|
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;
|
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 {
|
final mock = MockClient((req) async {
|
||||||
if (req.url.path == '/Users/user-1/Views') {
|
if (req.url.path == '/Users/user-1/Views') {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
@@ -1408,7 +1583,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (req.url.path == '/Items') {
|
if (req.url.path == '/Items') {
|
||||||
itemsRequest = req.url;
|
itemsRequested = true;
|
||||||
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
|
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
|
||||||
}
|
}
|
||||||
return http.Response('not found', 404);
|
return http.Response('not found', 404);
|
||||||
@@ -1416,12 +1591,43 @@ void main() {
|
|||||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||||
addTearDown(client.close);
|
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, isNotNull);
|
||||||
expect(itemsRequest!.queryParameters.containsKey('ParentId'), isFalse);
|
expect(itemsRequest!.queryParameters['ParentId'], 'collection-1');
|
||||||
expect(itemsRequest!.queryParameters['IncludeItemTypes'], 'BoxSet');
|
expect(itemsRequest!.queryParameters['StartIndex'], '20');
|
||||||
expect(itemsRequest!.queryParameters['Recursive'], 'true');
|
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', () {
|
group('JellyfinClient.fetchPlaylists filtering', () {
|
||||||
JellyfinClient buildClient() {
|
JellyfinClient buildClient() {
|
||||||
final mock = MockClient((req) async {
|
final mock = MockClient((req) async {
|
||||||
if (req.url.path == '/Items') {
|
if (req.url.path == '/Items') {
|
||||||
return http.Response(
|
final requestedMediaType = req.url.queryParameters['MediaTypes']?.toLowerCase();
|
||||||
jsonEncode({
|
final items =
|
||||||
'Items': [
|
[
|
||||||
{'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
{'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
|
||||||
{'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
|
{'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
|
||||||
{'Id': 'photo-1', 'Name': 'Photo Playlist', 'Type': 'Playlist', 'MediaType': 'Photo'},
|
{'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,
|
200,
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
);
|
);
|
||||||
@@ -1490,6 +1802,125 @@ void main() {
|
|||||||
client.close();
|
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 {
|
test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async {
|
||||||
final mock = MockClient((req) async {
|
final mock = MockClient((req) async {
|
||||||
if (req.url.path == '/jellyfin/Items') {
|
if (req.url.path == '/jellyfin/Items') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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_backend.dart';
|
||||||
import 'package:plezy/media/media_item.dart';
|
import 'package:plezy/media/media_item.dart';
|
||||||
import 'package:plezy/media/media_kind.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_client.dart';
|
||||||
import 'package:plezy/services/jellyfin_sequential_launcher.dart';
|
import 'package:plezy/services/jellyfin_sequential_launcher.dart';
|
||||||
import 'package:plezy/services/media_list_playback_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` +
|
/// Recording fake that satisfies [JellyfinClient] via `implements` +
|
||||||
/// `noSuchMethod`. The launcher only needs the
|
/// `noSuchMethod`. The launcher only needs the
|
||||||
@@ -42,10 +44,24 @@ class _RecordingJellyfinClient implements JellyfinClient {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
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));
|
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);
|
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
|
@override
|
||||||
@@ -219,7 +235,7 @@ void main() {
|
|||||||
expect(result, isA<PlayQueueSuccess>());
|
expect(result, isA<PlayQueueSuccess>());
|
||||||
expect(playback.loadedItems.length, 150);
|
expect(playback.loadedItems.length, 150);
|
||||||
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(2));
|
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(2));
|
||||||
expect(fakeClient.fetchPlaylistItemsCalls[0].offset, 0);
|
expect(fakeClient.fetchPlaylistItemsCalls.first.offset, 0);
|
||||||
expect(fakeClient.fetchPlaylistItemsCalls[1].offset, 100);
|
expect(fakeClient.fetchPlaylistItemsCalls[1].offset, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,394 @@ void main() {
|
|||||||
expect(page.items.single.libraryId, '7');
|
expect(page.items.single.libraryId, '7');
|
||||||
expect(page.items.single.libraryTitle, 'Movies');
|
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() => {
|
Map<String, dynamic> _filtersPayload() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user