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:
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personajlar",
|
||||
"addToWatchlist": "İzləmə siyahısına əlavə et",
|
||||
"removeFromWatchlist": "İzləmə siyahısından sil",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "İzləmə siyahısı yenilənə bilmədi",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Kitabxananızda yoxdur",
|
||||
"inTheseLibraries": "Bu kitabxanalarda var",
|
||||
"checkingLibrary": "Kitabxananız yoxlanılır...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Герои",
|
||||
"addToWatchlist": "Добави в списъка за гледане",
|
||||
"removeFromWatchlist": "Премахни от списъка за гледане",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Неуспешно обновяване на списъка за гледане",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Не е в твоята библиотека",
|
||||
"inTheseLibraries": "В тези библиотеки",
|
||||
"checkingLibrary": "Проверка на твоята библиотека...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Figurer",
|
||||
"addToWatchlist": "Føj til ønskeliste",
|
||||
"removeFromWatchlist": "Fjern fra ønskeliste",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Kunne ikke opdatere ønskelisten",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Ikke i dit bibliotek",
|
||||
"inTheseLibraries": "I disse biblioteker",
|
||||
"checkingLibrary": "Tjekker dit bibliotek...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Charaktere",
|
||||
"addToWatchlist": "Zur Merkliste hinzufügen",
|
||||
"removeFromWatchlist": "Von Merkliste entfernen",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Merkliste konnte nicht aktualisiert werden",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Nicht in deiner Mediathek",
|
||||
"inTheseLibraries": "In diesen Mediatheken",
|
||||
"checkingLibrary": "Deine Mediathek wird überprüft …",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Characters",
|
||||
"addToWatchlist": "Add to Watchlist",
|
||||
"removeFromWatchlist": "Remove from Watchlist",
|
||||
"addedToWatchlist": "Added to watchlist",
|
||||
"removedFromWatchlist": "Removed from watchlist",
|
||||
"watchlistUpdateFailed": "Couldn't update watchlist",
|
||||
"watchlistNoMatch": "Couldn't match this item to a watchlist",
|
||||
"notInLibrary": "Not in your library",
|
||||
"inTheseLibraries": "In these libraries",
|
||||
"checkingLibrary": "Checking your library...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personajes",
|
||||
"addToWatchlist": "Añadir a la lista de seguimiento",
|
||||
"removeFromWatchlist": "Quitar de la lista de seguimiento",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "No se pudo actualizar la lista de seguimiento",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "No está en tu biblioteca",
|
||||
"inTheseLibraries": "En estas bibliotecas",
|
||||
"checkingLibrary": "Comprobando tu biblioteca...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personnages",
|
||||
"addToWatchlist": "Ajouter à la liste de suivi",
|
||||
"removeFromWatchlist": "Retirer de la liste de suivi",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Impossible de mettre à jour la liste de suivi",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Absent de votre bibliothèque",
|
||||
"inTheseLibraries": "Dans ces bibliothèques",
|
||||
"checkingLibrary": "Vérification de votre bibliothèque...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Karakterek",
|
||||
"addToWatchlist": "Hozzáadás a Néznivalókhoz",
|
||||
"removeFromWatchlist": "Eltávolítás a Néznivalókból",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Nem sikerült a Néznivalók frissítése",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Nincs a könyvtáradban",
|
||||
"inTheseLibraries": "Ezekben a könyvtárakban",
|
||||
"checkingLibrary": "Könyvtár ellenőrzése...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personaggi",
|
||||
"addToWatchlist": "Aggiungi alla lista da guardare",
|
||||
"removeFromWatchlist": "Rimuovi dalla lista da guardare",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Impossibile aggiornare la lista da guardare",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Non è nella tua libreria",
|
||||
"inTheseLibraries": "In queste librerie",
|
||||
"checkingLibrary": "Ricerca nella tua libreria...",
|
||||
|
||||
@@ -1075,7 +1075,10 @@
|
||||
"characters": "キャラクター",
|
||||
"addToWatchlist": "ウォッチリストに追加",
|
||||
"removeFromWatchlist": "ウォッチリストから削除",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "ウォッチリストを更新できませんでした",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "ライブラリにありません",
|
||||
"inTheseLibraries": "これらのライブラリにあります",
|
||||
"checkingLibrary": "ライブラリを確認中…",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Кейіпкерлер",
|
||||
"addToWatchlist": "Көру тізіміне қосу",
|
||||
"removeFromWatchlist": "Көру тізімінен өшіру",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Көру тізімін жаңарту мүмкін болмады",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Кітапханаңызда жоқ",
|
||||
"inTheseLibraries": "Осы кітапханаларда бар",
|
||||
"checkingLibrary": "Кітапхана тексерілуде...",
|
||||
|
||||
@@ -1075,7 +1075,10 @@
|
||||
"characters": "캐릭터",
|
||||
"addToWatchlist": "관심 목록에 추가",
|
||||
"removeFromWatchlist": "관심 목록에서 제거",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "관심 목록을 업데이트하지 못했습니다",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"inTheseLibraries": "이 라이브러리에 있음",
|
||||
"checkingLibrary": "라이브러리 확인 중...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Figurer",
|
||||
"addToWatchlist": "Legg til i ønskeliste",
|
||||
"removeFromWatchlist": "Fjern fra ønskeliste",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Kunne ikke oppdatere ønskelisten",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Ikke i biblioteket ditt",
|
||||
"inTheseLibraries": "I disse bibliotekene",
|
||||
"checkingLibrary": "Sjekker biblioteket ditt...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personages",
|
||||
"addToWatchlist": "Toevoegen aan kijklijst",
|
||||
"removeFromWatchlist": "Verwijderen uit kijklijst",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Kon kijklijst niet bijwerken",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Niet in je bibliotheek",
|
||||
"inTheseLibraries": "In deze bibliotheken",
|
||||
"checkingLibrary": "Je bibliotheek controleren...",
|
||||
|
||||
@@ -1090,7 +1090,10 @@
|
||||
"characters": "Postacie",
|
||||
"addToWatchlist": "Dodaj do listy do obejrzenia",
|
||||
"removeFromWatchlist": "Usuń z listy do obejrzenia",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Nie udało się zaktualizować listy do obejrzenia",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Nie ma tego w Twojej bibliotece",
|
||||
"inTheseLibraries": "W tych bibliotekach",
|
||||
"checkingLibrary": "Sprawdzanie Twojej biblioteki...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Personagens",
|
||||
"addToWatchlist": "Adicionar à lista para assistir",
|
||||
"removeFromWatchlist": "Remover da lista para assistir",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Não foi possível atualizar a lista para assistir",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Não está na sua biblioteca",
|
||||
"inTheseLibraries": "Nestas bibliotecas",
|
||||
"checkingLibrary": "Verificando sua biblioteca...",
|
||||
|
||||
@@ -1090,7 +1090,10 @@
|
||||
"characters": "Персонажи",
|
||||
"addToWatchlist": "Добавить в список для просмотра",
|
||||
"removeFromWatchlist": "Удалить из списка для просмотра",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Не удалось обновить список для просмотра",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Нет в вашей библиотеке",
|
||||
"inTheseLibraries": "В этих библиотеках",
|
||||
"checkingLibrary": "Проверка вашей библиотеки...",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 38356 (1743 per locale)
|
||||
/// Strings: 38359 (1743 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -3054,9 +3054,18 @@ class Translations$explore$en {
|
||||
/// en: 'Remove from Watchlist'
|
||||
String get removeFromWatchlist => 'Remove from Watchlist';
|
||||
|
||||
/// en: 'Added to watchlist'
|
||||
String get addedToWatchlist => 'Added to watchlist';
|
||||
|
||||
/// en: 'Removed from watchlist'
|
||||
String get removedFromWatchlist => 'Removed from watchlist';
|
||||
|
||||
/// en: 'Couldn't update watchlist'
|
||||
String get watchlistUpdateFailed => 'Couldn\'t update watchlist';
|
||||
|
||||
/// en: 'Couldn't match this item to a watchlist'
|
||||
String get watchlistNoMatch => 'Couldn\'t match this item to a watchlist';
|
||||
|
||||
/// en: 'Not in your library'
|
||||
String get notInLibrary => 'Not in your library';
|
||||
|
||||
@@ -7065,7 +7074,10 @@ extension on Translations {
|
||||
'explore.characters' => 'Characters',
|
||||
'explore.addToWatchlist' => 'Add to Watchlist',
|
||||
'explore.removeFromWatchlist' => 'Remove from Watchlist',
|
||||
'explore.addedToWatchlist' => 'Added to watchlist',
|
||||
'explore.removedFromWatchlist' => 'Removed from watchlist',
|
||||
'explore.watchlistUpdateFailed' => 'Couldn\'t update watchlist',
|
||||
'explore.watchlistNoMatch' => 'Couldn\'t match this item to a watchlist',
|
||||
'explore.notInLibrary' => 'Not in your library',
|
||||
'explore.inTheseLibraries' => 'In these libraries',
|
||||
'explore.checkingLibrary' => 'Checking your library...',
|
||||
@@ -7102,11 +7114,11 @@ extension on Translations {
|
||||
'explore.stats.viewersWeek' => ({required Object n}) => '${n} watched this week',
|
||||
'explore.stats.viewersMonth' => ({required Object n}) => '${n} watched this month',
|
||||
'explore.stats.viewersYear' => ({required Object n}) => '${n} watched this year',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.stats.viewersAllTime' => ({required Object n}) => '${n} viewers',
|
||||
'explore.stats.planning' => ({required Object n}) => '${n} planning to watch',
|
||||
'explore.stats.favorited' => ({required Object n}) => '${n} favorites',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.stats.dropRate' => ({required Object percent}) => '${percent} dropped it',
|
||||
'explore.stats.comments' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} comment', other: '${n} comments', ),
|
||||
'explore.stats.votes' => ({required Object n}) => '${n} votes',
|
||||
@@ -7616,11 +7628,11 @@ extension on Translations {
|
||||
'performanceOverlay.player' => 'Player',
|
||||
'performanceOverlay.memory' => 'Memory',
|
||||
'performanceOverlay.uiFps' => 'UI FPS',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'externalPlayer.title' => 'External Player',
|
||||
'externalPlayer.useExternalPlayer' => 'Use External Player',
|
||||
'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'externalPlayer.selectPlayer' => 'Select Player',
|
||||
'externalPlayer.customPlayers' => 'Custom Players',
|
||||
'externalPlayer.systemDefault' => 'System Default',
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Karaktärer",
|
||||
"addToWatchlist": "Lägg till i bevakningslista",
|
||||
"removeFromWatchlist": "Ta bort från bevakningslista",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Det gick inte att uppdatera bevakningslistan",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Finns inte i ditt bibliotek",
|
||||
"inTheseLibraries": "I dessa bibliotek",
|
||||
"checkingLibrary": "Kontrollerar ditt bibliotek...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Karakterler",
|
||||
"addToWatchlist": "İzleme Listesine Ekle",
|
||||
"removeFromWatchlist": "İzleme Listesinden Kaldır",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "İzleme listesi güncellenemedi",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Kitaplığınızda yok",
|
||||
"inTheseLibraries": "Bu kitaplıklarda var",
|
||||
"checkingLibrary": "Kitaplığınız kontrol ediliyor...",
|
||||
|
||||
@@ -1080,7 +1080,10 @@
|
||||
"characters": "Qahramonlar",
|
||||
"addToWatchlist": "Tomosha roʻyxatiga qoʻshish",
|
||||
"removeFromWatchlist": "Tomosha roʻyxatidan oʻchirish",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "Tomosha roʻyxatini yangilab boʻlmadi",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "Kutubxonangizda yoʻq",
|
||||
"inTheseLibraries": "Ushbu kutubxonalarda bor",
|
||||
"checkingLibrary": "Kutubxona tekshirilmoqda...",
|
||||
|
||||
@@ -1075,7 +1075,10 @@
|
||||
"characters": "角色",
|
||||
"addToWatchlist": "新增至待看清單",
|
||||
"removeFromWatchlist": "從待看清單移除",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "無法更新待看清單",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "不在您的媒體庫中",
|
||||
"inTheseLibraries": "在這些媒體庫中",
|
||||
"checkingLibrary": "正在檢查您的媒體庫…",
|
||||
|
||||
@@ -1075,7 +1075,10 @@
|
||||
"characters": "角色",
|
||||
"addToWatchlist": "添加到想看列表",
|
||||
"removeFromWatchlist": "从想看列表移除",
|
||||
"addedToWatchlist": "",
|
||||
"removedFromWatchlist": "",
|
||||
"watchlistUpdateFailed": "无法更新想看列表",
|
||||
"watchlistNoMatch": "",
|
||||
"notInLibrary": "不在你的媒体库中",
|
||||
"inTheseLibraries": "在这些媒体库中",
|
||||
"checkingLibrary": "正在检查你的媒体库…",
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../connection/connection_registry.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
@@ -11,6 +13,7 @@ import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/profile_connection_registry.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../services/base_shared_preferences_service.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import '../services/catalog/catalog_source.dart';
|
||||
import '../services/catalog/anilist_catalog_source.dart';
|
||||
import '../services/catalog/mal_catalog_source.dart';
|
||||
@@ -18,6 +21,7 @@ import '../services/catalog/plex_catalog_source.dart';
|
||||
import '../services/catalog/seerr_catalog_source.dart';
|
||||
import '../services/catalog/simkl_catalog_source.dart';
|
||||
import '../services/catalog/trakt_catalog_source.dart';
|
||||
import '../services/trackers/future_coalescer.dart';
|
||||
import '../services/plex_discover_client.dart';
|
||||
import '../services/seerr/seerr_client.dart';
|
||||
import '../services/trackers/anilist/anilist_client.dart';
|
||||
@@ -115,6 +119,14 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
CatalogSourceId? _preferredSourceId;
|
||||
String _activeUserUuid = '';
|
||||
|
||||
// Per-item watchlist candidates (see [watchlistCandidatesFor]): the future
|
||||
// cache dedups/retries loads, the results map gives menu surfaces
|
||||
// synchronous access for labeling, and the generation guard keeps a load
|
||||
// that outlives a source rebind from repopulating disposed sources.
|
||||
final KeyedFutureCache<String, List<WatchlistCandidate>> _watchlistCandidateLoads = KeyedFutureCache();
|
||||
final Map<String, List<WatchlistCandidate>> _watchlistCandidateResults = {};
|
||||
int _watchlistCandidateGeneration = 0;
|
||||
|
||||
List<CatalogSource> get connectedSources => [
|
||||
?_trakt.source,
|
||||
?_mal.source,
|
||||
@@ -160,6 +172,40 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
return watchlistCapableSource;
|
||||
}
|
||||
|
||||
static String _watchlistItemKey(MediaItem item) => '${item.serverId ?? ''}/${item.id}';
|
||||
|
||||
/// Session-cached [resolveWatchlistCandidates] for a library item: every
|
||||
/// watchlist-capable source the item exists in, with its per-source ids.
|
||||
/// Shared by the detail screen and card context menus so an item resolves
|
||||
/// its external ids at most once per session. Failures are not cached (the
|
||||
/// next call retries); a null [client] (server offline) resolves to
|
||||
/// nothing without caching the miss. Invalidated when sources rebind.
|
||||
Future<List<WatchlistCandidate>> watchlistCandidatesFor(MediaItem item, {required MediaServerClient? client}) {
|
||||
final cached = _watchlistCandidateResults[_watchlistItemKey(item)];
|
||||
if (cached != null) return Future.value(cached);
|
||||
if (client == null) return Future.value(const []);
|
||||
final generation = _watchlistCandidateGeneration;
|
||||
return _watchlistCandidateLoads.run(_watchlistItemKey(item), () async {
|
||||
final candidates = await resolveWatchlistCandidates(client: client, item: item, sources: watchlistCapableSources);
|
||||
if (!isDisposed && generation == _watchlistCandidateGeneration) {
|
||||
_watchlistCandidateResults[_watchlistItemKey(item)] = candidates;
|
||||
}
|
||||
return candidates;
|
||||
});
|
||||
}
|
||||
|
||||
/// The already-resolved candidates for [item], or null when never resolved
|
||||
/// this session. Menu surfaces label from this without awaiting; an empty
|
||||
/// list means no connected source can hold the item.
|
||||
List<WatchlistCandidate>? cachedWatchlistCandidatesFor(MediaItem item) =>
|
||||
_watchlistCandidateResults[_watchlistItemKey(item)];
|
||||
|
||||
void _invalidateWatchlistCandidates() {
|
||||
_watchlistCandidateGeneration++;
|
||||
_watchlistCandidateLoads.clear();
|
||||
_watchlistCandidateResults.clear();
|
||||
}
|
||||
|
||||
/// Hydrate the per-profile active-source preference and Plex session.
|
||||
Future<void> onActiveProfileChanged(String? userUuid) async {
|
||||
final generation = ++_profileBindingGeneration;
|
||||
@@ -196,6 +242,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
expectedProfileGeneration == _profileBindingGeneration &&
|
||||
sessionGeneration == _plexSessionGeneration &&
|
||||
_plex.update(null)) {
|
||||
_invalidateWatchlistCandidates();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
return;
|
||||
@@ -205,7 +252,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
sessionGeneration != _plexSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
if (_plex.update(session)) safeNotifyListeners();
|
||||
if (_plex.update(session)) {
|
||||
_invalidateWatchlistCandidates();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setActiveSource(CatalogSourceId id) async {
|
||||
@@ -225,7 +275,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
changed = _anilist.update(trackers.anilistCatalogClient) || changed;
|
||||
changed = _simkl.update(trackers.simklCatalogClient) || changed;
|
||||
changed = _seerr.update(seerr.catalogClient) || changed;
|
||||
if (changed) safeNotifyListeners();
|
||||
if (changed) {
|
||||
_invalidateWatchlistCandidates();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -207,7 +207,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// Membership reads each source's session snapshot — no per-open API
|
||||
// call. Filled when the item is on ANY source's watchlist; with several
|
||||
// candidates the press opens a source chooser.
|
||||
// Not in the compact tiers: it drops away first on narrow screens.
|
||||
// Not in the compact tiers: on narrow screens it drops away first and
|
||||
// stays reachable through the ⋮ menu's watchlist entry.
|
||||
final watchlistStates = [
|
||||
for (final candidate in _watchlistCandidates) candidate.source.isOnWatchlist(metadata.kind, candidate.ids),
|
||||
];
|
||||
@@ -343,24 +344,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// shows that source's current membership.
|
||||
final renderBox = _watchlistButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
final choice = await showAppMenu<WatchlistCandidate>(
|
||||
final choice = await showWatchlistSourceChooser(
|
||||
context,
|
||||
kind: metadata.kind,
|
||||
candidates: candidates,
|
||||
anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size,
|
||||
focusFirstItem: true,
|
||||
entries: [
|
||||
for (final candidate in candidates)
|
||||
AppMenuItem(
|
||||
value: candidate,
|
||||
leading: CatalogSourceLogo(candidate.source.id),
|
||||
label: candidate.source.displayName,
|
||||
subtitle: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false)
|
||||
? t.explore.removeFromWatchlist
|
||||
: t.explore.addToWatchlist,
|
||||
trailing: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false)
|
||||
? const AppIcon(Symbols.bookmark_added_rounded, fill: 1)
|
||||
: const AppIcon(Symbols.bookmark_add_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (choice == null || !mounted) return;
|
||||
await _toggleWatchlistOn(metadata, choice);
|
||||
@@ -373,11 +362,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
try {
|
||||
// Optimistic inside the source; the row/screens listening to
|
||||
// watchlistChanges (including this one) rebuild immediately.
|
||||
if (current) {
|
||||
await candidate.source.removeFromWatchlist(metadata.kind, candidate.ids);
|
||||
} else {
|
||||
await candidate.source.addToWatchlist(metadata.kind, candidate.ids);
|
||||
}
|
||||
await mutateWatchlistMembership(metadata.kind, candidate, add: !current);
|
||||
} catch (_) {
|
||||
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||
} finally {
|
||||
|
||||
@@ -58,12 +58,12 @@ import '../services/settings_service.dart';
|
||||
import '../services/watch_actions.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
import '../providers/catalog_sources_provider.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/offline_watch_provider.dart';
|
||||
import '../providers/watch_state_store.dart';
|
||||
import '../services/catalog/catalog_source.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/scroll_utils.dart';
|
||||
@@ -71,12 +71,11 @@ import '../utils/dialogs.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/catalog_source_logo.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../widgets/watchlist_source_chooser.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/placeholder_container.dart';
|
||||
@@ -118,10 +117,6 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
|
||||
|
||||
enum _SyncRuleAction { edit, remove, delete }
|
||||
|
||||
/// A watchlist-capable catalog source paired with this item's ids in that
|
||||
/// source's terms (see `_resolveWatchlistIds`).
|
||||
typedef WatchlistCandidate = ({CatalogSource source, CatalogItemIds ids});
|
||||
|
||||
class _SeasonEpisodePager {
|
||||
final Map<String, PagedMediaListState<MediaItem>> _states = {};
|
||||
final Set<String> _firstPageLoadsInFlight = {};
|
||||
@@ -685,37 +680,26 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// and for non-movie/show kinds.
|
||||
void _initWatchlistState() {
|
||||
if (widget.isOffline || (!_metadata.isMovie && !_metadata.isShow)) return;
|
||||
final sources = Provider.of<CatalogSourcesProvider?>(context, listen: false)?.watchlistCapableSources;
|
||||
if (sources == null || sources.isEmpty) return;
|
||||
final catalogSources = Provider.of<CatalogSourcesProvider?>(context, listen: false);
|
||||
final sources = catalogSources?.watchlistCapableSources ?? const <CatalogSource>[];
|
||||
if (catalogSources == null || sources.isEmpty) return;
|
||||
_watchlistListenedSources = sources;
|
||||
for (final source in sources) {
|
||||
source.watchlistChanges.addListener(_onWatchlistSourceChanged);
|
||||
unawaited(source.ensureWatchlistLoaded());
|
||||
}
|
||||
unawaited(_resolveWatchlistIds(sources));
|
||||
unawaited(_resolveWatchlistIds(catalogSources));
|
||||
}
|
||||
|
||||
Future<void> _resolveWatchlistIds(List<CatalogSource> sources) async {
|
||||
Future<void> _resolveWatchlistIds(CatalogSourcesProvider catalogSources) async {
|
||||
try {
|
||||
final ids = await _getMediaClientForMetadata(context)?.fetchExternalIds(_metadata.id);
|
||||
if (!mounted || ids == null || !ids.hasAny) return;
|
||||
// Sources can require their own id forms (MAL maps external ids to an
|
||||
// anime id via Fribb); null means the item is outside that source's
|
||||
// domain. The action shows for the sources that resolved; with more
|
||||
// than one, the toggle opens a source chooser.
|
||||
final candidates = <WatchlistCandidate>[];
|
||||
for (final source in sources) {
|
||||
try {
|
||||
final resolved = await source.resolveItemIds(_metadata.kind, ids);
|
||||
if (resolved != null) candidates.add((source: source, ids: resolved));
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.d(
|
||||
'Watchlist external-id resolution failed for ${source.id.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Session-cached on the provider and shared with the card context
|
||||
// menus; null means the item is outside a source's domain and the
|
||||
// action shows for the sources that resolved.
|
||||
final candidates = await catalogSources.watchlistCandidatesFor(
|
||||
_metadata,
|
||||
client: _getMediaClientForMetadata(context),
|
||||
);
|
||||
if (!mounted || candidates.isEmpty) return;
|
||||
setState(() => _watchlistCandidates = candidates);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import 'catalog_source.dart';
|
||||
|
||||
/// A watchlist-capable catalog source paired with a library item's ids in
|
||||
/// that source's terms (see [resolveWatchlistCandidates]).
|
||||
typedef WatchlistCandidate = ({CatalogSource source, CatalogItemIds ids});
|
||||
|
||||
/// Resolve which of [sources] can hold [item] on their watchlist, with the
|
||||
/// ids each source needs for membership/mutation.
|
||||
///
|
||||
/// The item's external ids come from its owning server ([client]); each
|
||||
/// source then maps them to its own id forms (MAL and AniList go through
|
||||
/// Fribb; Plex Discover matches server-side). A source that cannot hold the
|
||||
/// item (non-anime on MAL, no usable ids) is simply absent from the result.
|
||||
/// Per-source resolution failures are logged and skipped so one flaky
|
||||
/// provider does not hide the rest; a failed external-id fetch throws.
|
||||
Future<List<WatchlistCandidate>> resolveWatchlistCandidates({
|
||||
required MediaServerClient? client,
|
||||
required MediaItem item,
|
||||
required List<CatalogSource> sources,
|
||||
}) async {
|
||||
if (client == null || sources.isEmpty) return const [];
|
||||
final ids = await client.fetchExternalIds(item.id);
|
||||
if (!ids.hasAny) return const [];
|
||||
final candidates = <WatchlistCandidate>[];
|
||||
for (final source in sources) {
|
||||
try {
|
||||
final resolved = await source.resolveItemIds(item.kind, ids);
|
||||
if (resolved != null) candidates.add((source: source, ids: resolved));
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.d('Watchlist external-id resolution failed for ${source.id.name}', error: e, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// Watchlist mutations keyed by source+item so surfaces without a per-screen
|
||||
/// guard (context menus, which outlive their tap) can't double-fire while
|
||||
/// one is still in flight.
|
||||
final Set<String> _watchlistMutationsInFlight = {};
|
||||
|
||||
/// Add or remove [candidate]'s item on its source's watchlist.
|
||||
///
|
||||
/// Returns false when the identical mutation is already in flight (nothing
|
||||
/// ran). API failures propagate to the caller, which owns user feedback.
|
||||
Future<bool> mutateWatchlistMembership(MediaKind kind, WatchlistCandidate candidate, {required bool add}) async {
|
||||
final key = '${candidate.source.id.name}/${kind.id}/${candidate.ids.canonicalKey ?? ''}';
|
||||
if (!_watchlistMutationsInFlight.add(key)) return false;
|
||||
try {
|
||||
if (add) {
|
||||
await candidate.source.addToWatchlist(kind, candidate.ids);
|
||||
} else {
|
||||
await candidate.source.removeFromWatchlist(kind, candidate.ids);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
_watchlistMutationsInFlight.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -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