feat(watchlist): add watchlist toggle to library context menus

Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.

External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.

close #1822
This commit is contained in:
edde746
2026-08-09 17:14:48 +02:00
parent 9d13584c00
commit 716fe9b51b
34 changed files with 936 additions and 72 deletions
+247
View File
@@ -27,9 +27,11 @@ import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/metadata_edit/metadata_edit_adapters.dart';
import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/offline_mode_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
@@ -43,7 +45,9 @@ import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/external_ids.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/media_server_timeouts.dart';
@@ -1195,6 +1199,111 @@ void main() {
expect(harness.client.fileInfoRequests, isEmpty);
});
});
group('watchlist entry', () {
testWidgets('cold open offers Add and adds to the single capable source', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
final harness = await _pumpWatchlistMenu(tester, sources: [source], guids: ['imdb://tt1']);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.explore.addToWatchlist), findsOneWidget);
expect(find.text(t.explore.removeFromWatchlist), findsNothing);
await tester.tap(find.text(t.explore.addToWatchlist));
await tester.pumpAndSettle();
expect(source.mutations.map((m) => m.add), [true]);
expect(source.mutations.single.ids.imdb, 'tt1');
expect(find.text(t.explore.addedToWatchlist), findsOneWidget);
// Opening also kicked the membership snapshot load for the next open.
expect(source.ensureLoadedCalls, greaterThan(0));
});
testWidgets('offers Remove once cached membership is known, and removes', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'))
..membership = true;
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.explore.removeFromWatchlist), findsOneWidget);
await tester.tap(find.text(t.explore.removeFromWatchlist));
await tester.pumpAndSettle();
expect(source.mutations.map((m) => m.add), [false]);
expect(find.text(t.explore.removedFromWatchlist), findsOneWidget);
});
testWidgets('hides the entry when the item resolved in no capable source', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.mal, 'MAL'); // resolves null: out of domain
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.mediaMenu.markAsWatched), findsOneWidget);
expect(find.text(t.explore.addToWatchlist), findsNothing);
expect(find.text(t.explore.removeFromWatchlist), findsNothing);
});
testWidgets('reports when the tapped item matches no watchlist', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
// The metadata answer carries no Guid entries: no external ids.
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.explore.addToWatchlist));
await tester.pumpAndSettle();
expect(source.mutations, isEmpty);
expect(find.text(t.explore.watchlistNoMatch), findsOneWidget);
});
testWidgets('several capable sources open a per-source chooser', (tester) async {
final trakt = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
final simkl = _MenuWatchlistSource(CatalogSourceId.simkl, 'Simkl', resolveTo: const CatalogItemIds(imdb: 'tt1'))
..membership = true;
final harness = await _pumpWatchlistMenu(tester, sources: [trakt, simkl]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
// Membership known-true on one source labels the entry Remove.
await tester.tap(find.text(t.explore.removeFromWatchlist));
await tester.pumpAndSettle();
// The chooser names each source with its own pending action.
expect(find.text('Trakt'), findsOneWidget);
expect(find.text('Simkl'), findsOneWidget);
expect(find.text(t.explore.addToWatchlist), findsOneWidget);
expect(find.text(t.explore.removeFromWatchlist), findsOneWidget);
await tester.tap(find.text('Simkl'));
await tester.pumpAndSettle();
expect(simkl.mutations.map((m) => m.add), [false]);
expect(trakt.mutations, isEmpty);
expect(find.text(t.explore.removedFromWatchlist), findsOneWidget);
});
});
}
Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
@@ -1761,3 +1870,141 @@ JellyfinConnection _jellyfinConnection() {
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
class _MenuWatchlistSource implements CatalogSource {
_MenuWatchlistSource(this.id, this.displayName, {this.resolveTo});
@override
final CatalogSourceId id;
@override
final String displayName;
final CatalogItemIds? resolveTo;
bool? membership;
int ensureLoadedCalls = 0;
final List<({MediaKind kind, CatalogItemIds ids, bool add})> mutations = [];
@override
bool get supportsWatchlist => true;
@override
Future<void> ensureWatchlistLoaded() async {
ensureLoadedCalls++;
}
@override
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => membership;
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async => resolveTo;
@override
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) async =>
mutations.add((kind: kind, ids: ids, add: true));
@override
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) async =>
mutations.add((kind: kind, ids: ids, add: false));
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _WatchlistSourcesProvider extends CatalogSourcesProvider {
_WatchlistSourcesProvider(this.sources);
final List<CatalogSource> sources;
@override
List<CatalogSource> get connectedSources => sources;
}
class _SeedIdsClient implements MediaServerClient {
_SeedIdsClient(this.ids);
final ExternalIds ids;
@override
Future<ExternalIds> fetchExternalIds(String itemId) async => ids;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Pumps a Plex movie's context menu with [sources] connected as catalog
/// sources. The Plex MockClient answers the external-id metadata fetch with
/// [guids] (empty: the item carries no external ids).
Future<({GlobalKey<MediaContextMenuState> menuKey, MediaItem item, CatalogSourcesProvider catalogSources})>
_pumpWatchlistMenu(WidgetTester tester, {required List<CatalogSource> sources, List<String> guids = const []}) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
final client = testPlexClient(
serverId: ServerId('plex-1'),
httpClient: MockClient((request) async {
if (request.url.path == '/library/metadata/movie-1') {
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'Movie',
if (guids.isNotEmpty)
'Guid': [
for (final guid in guids) {'id': guid},
],
},
],
},
});
}
return http.Response('not found', 404);
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final offlineMode = OfflineModeProvider(manager);
final catalogSources = _WatchlistSourcesProvider(sources);
final stack = await ProfileStack.create(db: db, withStorage: false);
addTearDown(() async {
await stack.dispose();
catalogSources.dispose();
offlineMode.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final item = testMediaItem(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', serverId: 'plex-1');
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<OfflineModeProvider>.value(value: offlineMode),
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: catalogSources),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('watchlist target')),
),
),
),
),
),
),
);
return (menuKey: menuKey, item: item, catalogSources: catalogSources);
}