From 102ef46d00fa723302b753acbf1be24363a152d0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:06:00 +0200 Subject: [PATCH] feat(jellyfin): lazily page recently added hubs close #1611 --- lib/mixins/paginated_item_loader.dart | 13 ++ lib/screens/hub_detail_screen.dart | 124 +++++++++++++++--- .../jellyfin_client/parts/browse.dart | 43 ++++-- test/mixins/paginated_item_loader_test.dart | 4 + test/screens/hub_detail_screen_test.dart | 37 +++++- test/services/jellyfin_client_urls_test.dart | 52 +++++--- 6 files changed, 225 insertions(+), 48 deletions(-) diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index d1b6eada..f13f4272 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -28,6 +28,7 @@ mixin PaginatedItemLoader on State { final Set _loadingRanges = {}; AbortController? _cancelToken; + Object? _paginationError; /// Monotonic generation — bumped on reset/dispose so stale fetches are /// discarded instead of mutating state from a prior load. @@ -37,6 +38,8 @@ mixin PaginatedItemLoader on State { Timer? _retryTimer; bool _visibleRangeLoading = false; DateTime? _lastEagerPrefetch; + Object? get paginationError => _paginationError; + bool get isPaginationLoading => _loadingRanges.isNotEmpty; /// Re-invoked by the retry timer. Most recent range-load args. VoidCallback? _scheduledRetry; @@ -49,6 +52,10 @@ mixin PaginatedItemLoader on State { /// Override for image prefetch, syncing a base-class `items` list, etc. void onPageLoaded(int _, List _) {} + /// Hook fired when a lazy page starts or finishes loading, or fails. + /// Override when the surrounding UI exposes loading or retry state. + void onPaginationStateChanged() {} + /// Synchronously clear pagination state and bump the generation counter. /// Call from inside the subclass's `setState` before awaiting /// [loadInitialPage]. Aborts any in-flight fetches from the previous load. @@ -61,6 +68,7 @@ mixin PaginatedItemLoader on State { _visibleRangeLoading = false; _lastEagerPrefetch = null; _scheduledRetry = null; + _paginationError = null; loadedItems.clear(); _loadingRanges.clear(); totalSize = 0; @@ -265,6 +273,7 @@ mixin PaginatedItemLoader on State { _retryTimer?.cancel(); _retryTimer = null; _loadingRanges.clear(); + _paginationError = null; _scheduledRetry = null; } @@ -276,6 +285,8 @@ mixin PaginatedItemLoader on State { final indices = List.generate(clampedSize, (i) => start + i); if (indices.every((i) => _loadingRanges.contains(i) || loadedItems.containsKey(i))) return true; _loadingRanges.addAll(indices); + _paginationError = null; + onPaginationStateChanged(); final generation = _requestId; @@ -295,6 +306,7 @@ mixin PaginatedItemLoader on State { return true; } catch (e) { if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false; + _paginationError = e; _retryCount++; final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4))); _retryTimer?.cancel(); @@ -304,6 +316,7 @@ mixin PaginatedItemLoader on State { return false; } finally { _loadingRanges.removeAll(indices); + onPaginationStateChanged(); } } } diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 7b7c233a..2334ca08 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -16,6 +16,7 @@ import '../utils/app_logger.dart'; import '../utils/continuation_pagination_coordinator.dart'; import '../utils/error_message_utils.dart'; import '../utils/platform_detector.dart'; +import '../utils/media_server_http_client.dart'; import '../utils/plex_library_section_utils.dart'; import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; @@ -28,6 +29,7 @@ import '../focus/focusable_action_bar.dart'; import '../focus/focusable_button.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; +import '../mixins/paginated_item_loader.dart'; import 'libraries/sort_bottom_sheet.dart'; import 'libraries/content_state_builder.dart'; import '../mixins/refreshable.dart'; @@ -56,7 +58,7 @@ class HubDetailScreen extends StatefulWidget { } class _HubDetailScreenState extends State - with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin { + with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin, PaginatedItemLoader { static const int _pageSize = 200; List _items = []; @@ -67,6 +69,7 @@ class _HubDetailScreenState extends State bool _isLoading = false; String? _errorMessage; bool _replaceContinuationItems = false; + bool _usesPaginatedLoader = false; late final ContinuationPaginationCoordinator _continuation = ContinuationPaginationCoordinator( loadPage: _fetchContinuationPage, @@ -111,6 +114,7 @@ class _HubDetailScreenState extends State @override void initState() { super.initState(); + scrollController.addListener(_maybeLoadNextHubPage); _items = widget.hub.items; _filteredItems = widget.hub.items; if (widget.hub.more) { @@ -122,6 +126,8 @@ class _HubDetailScreenState extends State @override void dispose() { + scrollController.removeListener(_maybeLoadNextHubPage); + disposePagination(); _continuation.dispose(); _continuationRetryFocusNode.dispose(); disposeFocusResources(); @@ -269,34 +275,70 @@ class _HubDetailScreenState extends State }); } + bool _shouldUsePaginatedLoader(MediaServerClient client) => + client.backend == MediaBackend.jellyfin && widget.hub.id.endsWith('.recent'); + + @override + Future> fetchPage(int start, int size, AbortController? abort) async { + final serverId = widget.hub.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); + if (client == null) throw StateError('No media client available for paginated hub'); + return client.fetchMoreHubItemsPage(widget.hub.id, start: start, size: size, abort: abort); + } + + @override + void onPageLoaded(int start, List items) { + if (!_usesPaginatedLoader || start == 0 || !mounted) return; + setState(() { + _items = List.of(_items)..addAll(items); + _filteredItems = List.of(_items); + }); + _applySort(); + _scheduleNextHubPageCheck(); + } + + @override + void onPaginationStateChanged() { + if (mounted) setState(() {}); + } + Future _loadMoreItems() async { if (_isLoading) return; final serverId = widget.hub.serverId; - if (widget.loadItems == null && serverId == null) { + final loader = widget.loadItems; + if (loader == null && serverId == null) { appLogger.w('Hub has no serverId; cannot load more items for ${widget.hub.id}'); return; } + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); + final usesCustomLoader = loader != null; + _usesPaginatedLoader = !usesCustomLoader && client != null && _shouldUsePaginatedLoader(client); + setState(() { _isLoading = true; _errorMessage = null; + if (_usesPaginatedLoader) resetPaginationState(); }); try { List items = const []; var totalCount = 0; var loadedCount = 0; - var usesCustomLoader = false; - MediaServerClient? client; + var initialPageApplied = true; final applied = await _continuation.runNewGeneration(() async { - final loader = widget.loadItems; - usesCustomLoader = loader != null; - client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); - if (loader == null) { + if (_usesPaginatedLoader) { + final result = await loadInitialPageWithStatus(_pageSize); + initialPageApplied = result.applied; + if (!result.applied) return; + items = result.page.items; + totalCount = result.page.totalCount; + loadedCount = result.page.items.length; + } else if (loader == null) { final page = client == null ? const LibraryPage(items: [], totalCount: 0) - : await client!.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize); + : await client.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize); items = _applySectionFilter(page.items); totalCount = page.totalCount; loadedCount = page.items.length; @@ -307,7 +349,7 @@ class _HubDetailScreenState extends State } }); - if (!mounted || !applied) return; + if (!mounted || !applied || !initialPageApplied) return; setState(() { _items = List.of(items); _filteredItems = List.of(items); @@ -315,14 +357,16 @@ class _HubDetailScreenState extends State }); _applySort(); - if (!usesCustomLoader && client != null && loadedCount < totalCount) { - _replaceContinuationItems = client!.backend == MediaBackend.plex; + if (!usesCustomLoader && !_usesPaginatedLoader && client != null && loadedCount < totalCount) { + _replaceContinuationItems = client.backend == MediaBackend.plex; if (_replaceContinuationItems) { _continuation.setContinuation(startIndex: 0, totalCount: 1); } else { _continuation.setContinuation(startIndex: loadedCount, totalCount: totalCount); } unawaited(_continuation.loadRemaining()); + } else if (_usesPaginatedLoader && loadedCount < totalCount) { + _scheduleNextHubPageCheck(); } appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}'); @@ -370,13 +414,51 @@ class _HubDetailScreenState extends State _applySort(); } + void _scheduleNextHubPageCheck() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _maybeLoadNextHubPage(); + }); + } + + void _maybeLoadNextHubPage() { + if (!_usesPaginatedLoader || + loadedItems.length >= totalSize || + isPaginationLoading || + paginationError != null || + !scrollController.hasClients) { + return; + } + final position = scrollController.position; + if (position.extentAfter <= position.viewportDimension) { + _requestNextHubPage(); + } + } + + void _requestNextHubPage() { + if (!_usesPaginatedLoader || loadedItems.length >= totalSize || isPaginationLoading || paginationError != null) { + return; + } + ensureIndexLoaded(loadedItems.length, pageSize: _pageSize); + } + + void _handleGridItemFocusChange(int index, bool hasFocus, {required bool isLastRow}) { + trackGridItemFocus(index, hasFocus); + if (hasFocus && isLastRow) _requestNextHubPage(); + } + void _handleContinuationStateChanged() { if (mounted) { setState(() {}); } } - void _retryHubContinuation() => unawaited(_continuation.retry()); + void _retryHubContinuation() { + if (_usesPaginatedLoader) { + ensureIndexLoaded(loadedItems.length, pageSize: _pageSize); + } else { + unawaited(_continuation.retry()); + } + } List _applySectionFilter(List items) { final sectionFilter = int.tryParse(widget.hub.libraryId ?? ''); @@ -408,8 +490,11 @@ class _HubDetailScreenState extends State unawaited(_loadMoreItems()); } + Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error; + bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading; + Widget _buildContinuationStatusSliver() { - final exception = _continuation.error; + final exception = _pageLoadError; final error = exception == null ? null : t.messages.errorLoading(error: exception.toString()); return SliverToBoxAdapter( child: Padding( @@ -530,13 +615,16 @@ class _HubDetailScreenState extends State usesContinueWatchingAction: widget.usesContinueWatchingAction, onNavigateUp: position.isFirstRow ? navigateToAppBar : null, onNavigateDown: - _continuation.error != null && - position.index >= position.itemCount - position.columnCount + _pageLoadError != null && position.index >= position.itemCount - position.columnCount ? _continuationRetryFocusNode.requestFocus : null, onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null, onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + onFocusChange: (hasFocus) => _handleGridItemFocusChange( + index, + hasFocus, + isLastRow: position.index >= position.itemCount - position.columnCount, + ), mixedHubContext: isMixedHub, fullBleedImage: fullCardLayout && position.isGrid, ); @@ -544,7 +632,7 @@ class _HubDetailScreenState extends State ); }, ), - if (_filteredItems.isNotEmpty && (_continuation.isLoading || _continuation.error != null)) + if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) _buildContinuationStatusSliver(), ], ), diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 1a56dd8f..ecbda98a 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1511,13 +1511,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ].where((h) => h.items.isNotEmpty).toList(); } - /// Re-run the synthetic hub query without the preview limit so the - /// hub-detail screen can render the full list. Branches on the - /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: - /// `home.recent` / `library.{id}.recent` → Latest, `*.latestalbums` → - /// Latest with the slim music album fields, `*.continue` → Resume, - /// `*.nextup` → NextUp, `*.recentlyplayed` / `*.mostplayed` → the music - /// played-track queries. Unknown ids return an empty list. + /// Expand a synthetic hub so the detail screen can render beyond its + /// preview. Recently Added uses the pageable Items endpoint with the same + /// date-created ordering and media types as Jellyfin's Latest query. + /// Latest Albums retains the grouped, single-page Latest endpoint. + /// Continue Watching, Next Up, Recently Played, and Most Played use their + /// native pageable endpoints. Unknown ids return an empty list. @override Future> fetchMoreHubItems(String hubId, {int? limit}) async { try { @@ -1551,19 +1550,35 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final tail = hubId.split('.').last; switch (tail) { case 'recent': + return _safeFetchMediaPage( + '/Items', + { + 'userId': connection.userId, + 'ParentId': ?parentId, + 'Recursive': 'true', + 'StartIndex': offset.toString(), + 'Limit': effectiveLimit, + 'EnableTotalRecordCount': 'true', + 'IncludeItemTypes': 'Movie,Series,Episode,Video,MusicVideo,Photo', + 'SortBy': 'DateCreated,SortName,ProductionYear', + 'SortOrder': 'Descending,Descending,Descending', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + offset: offset, + requestedSize: pageSize, + abort: abort, + ); case 'latestalbums': - // Jellyfin's Latest endpoint has a Limit but no StartIndex. Expose it - // as one bounded page so callers don't infer endless fake pages. - // Music album rows keep the slim fields their preview row used - // (see [_musicAlbumRowFields]). + // Latest groups music into albums but does not expose StartIndex. if (offset > 0) return LibraryPage(items: const [], totalCount: offset, offset: offset); return _safeFetchMediaPage( '/Users/${_segment(connection.userId)}/Items/Latest', { 'Limit': effectiveLimit, - 'Fields': tail == 'latestalbums' ? _musicAlbumRowFields : _browseFields, - if (tail == 'latestalbums') 'EnableUserData': 'false', - if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode', + 'Fields': _musicAlbumRowFields, + 'EnableUserData': 'false', + 'ParentId': ?parentId, ...jellyfinImageQueryParameters, }, offset: offset, diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index 2e95ce49..01cf7af1 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -272,9 +272,13 @@ void main() { // Drain the failed Future, then advance past the retry timer's 1s delay // so the timer fires and re-invokes ensureIndexLoaded. await tester.pump(); + expect(state.paginationError, isA()); + expect(state.isPaginationLoading, isFalse); await tester.pump(const Duration(milliseconds: 1100)); // Drain the retry's Future. await tester.pump(); + expect(state.paginationError, isNull); + expect(state.isPaginationLoading, isFalse); expect(state.loadedItems.containsKey(220), isTrue); expect(rangeAttempt, greaterThanOrEqualTo(2)); // failed + retry diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart index 9f26d1c1..a78966ce 100644 --- a/test/screens/hub_detail_screen_test.dart +++ b/test/screens/hub_detail_screen_test.dart @@ -42,7 +42,7 @@ void main() { harness.wrap( HubDetailScreen( hub: MediaHub( - id: 'home.recent', + id: 'home.continue', title: 'Recent', type: 'movie', items: items.take(5).toList(), @@ -66,6 +66,39 @@ void main() { expect(find.text('Item 203'), findsNothing); }); + testWidgets('Jellyfin Recently Added fetches every page only as the user reaches the end', (tester) async { + final items = List.generate(450, (index) => _item(index, backend: MediaBackend.jellyfin)); + final harness = await _createHarness(items, backend: MediaBackend.jellyfin); + + await tester.pumpWidget( + harness.wrap( + HubDetailScreen( + hub: MediaHub( + id: 'home.recent', + title: 'Recently Added', + type: 'mixed', + items: items.take(20).toList(), + size: items.length, + more: true, + serverId: 'server_1', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0]); + expect(harness.client.requestedSizes, [200]); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000)); + await tester.pumpAndSettle(); + expect(harness.client.requestedStarts, [0, 200, 400]); + expect(harness.client.requestedSizes, [200, 200, 50]); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000)); + await tester.pumpAndSettle(); + expect(find.text('Item 449'), findsOneWidget); + }); + testWidgets('Plex hub replaces its preview with the full-hub response', (tester) async { final items = List.generate(205, (index) => _item(index, backend: MediaBackend.plex)); final harness = await _createHarness(items, backend: MediaBackend.plex); @@ -138,6 +171,7 @@ class _PagedHubClient implements MediaServerClient { final List items; final List requestedStarts = []; + final List requestedSizes = []; int fullHubRequests = 0; @override @@ -161,6 +195,7 @@ class _PagedHubClient implements MediaServerClient { AbortController? abort, }) async { requestedStarts.add(start); + requestedSizes.add(size); return fakeLibraryPage(items, start: start, size: size); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 54ca32d7..b5e7491e 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -3142,14 +3142,20 @@ void main() { return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } - test('global "home.recent" hits /Users/{userId}/Items/Latest with provided limit', () async { + test('global "home.recent" uses the pageable Items catalogue with the provided limit', () async { final client = buildClient(); await client.fetchMoreHubItems('home.recent', limit: 80); expect(captured, isNotNull); - expect(captured!.path, '/Users/user-1/Items/Latest'); + expect(captured!.path, '/Items'); + expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['Limit'], '80'); - expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode'); + expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); + expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo'); + expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear'); + expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); @@ -3192,19 +3198,21 @@ void main() { client.close(); }); - test('library-scoped "library.{id}.recent" forwards ParentId to Latest', () async { + test('library-scoped "library.{id}.recent" pages Items within its parent', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.recent', limit: 30); expect(captured, isNotNull); - expect(captured!.path, '/Users/user-1/Items/Latest'); + expect(captured!.path, '/Items'); + expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['ParentId'], 'lib-99'); + expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['Limit'], '30'); + expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); + expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); - // ParentId-scoped Latest should NOT also pin IncludeItemTypes (the - // library already constrains the kinds returned). - expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse); client.close(); }); @@ -3313,23 +3321,37 @@ void main() { expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); }); - test('Latest hub is treated as a single page when offset is requested', () async { - var requestCount = 0; + test('paged Recently Added sends the requested offset and parses total count', () async { + Uri? requestUri; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { - requestCount++; - return http.Response('[]', 200, headers: {'content-type': 'application/json'}); + requestUri = req.url; + return http.Response( + jsonEncode({ + 'TotalRecordCount': 321, + 'Items': [ + {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, + ], + }), + 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.items.single.id, 'recent-20'); + expect(page.totalCount, 321); expect(page.offset, 20); - expect(requestCount, 0); + expect(requestUri, isNotNull); + expect(requestUri!.path, '/Items'); + expect(requestUri!.queryParameters['StartIndex'], '20'); + expect(requestUri!.queryParameters['Limit'], '10'); + expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); + expect(requestUri!.queryParameters.containsKey('ParentId'), isFalse); }); test('paged hub first-page errors throw while list helper keeps empty fallback', () async {