@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user