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