fix(jellyfin): honor continue watching action

close #1334
This commit is contained in:
edde746
2026-06-14 07:25:40 +02:00
parent e1c0df3bd7
commit 849e62df7e
9 changed files with 142 additions and 15 deletions
+36
View File
@@ -44,6 +44,21 @@ class MediaHub {
this.serverName,
});
/// True for hubs that represent the user's resumable Continue Watching row.
bool get isContinueWatchingHub => _anySemanticKey(_isContinueWatchingKey);
/// True when selecting an item should honor the Continue Watching action
/// preference. This is intentionally broader than [isContinueWatchingHub]:
/// backend "Next Up" rows should use the same activation preference without
/// inheriting remove-from-Continue-Watching menu semantics.
bool get usesContinueWatchingAction => isContinueWatchingHub || _anySemanticKey(_usesContinueWatchingActionKey);
bool _anySemanticKey(bool Function(String key) matches) {
if (matches(id)) return true;
final hubIdentifier = identifier;
return hubIdentifier != null && matches(hubIdentifier);
}
MediaHub copyWith({
String? id,
String? identifier,
@@ -70,3 +85,24 @@ class MediaHub {
);
}
}
bool _isContinueWatchingKey(String rawKey) {
final compactKey = _compactHubKey(rawKey);
if (compactKey == 'continuewatching') return true;
final tokens = _hubKeyTokens(rawKey);
return tokens.contains('inprogress') || _hasTailToken(tokens, 'continue');
}
bool _usesContinueWatchingActionKey(String rawKey) {
final tokens = _hubKeyTokens(rawKey);
return _hasTailToken(tokens, 'nextup') || tokens.contains('ondeck');
}
List<String> _hubKeyTokens(String rawKey) {
return rawKey.toLowerCase().split(RegExp(r'[^a-z0-9]+')).where((part) => part.isNotEmpty).toList(growable: false);
}
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '');
bool _hasTailToken(List<String> tokens, String token) => tokens.isNotEmpty && tokens.last == token;
+2 -1
View File
@@ -1313,7 +1313,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onFocusedItemChanged: _setSpotlightItem,
onRefresh: _discover.updateItem,
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
isContinueWatchingHub: (hub) => hub.id == 'continue_watching',
isContinueWatchingHub: (hub) => hub.isContinueWatchingHub,
usesContinueWatchingAction: (hub) => hub.usesContinueWatchingAction,
loadMoreItems: (hub) =>
hub.id == 'continue_watching' ? _discover.loadAllContinueWatching() : Future.value(hub.items),
onNavigateUp: _focusTopActions,
+5 -1
View File
@@ -37,6 +37,7 @@ class HubDetailScreen extends StatefulWidget {
final MediaHub hub;
final Future<List<MediaItem>> Function()? loadItems;
final bool isInContinueWatching;
final bool usesContinueWatchingAction;
final VoidCallback? onRemoveFromContinueWatching;
const HubDetailScreen({
@@ -44,8 +45,9 @@ class HubDetailScreen extends StatefulWidget {
required this.hub,
this.loadItems,
this.isInContinueWatching = false,
bool? usesContinueWatchingAction,
this.onRemoveFromContinueWatching,
});
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
State<HubDetailScreen> createState() => _HubDetailScreenState();
@@ -550,6 +552,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: index == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -591,6 +594,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: isFirstRow ? navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
@@ -185,10 +185,9 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
/// Detects Continue Watching hubs by hub identifier.
/// Section-specific CW hubs use identifiers like "movie.inprogress.1".
static bool _isContinueWatchingHub(MediaHub hub) {
final hubId = hub.identifier?.toLowerCase() ?? '';
return hubId.contains('inprogress');
}
static bool _isContinueWatchingHub(MediaHub hub) => hub.isContinueWatchingHub;
static bool _usesContinueWatchingAction(MediaHub hub) => hub.usesContinueWatchingAction;
@override
Future<List<MediaHub>> loadData() async {
@@ -307,12 +306,14 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
itemBuilder: (context, index) {
final hub = items[index];
final isContinueWatching = _isContinueWatchingHub(hub);
final usesContinueWatchingAction = _usesContinueWatchingAction(hub);
return HubSection(
key: index < _hubKeys.length ? _hubKeys[index] : null,
hub: hub,
icon: _getHubIcon(hub),
isInContinueWatching: isContinueWatching,
usesContinueWatchingAction: usesContinueWatchingAction,
onRefresh: updateItem,
onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null,
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
@@ -412,6 +413,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching,
isContinueWatchingHub: _isContinueWatchingHub,
usesContinueWatchingAction: _usesContinueWatchingAction,
onNavigateUp: widget.onNavigateToChrome ?? widget.onBack,
onNavigateToSidebar: _navigateToSidebar,
onBack: widget.onBack,
+4 -1
View File
@@ -23,6 +23,7 @@ class FocusableMediaCard extends StatefulWidget {
final bool forceGridMode;
final bool forceListMode;
final bool isInContinueWatching;
final bool usesContinueWatchingAction;
final String? collectionId;
/// True for downloaded content without server access
@@ -80,6 +81,7 @@ class FocusableMediaCard extends StatefulWidget {
this.forceGridMode = false,
this.forceListMode = false,
this.isInContinueWatching = false,
bool? usesContinueWatchingAction,
this.collectionId,
this.isOffline = false,
this.mixedHubContext = false,
@@ -93,7 +95,7 @@ class FocusableMediaCard extends StatefulWidget {
this.onNavigateRight,
this.onBack,
this.onFocusChange,
});
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
State<FocusableMediaCard> createState() => _FocusableMediaCardState();
@@ -134,6 +136,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
forceGridMode: widget.forceGridMode,
forceListMode: widget.forceListMode,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
collectionId: widget.collectionId,
isOffline: widget.isOffline,
mixedHubContext: widget.mixedHubContext,
+11 -2
View File
@@ -40,6 +40,7 @@ class HubSection extends StatefulWidget {
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool isInContinueWatching;
final bool usesContinueWatchingAction;
final bool showServerName;
final Future<List<MediaItem>> Function()? loadMoreItems;
@@ -75,6 +76,7 @@ class HubSection extends StatefulWidget {
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isInContinueWatching = false,
bool? usesContinueWatchingAction,
this.showServerName = false,
this.loadMoreItems,
this.onFocusedItemChanged,
@@ -84,7 +86,7 @@ class HubSection extends StatefulWidget {
this.onNavigateToSidebar,
this.inset = false,
this.focusScrollAlignment = 0.3,
});
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
State<HubSection> createState() => HubSectionState();
@@ -359,7 +361,12 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
}
Future<void> _navigateToItem(MediaItem item) async {
await navigateToMediaItem(context, item, onRefresh: widget.onRefresh, playDirectly: widget.isInContinueWatching);
await navigateToMediaItem(
context,
item,
onRefresh: widget.onRefresh,
playDirectly: widget.usesContinueWatchingAction,
);
}
void _navigateToHubDetail(BuildContext context) {
@@ -370,6 +377,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
hub: widget.hub,
loadItems: widget.loadMoreItems,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
),
),
@@ -568,6 +576,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
mixedHubContext: isMixedHub,
),
),
+4 -3
View File
@@ -56,6 +56,7 @@ class MediaCard extends StatefulWidget {
final bool forceGridMode;
final bool forceListMode;
final bool isInContinueWatching;
final bool usesContinueWatchingAction;
final String? collectionId; // The collection ID if displaying within a collection
final bool isOffline; // True for downloaded content without server access
final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes)
@@ -74,13 +75,14 @@ class MediaCard extends StatefulWidget {
this.forceGridMode = false,
this.forceListMode = false,
this.isInContinueWatching = false,
bool? usesContinueWatchingAction,
this.collectionId,
this.isOffline = false,
this.mixedHubContext = false,
this.showServerName = false,
this.episodePosterModeOverride,
this.fullBleedImage = false,
});
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
State<MediaCard> createState() => MediaCardState();
@@ -162,7 +164,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
item,
onRefresh: widget.onRefresh,
isOffline: widget.isOffline,
playDirectly: widget.isInContinueWatching,
playDirectly: widget.usesContinueWatchingAction,
);
if (!context.mounted) return;
@@ -900,7 +902,6 @@ class _MediaCardHelpers {
return const SizedBox.shrink();
}
}
/// Whether this media item has a clickable title that navigates somewhere.
+13 -3
View File
@@ -302,6 +302,7 @@ class TvBrowseRail extends StatefulWidget {
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool Function(MediaHub hub)? isContinueWatchingHub;
final bool Function(MediaHub hub)? usesContinueWatchingAction;
final Future<List<MediaItem>> Function(MediaHub hub)? loadMoreItems;
/// Optional per-hub trailing-slot state (loading/error/viewAll). When null the
@@ -344,6 +345,7 @@ class TvBrowseRail extends StatefulWidget {
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isContinueWatchingHub,
this.usesContinueWatchingAction,
this.loadMoreItems,
this.trailingForHub,
this.onRetryHub,
@@ -915,6 +917,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey<MediaCardState>());
}
bool _isContinueWatchingHub(MediaHub hub) => widget.isContinueWatchingHub?.call(hub) ?? false;
bool _usesContinueWatchingAction(MediaHub hub) {
return widget.usesContinueWatchingAction?.call(hub) ?? _isContinueWatchingHub(hub);
}
void _showContextMenuForCurrentItem() {
final hub = _activeHub;
if (hub == null || _itemIndex >= hub.items.length) return;
@@ -947,7 +955,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
context,
item,
onRefresh: widget.onRefresh,
playDirectly: widget.isContinueWatchingHub?.call(hub) ?? false,
playDirectly: _usesContinueWatchingAction(hub),
);
}
@@ -958,7 +966,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
builder: (context) => HubDetailScreen(
hub: hub,
loadItems: widget.loadMoreItems == null ? null : () => widget.loadMoreItems!(hub),
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
isInContinueWatching: _isContinueWatchingHub(hub),
usesContinueWatchingAction: _usesContinueWatchingAction(hub),
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
),
),
@@ -1316,7 +1325,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
fullBleedImage: fullCardLayout,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
isInContinueWatching: _isContinueWatchingHub(hub),
usesContinueWatchingAction: _usesContinueWatchingAction(hub),
mixedHubContext: metrics.isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_hub.dart';
MediaHub _hub({required String id, String? identifier}) {
return MediaHub(id: id, identifier: identifier, title: 'Hub', type: 'mixed', items: const []);
}
void main() {
group('MediaHub continue watching semantics', () {
test('recognizes Plex in-progress hubs as continue watching rows', () {
final hub = _hub(id: '/hubs/sections/1', identifier: 'movie.inprogress.1');
expect(hub.isContinueWatchingHub, isTrue);
expect(hub.usesContinueWatchingAction, isTrue);
});
test('recognizes Jellyfin continue hubs as continue watching rows', () {
final hub = _hub(id: 'library.lib-99.continue');
final homeHub = _hub(id: 'home.continue');
expect(hub.isContinueWatchingHub, isTrue);
expect(hub.usesContinueWatchingAction, isTrue);
expect(homeHub.isContinueWatchingHub, isTrue);
expect(homeHub.usesContinueWatchingAction, isTrue);
});
test('recognizes synthetic home continue watching hub', () {
final hub = _hub(id: 'continue_watching', identifier: '_continue_watching_');
expect(hub.isContinueWatchingHub, isTrue);
expect(hub.usesContinueWatchingAction, isTrue);
});
test('uses continue watching action for next up without removal semantics', () {
final hub = _hub(id: 'library.lib-99.nextup');
final homeHub = _hub(id: 'home.nextup');
expect(hub.isContinueWatchingHub, isFalse);
expect(hub.usesContinueWatchingAction, isTrue);
expect(homeHub.isContinueWatchingHub, isFalse);
expect(homeHub.usesContinueWatchingAction, isTrue);
});
test('uses continue watching action for Plex on deck without removal semantics', () {
final hub = _hub(id: '/hubs/sections/1', identifier: 'tv.ondeck.1');
expect(hub.isContinueWatchingHub, isFalse);
expect(hub.usesContinueWatchingAction, isTrue);
});
test('does not match unrelated hubs or library ids containing semantic words', () {
final recent = _hub(id: 'library.lib-99.recent');
final libraryNamedNextUp = _hub(id: 'library.nextup.recent');
expect(recent.isContinueWatchingHub, isFalse);
expect(recent.usesContinueWatchingAction, isFalse);
expect(libraryNamedNextUp.isContinueWatchingHub, isFalse);
expect(libraryNamedNextUp.usesContinueWatchingAction, isFalse);
});
});
}