diff --git a/lib/media/media_hub.dart b/lib/media/media_hub.dart index 705d0333..630cd19f 100644 --- a/lib/media/media_hub.dart +++ b/lib/media/media_hub.dart @@ -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 _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 tokens, String token) => tokens.isNotEmpty && tokens.last == token; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 6510c846..a2db5202 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1313,7 +1313,8 @@ class _DiscoverScreenState extends State 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, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index d3845c37..7d19d2dc 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -37,6 +37,7 @@ class HubDetailScreen extends StatefulWidget { final MediaHub hub; final Future> 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 createState() => _HubDetailScreenState(); @@ -550,6 +552,7 @@ class _HubDetailScreenState extends State ? _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 ? _handleRemoveFromContinueWatching : null, isInContinueWatching: widget.isInContinueWatching, + usesContinueWatchingAction: widget.usesContinueWatchingAction, onNavigateUp: isFirstRow ? navigateToAppBar : null, onNavigateLeft: isFirstColumn ? () {} : null, onBack: handleBackFromContent, diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 5ddae040..ffddd8e7 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -185,10 +185,9 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState hub.isContinueWatchingHub; + + static bool _usesContinueWatchingAction(MediaHub hub) => hub.usesContinueWatchingAction; @override Future> loadData() async { @@ -307,12 +306,14 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _handleVerticalNavigation(index, isUp), @@ -412,6 +413,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState createState() => _FocusableMediaCardState(); @@ -134,6 +136,7 @@ class _FocusableMediaCardState extends State { forceGridMode: widget.forceGridMode, forceListMode: widget.forceListMode, isInContinueWatching: widget.isInContinueWatching, + usesContinueWatchingAction: widget.usesContinueWatchingAction, collectionId: widget.collectionId, isOffline: widget.isOffline, mixedHubContext: widget.mixedHubContext, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 3d8c1f1c..a7cfe8cf 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -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> 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 createState() => HubSectionState(); @@ -359,7 +361,12 @@ class HubSectionState extends State with MountedSetStateMixin { } Future _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 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 with MountedSetStateMixin { onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, forceGridMode: true, isInContinueWatching: widget.isInContinueWatching, + usesContinueWatchingAction: widget.usesContinueWatchingAction, mixedHubContext: isMixedHub, ), ), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 367800f5..5275d501 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 createState() => MediaCardState(); @@ -162,7 +164,7 @@ class MediaCardState extends State with ContextMenuTapMixin> 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 { return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey()); } + 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 { context, item, onRefresh: widget.onRefresh, - playDirectly: widget.isContinueWatchingHub?.call(hub) ?? false, + playDirectly: _usesContinueWatchingAction(hub), ); } @@ -958,7 +966,8 @@ class TvBrowseRailState extends State { 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 { onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, forceGridMode: true, fullBleedImage: fullCardLayout, - isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, + isInContinueWatching: _isContinueWatchingHub(hub), + usesContinueWatchingAction: _usesContinueWatchingAction(hub), mixedHubContext: metrics.isMixedHub, episodePosterModeOverride: episodePosterMode, ), diff --git a/test/media/media_hub_test.dart b/test/media/media_hub_test.dart new file mode 100644 index 00000000..2dd8911d --- /dev/null +++ b/test/media/media_hub_test.dart @@ -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); + }); + }); +}