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:
@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
import '../providers/catalog_sources_provider.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import '../utils/catalog_navigation_helper.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
@@ -25,11 +26,6 @@ class _CatalogMenuAction {
|
||||
static const toggleWatchlist = _CatalogMenuAction(_CatalogMenuActionType.toggleWatchlist);
|
||||
}
|
||||
|
||||
/// Watchlist mutations keyed by source+item so a re-opened menu can't
|
||||
/// double-fire while one is still in flight (the detail screens keep their
|
||||
/// own per-screen guards).
|
||||
final Set<String> _watchlistMutationsInFlight = {};
|
||||
|
||||
/// Context menu for catalog stand-in cards (Explore tab). Replaces
|
||||
/// [MediaContextMenu], whose entries are all server-backed and would break on
|
||||
/// items with no server id.
|
||||
@@ -87,18 +83,12 @@ Future<void> showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset
|
||||
// Re-read membership: it can have changed while the menu was open
|
||||
// (snapshot load, another surface's toggle).
|
||||
final current = source!.isOnWatchlist(item.kind, item.ids) ?? onWatchlist ?? false;
|
||||
final mutationKey = '${source.id.name}/${item.kind.id}/${item.ids.canonicalKey ?? item.title}';
|
||||
if (!_watchlistMutationsInFlight.add(mutationKey)) return;
|
||||
try {
|
||||
if (current) {
|
||||
await source.removeFromWatchlist(item.kind, item.ids);
|
||||
} else {
|
||||
await source.addToWatchlist(item.kind, item.ids);
|
||||
}
|
||||
// The shared guard keys by source+item so a re-opened menu can't
|
||||
// double-fire while one mutation is still in flight.
|
||||
await mutateWatchlistMembership(item.kind, (source: source, ids: item.ids), add: !current);
|
||||
} catch (_) {
|
||||
if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||
} finally {
|
||||
_watchlistMutationsInFlight.remove(mutationKey);
|
||||
}
|
||||
case _CatalogMenuActionType.openUrl:
|
||||
final url = action.url;
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../services/music/music_playback_service.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
import '../services/watch_actions.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/delete_impact.dart';
|
||||
@@ -34,6 +35,7 @@ import '../utils/global_key_utils.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/offline_mode_provider.dart';
|
||||
import '../providers/catalog_sources_provider.dart';
|
||||
import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
@@ -62,6 +64,7 @@ import '../widgets/file_info_bottom_sheet.dart';
|
||||
import 'pill_input_decoration.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import 'watchlist_source_chooser.dart';
|
||||
import '../widgets/rating_bottom_sheet.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
@@ -315,6 +318,40 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
||||
|
||||
// Watchlist (movies and shows), backed by the connected catalog sources
|
||||
// (Trakt, Plex, MAL, ...). Whether THIS item resolves in a source needs
|
||||
// its external ids, which load lazily: the entry defaults to "Add" — an
|
||||
// idempotent no-op when the item turns out to be listed already — and
|
||||
// only offers "Remove" once cached candidates prove membership, so a
|
||||
// cold cache can never turn a press into a surprise removal. Opening
|
||||
// the menu warms both caches for the tap and the next open.
|
||||
final catalogSources = Provider.of<CatalogSourcesProvider?>(context, listen: false);
|
||||
var showWatchlistEntry = false;
|
||||
var watchlistRemoveOffered = false;
|
||||
if (mediaItem != null &&
|
||||
(mediaKind == MediaKind.movie || mediaKind == MediaKind.show) &&
|
||||
catalogSources != null &&
|
||||
catalogSources.watchlistCapableSources.isNotEmpty &&
|
||||
itemServerOnline &&
|
||||
!context.read<OfflineModeProvider>().isOffline) {
|
||||
final cachedCandidates = catalogSources.cachedWatchlistCandidatesFor(mediaItem);
|
||||
// Resolved-and-empty means no connected source can hold this item.
|
||||
showWatchlistEntry = cachedCandidates == null || cachedCandidates.isNotEmpty;
|
||||
watchlistRemoveOffered =
|
||||
cachedCandidates?.any((c) => c.source.isOnWatchlist(mediaItem.kind, c.ids) == true) ?? false;
|
||||
if (showWatchlistEntry) {
|
||||
unawaited(
|
||||
catalogSources.watchlistCandidatesFor(mediaItem, client: mediaClient).catchError((Object e, StackTrace st) {
|
||||
appLogger.d('Watchlist candidate warm-up failed', error: e, stackTrace: st);
|
||||
return const <WatchlistCandidate>[];
|
||||
}),
|
||||
);
|
||||
for (final source in catalogSources.watchlistCapableSources) {
|
||||
unawaited(source.ensureWatchlistLoaded());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion is the one gate that asks the server per item; see
|
||||
// [isMediaDeletionAllowed]. Only kinds that can actually be deleted pay
|
||||
// for the round trip, and only on a backend that answers it.
|
||||
@@ -620,6 +657,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
if (showWatchlistEntry) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'toggle_watchlist',
|
||||
icon: watchlistRemoveOffered ? Symbols.bookmark_remove_rounded : Symbols.bookmark_add_rounded,
|
||||
label: watchlistRemoveOffered ? t.explore.removeFromWatchlist : t.explore.addToWatchlist,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add to... (for episodes, movies, shows, and seasons). Plex-only —
|
||||
// uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The
|
||||
// MediaBrowser item-add APIs are different and not wired here yet.
|
||||
@@ -833,6 +880,18 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _showAddToSubmenu(context);
|
||||
break;
|
||||
|
||||
case 'toggle_watchlist':
|
||||
await _handleWatchlistToggle(
|
||||
context,
|
||||
mediaItem!,
|
||||
catalogSources!,
|
||||
mediaClient,
|
||||
removeOffered: watchlistRemoveOffered,
|
||||
position: position,
|
||||
openedFromKeyboard: openedFromKeyboard,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'shuffle_play':
|
||||
await _handleShufflePlayWithQueue(context);
|
||||
break;
|
||||
@@ -944,6 +1003,65 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolve-then-mutate for the watchlist entry. [removeOffered] pins the
|
||||
/// intent the user saw: an entry labeled "Add" always adds (idempotent
|
||||
/// when the item was already listed) — resolution finishing after the
|
||||
/// menu was built must not flip a press into a removal. With several
|
||||
/// capable sources a chooser opens and the picked source toggles by its
|
||||
/// own (by now resolved) membership, mirroring the detail screen.
|
||||
Future<void> _handleWatchlistToggle(
|
||||
BuildContext context,
|
||||
MediaItem item,
|
||||
CatalogSourcesProvider catalogSources,
|
||||
MediaServerClient? client, {
|
||||
required bool removeOffered,
|
||||
required Offset? position,
|
||||
required bool openedFromKeyboard,
|
||||
}) async {
|
||||
List<WatchlistCandidate> candidates;
|
||||
try {
|
||||
candidates = await catalogSources.watchlistCandidatesFor(item, client: client);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Watchlist candidate resolution failed', error: e, stackTrace: st);
|
||||
if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (candidates.isEmpty) {
|
||||
showAppSnackBar(context, t.explore.watchlistNoMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
final WatchlistCandidate candidate;
|
||||
final bool add;
|
||||
if (candidates.length == 1) {
|
||||
candidate = candidates.single;
|
||||
add = !removeOffered;
|
||||
} else {
|
||||
final choice = await showWatchlistSourceChooser(
|
||||
context,
|
||||
kind: item.kind,
|
||||
candidates: candidates,
|
||||
position: position,
|
||||
focusFirstItem: openedFromKeyboard,
|
||||
);
|
||||
if (choice == null || !context.mounted) return;
|
||||
candidate = choice;
|
||||
add = !(choice.source.isOnWatchlist(item.kind, choice.ids) ?? false);
|
||||
}
|
||||
|
||||
try {
|
||||
// Membership updates optimistically inside the source; Explore rows
|
||||
// and open detail screens listening to watchlistChanges follow.
|
||||
if (!await mutateWatchlistMembership(item.kind, candidate, add: add)) return;
|
||||
if (!context.mounted) return;
|
||||
showSuccessSnackBar(context, add ? t.explore.addedToWatchlist : t.explore.removedFromWatchlist);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Watchlist update failed', error: e, stackTrace: st);
|
||||
if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an action with error handling and refresh
|
||||
Future<void> _executeAction(BuildContext context, Future<void> Function() action, String successMessage) async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'app_menu.dart';
|
||||
import 'catalog_source_logo.dart';
|
||||
|
||||
/// Anchored chooser for an item that resolves in several watchlist-capable
|
||||
/// sources: one entry per source, subtitled with what selecting it would do
|
||||
/// given that source's current membership. Returns the picked candidate, or
|
||||
/// null on dismiss.
|
||||
Future<WatchlistCandidate?> showWatchlistSourceChooser(
|
||||
BuildContext context, {
|
||||
required MediaKind kind,
|
||||
required List<WatchlistCandidate> candidates,
|
||||
Rect? anchorRect,
|
||||
Offset? position,
|
||||
bool focusFirstItem = false,
|
||||
}) {
|
||||
return showAppMenu<WatchlistCandidate>(
|
||||
context,
|
||||
anchorRect: anchorRect,
|
||||
position: position,
|
||||
focusFirstItem: focusFirstItem,
|
||||
entries: [
|
||||
for (final candidate in candidates)
|
||||
AppMenuItem(
|
||||
value: candidate,
|
||||
leading: CatalogSourceLogo(candidate.source.id),
|
||||
label: candidate.source.displayName,
|
||||
subtitle: (candidate.source.isOnWatchlist(kind, candidate.ids) ?? false)
|
||||
? t.explore.removeFromWatchlist
|
||||
: t.explore.addToWatchlist,
|
||||
trailing: (candidate.source.isOnWatchlist(kind, candidate.ids) ?? false)
|
||||
? const AppIcon(Symbols.bookmark_added_rounded, fill: 1)
|
||||
: const AppIcon(Symbols.bookmark_add_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user