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