fix(media): paginate large server lists

This commit is contained in:
edde746
2026-05-17 20:25:39 +02:00
parent e726f9f528
commit c855dba3be
23 changed files with 2527 additions and 519 deletions
+49 -5
View File
@@ -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,
+25 -11
View File
@@ -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;
+56 -9
View File
@@ -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),
),
], ],
); );
} }
+3 -3
View File
@@ -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.
+160 -9
View File
@@ -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();
}
} }
+79 -26
View File
@@ -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)),
],
),
),
),
);
}
} }
-8
View File
@@ -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
+200 -51
View File
@@ -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
+102 -18
View File
@@ -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);
+5 -5
View File
@@ -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
View File
@@ -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);
+201 -30
View File
@@ -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),
+42 -4
View File
@@ -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);
}); });
}); });
+447 -16
View File
@@ -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() => {