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:
@@ -1,8 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/plex_discover_client.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
@@ -78,4 +85,132 @@ void main() {
|
||||
expect(calls, 3);
|
||||
expect(provider.connectedSources, isEmpty);
|
||||
});
|
||||
|
||||
group('watchlist candidate cache', () {
|
||||
final item = testMediaItem(id: 'movie-1', serverId: 'server-1');
|
||||
|
||||
test('resolves an item once per session and hands menus the cached result', () async {
|
||||
final source = _FakeWatchlistSource(CatalogSourceId.trakt);
|
||||
final provider = _FakeSourcesProvider([source]);
|
||||
addTearDown(provider.dispose);
|
||||
final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1'));
|
||||
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), isNull);
|
||||
final first = await provider.watchlistCandidatesFor(item, client: client);
|
||||
final second = await provider.watchlistCandidatesFor(item, client: client);
|
||||
|
||||
expect(client.calls, 1);
|
||||
expect(first.single.source, same(source));
|
||||
expect(second, same(first));
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), same(first));
|
||||
});
|
||||
|
||||
test('concurrent loads for the same item coalesce into one resolution', () async {
|
||||
final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]);
|
||||
addTearDown(provider.dispose);
|
||||
final gate = Completer<void>();
|
||||
final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1'), gate: gate);
|
||||
|
||||
final first = provider.watchlistCandidatesFor(item, client: client);
|
||||
final second = provider.watchlistCandidatesFor(item, client: client);
|
||||
gate.complete();
|
||||
|
||||
expect(await second, same(await first));
|
||||
expect(client.calls, 1);
|
||||
});
|
||||
|
||||
test('a failed resolution is retried instead of cached', () async {
|
||||
final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]);
|
||||
addTearDown(provider.dispose);
|
||||
final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1'), error: StateError('unreachable'));
|
||||
|
||||
await expectLater(provider.watchlistCandidatesFor(item, client: client), throwsStateError);
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), isNull);
|
||||
|
||||
client.error = null;
|
||||
final candidates = await provider.watchlistCandidatesFor(item, client: client);
|
||||
expect(candidates, hasLength(1));
|
||||
expect(client.calls, 2);
|
||||
});
|
||||
|
||||
test('a null client resolves to nothing without caching the miss', () async {
|
||||
final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]);
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
expect(await provider.watchlistCandidatesFor(item, client: null), isEmpty);
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), isNull);
|
||||
|
||||
final candidates = await provider.watchlistCandidatesFor(
|
||||
item,
|
||||
client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1')),
|
||||
);
|
||||
expect(candidates, hasLength(1));
|
||||
});
|
||||
|
||||
test('a source rebind invalidates cached candidates', () async {
|
||||
PlexDiscoverSession? session = const PlexDiscoverSession(accessToken: 'a', clientIdentifier: 'client-id');
|
||||
final provider = _FakeSourcesProvider([
|
||||
_FakeWatchlistSource(CatalogSourceId.trakt),
|
||||
], plexSessionSupplier: () async => session);
|
||||
addTearDown(provider.dispose);
|
||||
await provider.onActiveProfileChanged('profile-1');
|
||||
|
||||
await provider.watchlistCandidatesFor(item, client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1')));
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), isNotNull);
|
||||
|
||||
await provider.onProfileBindingStateChanged(true);
|
||||
session = const PlexDiscoverSession(accessToken: 'b', clientIdentifier: 'client-id');
|
||||
await provider.onProfileBindingStateChanged(false);
|
||||
|
||||
expect(provider.cachedWatchlistCandidatesFor(item), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _ExternalIdsClient implements MediaServerClient {
|
||||
_ExternalIdsClient(this.ids, {this.error, this.gate});
|
||||
|
||||
final ExternalIds ids;
|
||||
Object? error;
|
||||
final Completer<void>? gate;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async {
|
||||
calls++;
|
||||
await gate?.future;
|
||||
if (error != null) throw error!;
|
||||
return ids;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeWatchlistSource implements CatalogSource {
|
||||
_FakeWatchlistSource(this.id);
|
||||
|
||||
@override
|
||||
final CatalogSourceId id;
|
||||
|
||||
@override
|
||||
bool get supportsWatchlist => true;
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
||||
CatalogItemIds(imdb: external.imdb);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// Overrides the bound-client source list so the cache can be exercised with
|
||||
/// fake sources; the invalidation paths (Plex session rebinds) stay real.
|
||||
class _FakeSourcesProvider extends CatalogSourcesProvider {
|
||||
_FakeSourcesProvider(this.sources, {super.plexSessionSupplier});
|
||||
|
||||
final List<CatalogSource> sources;
|
||||
|
||||
@override
|
||||
List<CatalogSource> get connectedSources => sources;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/library_watchlist_candidates.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
|
||||
class _ExternalIdsClient implements MediaServerClient {
|
||||
_ExternalIdsClient(this.ids, {this.error});
|
||||
|
||||
ExternalIds ids;
|
||||
Object? error;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async {
|
||||
calls++;
|
||||
if (error != null) throw error!;
|
||||
return ids;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeWatchlistSource implements CatalogSource {
|
||||
_FakeWatchlistSource(this.id, {this.resolveTo, this.resolveError, this.mutationGate, this.mutationError});
|
||||
|
||||
@override
|
||||
final CatalogSourceId id;
|
||||
|
||||
final CatalogItemIds? resolveTo;
|
||||
final Object? resolveError;
|
||||
final Completer<void>? mutationGate;
|
||||
final Object? mutationError;
|
||||
|
||||
int resolveCalls = 0;
|
||||
final List<({MediaKind kind, CatalogItemIds ids, bool add})> mutations = [];
|
||||
|
||||
@override
|
||||
bool get supportsWatchlist => true;
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
|
||||
resolveCalls++;
|
||||
if (resolveError != null) throw resolveError!;
|
||||
return resolveTo;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) => _mutate(kind, ids, add: true);
|
||||
|
||||
@override
|
||||
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) => _mutate(kind, ids, add: false);
|
||||
|
||||
Future<void> _mutate(MediaKind kind, CatalogItemIds ids, {required bool add}) async {
|
||||
await mutationGate?.future;
|
||||
if (mutationError != null) throw mutationError!;
|
||||
mutations.add((kind: kind, ids: ids, add: add));
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final item = testMediaItem(id: 'movie-1', serverId: 'server-1');
|
||||
|
||||
group('resolveWatchlistCandidates', () {
|
||||
test('pairs each source with its resolved ids, skipping out-of-domain and failing sources', () async {
|
||||
final trakt = _FakeWatchlistSource(CatalogSourceId.trakt, resolveTo: const CatalogItemIds(imdb: 'tt1'));
|
||||
final mal = _FakeWatchlistSource(CatalogSourceId.mal); // resolves null: not in domain
|
||||
final simkl = _FakeWatchlistSource(CatalogSourceId.simkl, resolveError: StateError('down'));
|
||||
|
||||
final candidates = await resolveWatchlistCandidates(
|
||||
client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1')),
|
||||
item: item,
|
||||
sources: [trakt, mal, simkl],
|
||||
);
|
||||
|
||||
expect(candidates.map((c) => c.source.id), [CatalogSourceId.trakt]);
|
||||
expect(candidates.single.ids.imdb, 'tt1');
|
||||
expect(mal.resolveCalls, 1);
|
||||
expect(simkl.resolveCalls, 1);
|
||||
});
|
||||
|
||||
test('an item without external ids resolves to nothing without asking any source', () async {
|
||||
final trakt = _FakeWatchlistSource(CatalogSourceId.trakt, resolveTo: const CatalogItemIds(imdb: 'tt1'));
|
||||
|
||||
final candidates = await resolveWatchlistCandidates(
|
||||
client: _ExternalIdsClient(const ExternalIds()),
|
||||
item: item,
|
||||
sources: [trakt],
|
||||
);
|
||||
|
||||
expect(candidates, isEmpty);
|
||||
expect(trakt.resolveCalls, 0);
|
||||
});
|
||||
|
||||
test('a null client (server offline) resolves to nothing', () async {
|
||||
final candidates = await resolveWatchlistCandidates(
|
||||
client: null,
|
||||
item: item,
|
||||
sources: [_FakeWatchlistSource(CatalogSourceId.trakt)],
|
||||
);
|
||||
expect(candidates, isEmpty);
|
||||
});
|
||||
|
||||
test('a failed external-id fetch propagates to the caller', () {
|
||||
expect(
|
||||
resolveWatchlistCandidates(
|
||||
client: _ExternalIdsClient(const ExternalIds(), error: StateError('unreachable')),
|
||||
item: item,
|
||||
sources: [_FakeWatchlistSource(CatalogSourceId.trakt)],
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('mutateWatchlistMembership', () {
|
||||
const ids = CatalogItemIds(imdb: 'tt1');
|
||||
|
||||
test('dispatches add and remove to the candidate source', () async {
|
||||
final source = _FakeWatchlistSource(CatalogSourceId.trakt);
|
||||
|
||||
expect(await mutateWatchlistMembership(MediaKind.movie, (source: source, ids: ids), add: true), isTrue);
|
||||
expect(await mutateWatchlistMembership(MediaKind.movie, (source: source, ids: ids), add: false), isTrue);
|
||||
|
||||
expect(source.mutations.map((m) => m.add), [true, false]);
|
||||
expect(source.mutations.first.ids.imdb, 'tt1');
|
||||
});
|
||||
|
||||
test('an identical mutation already in flight is refused instead of double-firing', () async {
|
||||
final gate = Completer<void>();
|
||||
final source = _FakeWatchlistSource(CatalogSourceId.trakt, mutationGate: gate);
|
||||
final candidate = (source: source, ids: ids);
|
||||
|
||||
final first = mutateWatchlistMembership(MediaKind.movie, candidate, add: true);
|
||||
expect(await mutateWatchlistMembership(MediaKind.movie, candidate, add: true), isFalse);
|
||||
expect(source.mutations, isEmpty);
|
||||
|
||||
gate.complete();
|
||||
expect(await first, isTrue);
|
||||
expect(source.mutations, hasLength(1));
|
||||
|
||||
// The guard releases with the mutation.
|
||||
expect(await mutateWatchlistMembership(MediaKind.movie, candidate, add: false), isTrue);
|
||||
expect(source.mutations, hasLength(2));
|
||||
});
|
||||
|
||||
test('a failed mutation rethrows and releases the guard', () async {
|
||||
final failing = _FakeWatchlistSource(CatalogSourceId.trakt, mutationError: StateError('api down'));
|
||||
final candidate = (source: failing, ids: ids);
|
||||
|
||||
await expectLater(mutateWatchlistMembership(MediaKind.movie, candidate, add: true), throwsStateError);
|
||||
|
||||
final working = _FakeWatchlistSource(CatalogSourceId.trakt);
|
||||
expect(await mutateWatchlistMembership(MediaKind.movie, (source: working, ids: ids), add: true), isTrue);
|
||||
expect(working.mutations, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user