diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index e4bf2259..5c1dc3c6 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -202,7 +202,7 @@ abstract class MediaServerClient { /// Items the user has started but not finished. Plex calls this "On Deck" /// internally; the neutral name matches the Continue Watching UI surface. - Future> fetchContinueWatching({int count = 20}); + Future> fetchContinueWatching({int? count = 20}); /// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin /// synthesizes `Latest` plus optional `Resume` + `NextUp`). diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 7970d049..b4e5bd57 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -82,6 +82,8 @@ class _DiscoverScreenState extends State WidgetsBindingObserver { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200); + static const int _continueWatchingPreviewLimit = 20; + static const int _continueWatchingProbeLimit = _continueWatchingPreviewLimit + 1; /// Items in [_onDeck] and [_hubs] can come from any registered server /// (Plex or Jellyfin), so resolve the server per-item rather than via the @@ -116,6 +118,7 @@ class _DiscoverScreenState extends State List _onDeck = []; List _hubs = []; + bool _hasMoreContinueWatching = false; bool _isLoading = true; bool _areHubsLoading = true; bool _switchingProfile = false; @@ -572,7 +575,7 @@ class _DiscoverScreenState extends State // Start OnDeck and hubs fetch in parallel final useGlobalHubs = context.settingsRead(SettingsService.useGlobalHubs); final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers( - limit: 20, + limit: _continueWatchingProbeLimit, hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, ); final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( @@ -582,11 +585,16 @@ class _DiscoverScreenState extends State ); // Wait for OnDeck to complete and show it immediately - final onDeck = await onDeckFuture; + final fetchedOnDeck = await onDeckFuture; + final hasMoreContinueWatching = fetchedOnDeck.length > _continueWatchingPreviewLimit; + final onDeck = hasMoreContinueWatching + ? fetchedOnDeck.take(_continueWatchingPreviewLimit).toList() + : fetchedOnDeck; if (!mounted) return; setState(() { _onDeck = onDeck; + _hasMoreContinueWatching = hasMoreContinueWatching; _isLoading = false; // Show content, but hubs still loading // Reset hero index to avoid sync issues @@ -676,14 +684,19 @@ class _DiscoverScreenState extends State } final hiddenLibrariesProvider = context.read(); - final onDeck = await multiServerProvider.aggregationService.getOnDeckFromAllServers( - limit: 20, + final fetchedOnDeck = await multiServerProvider.aggregationService.getOnDeckFromAllServers( + limit: _continueWatchingProbeLimit, hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, ); + final hasMoreContinueWatching = fetchedOnDeck.length > _continueWatchingPreviewLimit; + final onDeck = hasMoreContinueWatching + ? fetchedOnDeck.take(_continueWatchingPreviewLimit).toList() + : fetchedOnDeck; if (mounted) { setState(() { _onDeck = onDeck; + _hasMoreContinueWatching = hasMoreContinueWatching; // Reset hero index if needed if (_currentHeroIndex >= onDeck.length) { _currentHeroIndex = 0; @@ -706,6 +719,19 @@ class _DiscoverScreenState extends State } } + Future> _loadAllContinueWatchingItems() async { + final multiServerProvider = context.read(); + if (!multiServerProvider.hasConnectedServers) return const []; + + final hiddenLibrariesProvider = context.read(); + await hiddenLibrariesProvider.ensureInitialized(); + if (!mounted) return const []; + + return multiServerProvider.aggregationService.getOnDeckFromAllServers( + hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, + ); + } + /// Sync On Deck items to Android TV Watch Next row. Future _syncWatchNext(List onDeck) async { try { @@ -1238,14 +1264,15 @@ class _DiscoverScreenState extends State title: t.discover.continueWatching, type: 'mixed', identifier: '_continue_watching_', - size: _onDeck.length, - more: false, + size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), + more: _hasMoreContinueWatching, items: _onDeck, ), icon: Symbols.play_circle_rounded, onRefresh: updateItem, onRemoveFromContinueWatching: _refreshContinueWatching, isInContinueWatching: true, + loadMoreItems: _loadAllContinueWatchingItems, onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), onNavigateUp: _focusTopBoundary, onNavigateToSidebar: _navigateToSidebar, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 10ba9fdd..aa79078f 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../media/media_hub.dart'; @@ -25,8 +27,17 @@ import 'focusable_detail_screen_mixin.dart'; /// Screen to display full content of a recommendation hub class HubDetailScreen extends StatefulWidget { final MediaHub hub; + final Future> Function()? loadItems; + final bool isInContinueWatching; + final VoidCallback? onRemoveFromContinueWatching; - const HubDetailScreen({super.key, required this.hub}); + const HubDetailScreen({ + super.key, + required this.hub, + this.loadItems, + this.isInContinueWatching = false, + this.onRemoveFromContinueWatching, + }); @override State createState() => _HubDetailScreenState(); @@ -216,7 +227,7 @@ class _HubDetailScreenState extends State if (_isLoading) return; final serverId = widget.hub.serverId; - if (serverId == null) { + if (widget.loadItems == null && serverId == null) { appLogger.w('Hub has no serverId; cannot load more items for ${widget.hub.id}'); return; } @@ -227,8 +238,11 @@ class _HubDetailScreenState extends State }); try { - final client = context.tryGetMediaClientForServer(serverId); - var items = client == null ? const [] : await client.fetchMoreHubItems(widget.hub.id); + 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 ?? ''); @@ -283,6 +297,11 @@ class _HubDetailScreenState extends State } } + void _handleRemoveFromContinueWatching() { + widget.onRemoveFromContinueWatching?.call(); + unawaited(_loadMoreItems()); + } + @override void refresh() { _loadMoreItems(); @@ -355,6 +374,10 @@ class _HubDetailScreenState extends State item: item, disableScale: true, onRefresh: _handleItemRefresh, + onRemoveFromContinueWatching: widget.isInContinueWatching + ? _handleRemoveFromContinueWatching + : null, + isInContinueWatching: widget.isInContinueWatching, onNavigateUp: index == 0 ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), @@ -397,6 +420,10 @@ class _HubDetailScreenState extends State focusNode: focusNode, item: item, onRefresh: _handleItemRefresh, + onRemoveFromContinueWatching: widget.isInContinueWatching + ? _handleRemoveFromContinueWatching + : null, + isInContinueWatching: widget.isInContinueWatching, onNavigateUp: isFirstRow ? navigateToAppBar : null, onNavigateLeft: isFirstColumn ? () {} : null, onBack: handleBackFromContent, diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 753a5007..76b1b676 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -52,7 +52,7 @@ class DataAggregationService { final futures = clients.entries.map((entry) async { final client = entry.value; try { - return await client.fetchContinueWatching(); + return await client.fetchContinueWatching(count: limit); } catch (e, st) { appLogger.e('Failed on-deck fetch from ${entry.key}', error: e, stackTrace: st); return []; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 3d7d6cf7..e2c6118f 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -591,11 +591,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> fetchContinueWatching({int count = 20}) async { + Future> fetchContinueWatching({int? count = 20}) async { final results = await Future.wait([ _fetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, - 'Limit': count.toString(), + 'Limit': ?count?.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', @@ -604,7 +604,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { }), _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, - 'Limit': count.toString(), + 'Limit': ?count?.toString(), 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', @@ -868,9 +868,9 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { List _mergeContinueWatchingAndNextUp({ required List resume, required List nextUp, - required int limit, + required int? limit, }) { - if (limit <= 0) return const []; + if (limit != null && limit <= 0) return const []; final result = []; final seenIds = {}; @@ -885,11 +885,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { for (final item in resume) { add(item); - if (result.length >= limit) return result; + if (limit != null && result.length >= limit) return result; } for (final item in nextUp) { add(item); - if (result.length >= limit) return result; + if (limit != null && result.length >= limit) return result; } return result; } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index baed6fa2..81bf535c 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1270,13 +1270,13 @@ class PlexClient /// Get continue watching items via the hubs system. /// Uses /hubs?identifier=home.continue,home.ondeck which respects the /// server's OnDeckWindow preference (unlike /library/onDeck). - Future> _getContinueWatching({int count = 20}) async { + Future> _getContinueWatching({int? count = 20}) async { final response = await retryTransientMediaServerCall( operation: 'Plex continue watching hubs', attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, call: (timeout, abort) => _getWithFailover( '/hubs', - queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1}, + queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': ?count, 'includeGuids': 1}, timeout: timeout, abort: abort, allowEndpointFailover: false, @@ -3305,7 +3305,7 @@ class PlexClient } @override - Future> fetchContinueWatching({int count = 20}) async { + Future> fetchContinueWatching({int? count = 20}) async { final items = await _getContinueWatching(count: count); return items.map((m) => PlexMappers.mediaItem(m)).toList(); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 2f05c169..991f8dbd 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -14,6 +14,7 @@ import '../utils/grid_size_calculator.dart'; import '../theme/mono_tokens.dart'; import '../focus/locked_hub_controller.dart'; import '../media/media_hub.dart'; +import '../media/media_item.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; @@ -38,6 +39,7 @@ class HubSection extends StatefulWidget { final VoidCallback? onRemoveFromContinueWatching; final bool isInContinueWatching; final bool showServerName; + final Future> Function()? loadMoreItems; /// Callback for vertical navigation (up/down). Return true if handled. final bool Function(bool isUp)? onVerticalNavigation; @@ -66,6 +68,7 @@ class HubSection extends StatefulWidget { this.onRemoveFromContinueWatching, this.isInContinueWatching = false, this.showServerName = false, + this.loadMoreItems, this.onVerticalNavigation, this.onBack, this.onNavigateUp, @@ -314,7 +317,17 @@ class HubSectionState extends State with MountedSetStateMixin { } void _navigateToHubDetail(BuildContext context) { - Navigator.push(context, MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub))); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => HubDetailScreen( + hub: widget.hub, + loadItems: widget.loadMoreItems, + isInContinueWatching: widget.isInContinueWatching, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + ), + ), + ); } @override diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 00db680e..379cf6c0 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -59,6 +59,52 @@ void main() { expect(await service.getOnDeckFromAllServers(), isEmpty); }); + test('getOnDeckFromAllServers forwards preview limit to clients', () async { + final captured = []; + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + httpClient: MockClient((req) async { + captured.add(req.url); + if (req.url.path == '/hubs') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 1, + 'Metadata': [ + {'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie 1'}, + ], + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + final items = await service.getOnDeckFromAllServers(limit: 21); + + expect(items.map((item) => item.id), ['movie-1']); + expect(captured.single.path, '/hubs'); + expect(captured.single.queryParameters['count'], '21'); + }); + test('per-library hubs skip playback rows and fetch in bounded batches', () async { final captured = []; var activeLatest = 0; diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index f4cb4cbd..0eb2b57f 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1084,6 +1084,50 @@ void main() { expect(items.map((item) => item.id), ['resume-movie-1']); }); + + test('fetchContinueWatching omits Limit when count is null', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + requests.add(req.url); + if (req.url.path == '/UserItems/Resume') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Shows/NextUp') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(scoped.close); + + final items = await scoped.fetchContinueWatching(count: null); + + expect(items.map((item) => item.id), ['resume-movie-1', 'resume-movie-2', 'next-show-1', 'next-show-2']); + final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume'); + expect(resume.queryParameters.containsKey('Limit'), isFalse); + final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp'); + expect(nextUp.queryParameters.containsKey('Limit'), isFalse); + }); }); group('JellyfinClient.fetchGlobalHubs URL builders', () { diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index f996123d..10598916 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -127,6 +127,36 @@ void main() { expect(httpClient.requests.last.url.queryParameters['count'], '12'); }); + test('fetchContinueWatching omits count when uncapped', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final httpClient = _SequenceClient([(_) async => _jsonResponse(_continueWatchingPayload())]); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'http://server:32400', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'server-id', + serverName: 'Server', + httpClient: httpClient, + ); + addTearDown(client.close); + + final items = await client.fetchContinueWatching(count: null); + + expect(items, hasLength(1)); + expect(items.single.title, 'Movie A'); + expect(httpClient.requests.single.url.path, '/hubs'); + expect(httpClient.requests.single.url.queryParameters['identifier'], 'home.continue,home.ondeck'); + expect(httpClient.requests.single.url.queryParameters.containsKey('count'), isFalse); + expect(httpClient.requests.single.url.queryParameters['includeGuids'], '1'); + }); + test('fetchLibraryHubs retries transient failures without switching Plex endpoints', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); @@ -192,6 +222,24 @@ Map _globalHubsPayload() => { }, }; +Map _continueWatchingPayload() => { + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 1, + 'more': false, + 'Metadata': [ + {'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'}, + ], + }, + ], + }, +}; + Map _mediaProvidersPayload() => { 'MediaContainer': { 'MediaProvider': [