@@ -232,8 +232,9 @@ abstract class MediaServerClient {
|
||||
Future<void> markWatched(MediaItem item);
|
||||
Future<void> markUnwatched(MediaItem item);
|
||||
|
||||
/// Hide an item from Continue Watching without changing its watched
|
||||
/// status.
|
||||
/// Hide an item from Continue Watching without changing watched status or
|
||||
/// progress. Only call when [capabilities.continueWatchingRemoval] is true;
|
||||
/// unsupported backends throw [UnsupportedError].
|
||||
Future<void> removeFromContinueWatching(MediaItem item);
|
||||
|
||||
/// Rate the item on a 0–10 scale. Backends without numeric ratings
|
||||
|
||||
@@ -60,6 +60,10 @@ class ServerCapabilities {
|
||||
/// only a binary like/dislike, so star sliders should be hidden.
|
||||
final bool numericUserRating;
|
||||
|
||||
/// Hide an item from Continue Watching without changing watch state or
|
||||
/// playback progress. Plex exposes this directly; Jellyfin does not.
|
||||
final bool continueWatchingRemoval;
|
||||
|
||||
/// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`).
|
||||
/// Hides the "Search subtitles" affordance when false.
|
||||
final bool externalSubtitleSearch;
|
||||
@@ -113,6 +117,7 @@ class ServerCapabilities {
|
||||
this.serverSideSync = false,
|
||||
this.richHubs = false,
|
||||
this.numericUserRating = false,
|
||||
this.continueWatchingRemoval = false,
|
||||
this.externalSubtitleSearch = false,
|
||||
this.trackPreferencePersistence = false,
|
||||
this.endpointFailover = false,
|
||||
@@ -135,6 +140,7 @@ class ServerCapabilities {
|
||||
serverSideSync: true,
|
||||
richHubs: true,
|
||||
numericUserRating: true,
|
||||
continueWatchingRemoval: true,
|
||||
externalSubtitleSearch: true,
|
||||
trackPreferencePersistence: true,
|
||||
endpointFailover: true,
|
||||
@@ -185,6 +191,7 @@ class ServerCapabilities {
|
||||
bool? serverSideSync,
|
||||
bool? richHubs,
|
||||
bool? numericUserRating,
|
||||
bool? continueWatchingRemoval,
|
||||
bool? externalSubtitleSearch,
|
||||
bool? trackPreferencePersistence,
|
||||
bool? endpointFailover,
|
||||
@@ -205,6 +212,7 @@ class ServerCapabilities {
|
||||
serverSideSync: serverSideSync ?? this.serverSideSync,
|
||||
richHubs: richHubs ?? this.richHubs,
|
||||
numericUserRating: numericUserRating ?? this.numericUserRating,
|
||||
continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval,
|
||||
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch,
|
||||
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence,
|
||||
endpointFailover: endpointFailover ?? this.endpointFailover,
|
||||
|
||||
@@ -1358,12 +1358,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
|
||||
@override
|
||||
Future<void> removeFromContinueWatching(MediaItem item) async {
|
||||
// Jellyfin uses a `Hide` endpoint to remove items from Continue Watching.
|
||||
final response = await _http.post(
|
||||
'/UserItems/${_segment(item.id)}/HideFromResume',
|
||||
queryParameters: {'userId': connection.userId, 'Hide': 'true'},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.');
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -211,11 +211,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
activeProfile: activeProfile,
|
||||
);
|
||||
|
||||
// Backend capabilities — used to gate the "Play Version" item below.
|
||||
// Reads the same `capabilities.videoTranscoding` flag the in-player
|
||||
// sheet uses so the two surfaces never disagree about what's offered.
|
||||
// Backend capabilities gate menu items so we don't expose actions the
|
||||
// active server cannot perform.
|
||||
final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null;
|
||||
final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false;
|
||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||
|
||||
final menuActions = <_MenuAction>[];
|
||||
|
||||
@@ -271,7 +271,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isInContinueWatching) {
|
||||
if (widget.isInContinueWatching && canRemoveFromContinueWatching) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'remove_from_continue_watching',
|
||||
|
||||
@@ -399,7 +399,6 @@ void main() {
|
||||
await scoped.fetchClientSideEpisodeQueue('folder/show #1?x');
|
||||
await scoped.markWatched(item);
|
||||
await scoped.markUnwatched(item);
|
||||
await scoped.removeFromContinueWatching(item);
|
||||
await scoped.rate(item, 7);
|
||||
await scoped.rate(item, -1);
|
||||
|
||||
@@ -408,10 +407,26 @@ void main() {
|
||||
expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Episodes'));
|
||||
expect(paths, contains('/UserPlayedItems/folder%2Fitem%20%231%3Fx'));
|
||||
expect(paths.where((p) => p == '/UserPlayedItems/folder%2Fitem%20%231%3Fx'), hasLength(2));
|
||||
expect(paths, contains('/UserItems/folder%2Fitem%20%231%3Fx/HideFromResume'));
|
||||
expect(paths.where((p) => p == '/UserItems/folder%2Fitem%20%231%3Fx/Rating'), hasLength(2));
|
||||
});
|
||||
|
||||
test('removeFromContinueWatching is unsupported for Jellyfin and does not call the server', () async {
|
||||
var requested = false;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
requested = true;
|
||||
return http.Response('', 500);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1');
|
||||
|
||||
await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA<UnsupportedError>()));
|
||||
expect(requested, isFalse);
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization URL-encodes appended api_key', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(accessToken: 'tok+with spaces/?&'),
|
||||
|
||||
Reference in New Issue
Block a user