fix(discover): expose full continue watching

close #1045
This commit is contained in:
edde746
2026-05-15 18:49:23 +02:00
parent 562d19c207
commit e37602421b
10 changed files with 228 additions and 23 deletions
+1 -1
View File
@@ -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<List<MediaItem>> fetchContinueWatching({int count = 20});
Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
/// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
/// synthesizes `Latest` plus optional `Resume` + `NextUp`).
+33 -6
View File
@@ -82,6 +82,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
List<MediaItem> _onDeck = [];
List<MediaHub> _hubs = [];
bool _hasMoreContinueWatching = false;
bool _isLoading = true;
bool _areHubsLoading = true;
bool _switchingProfile = false;
@@ -572,7 +575,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// 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<DiscoverScreen>
);
// 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<DiscoverScreen>
}
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
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<DiscoverScreen>
}
}
Future<List<MediaItem>> _loadAllContinueWatchingItems() async {
final multiServerProvider = context.read<MultiServerProvider>();
if (!multiServerProvider.hasConnectedServers) return const [];
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
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<void> _syncWatchNext(List<MediaItem> onDeck) async {
try {
@@ -1238,14 +1264,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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,
+31 -4
View File
@@ -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<List<MediaItem>> 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<HubDetailScreen> createState() => _HubDetailScreenState();
@@ -216,7 +227,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
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<HubDetailScreen>
});
try {
final client = context.tryGetMediaClientForServer(serverId);
var items = client == null ? const <MediaItem>[] : 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 <MediaItem>[] : 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<HubDetailScreen>
}
}
void _handleRemoveFromContinueWatching() {
widget.onRemoveFromContinueWatching?.call();
unawaited(_loadMoreItems());
}
@override
void refresh() {
_loadMoreItems();
@@ -355,6 +374,10 @@ class _HubDetailScreenState extends State<HubDetailScreen>
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<HubDetailScreen>
focusNode: focusNode,
item: item,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
onNavigateUp: isFirstRow ? navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
+1 -1
View File
@@ -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 <MediaItem>[];
@@ -591,11 +591,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
@override
Future<List<MediaItem>> fetchContinueWatching({int count = 20}) async {
Future<List<MediaItem>> 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<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
required int limit,
required int? limit,
}) {
if (limit <= 0) return const [];
if (limit != null && limit <= 0) return const [];
final result = <MediaItem>[];
final seenIds = <String>{};
@@ -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;
}
+3 -3
View File
@@ -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<List<PlexMetadataDto>> _getContinueWatching({int count = 20}) async {
Future<List<PlexMetadataDto>> _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<List<MediaItem>> fetchContinueWatching({int count = 20}) async {
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
final items = await _getContinueWatching(count: count);
return items.map((m) => PlexMappers.mediaItem(m)).toList();
}
+14 -1
View File
@@ -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<List<MediaItem>> 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<HubSection> 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