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
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personajlar", "characters": "Personajlar",
"addToWatchlist": "İzləmə siyahısına əlavə et", "addToWatchlist": "İzləmə siyahısına əlavə et",
"removeFromWatchlist": "İzləmə siyahısından sil", "removeFromWatchlist": "İzləmə siyahısından sil",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "İzləmə siyahısı yenilənə bilmədi", "watchlistUpdateFailed": "İzləmə siyahısı yenilənə bilmədi",
"watchlistNoMatch": "",
"notInLibrary": "Kitabxananızda yoxdur", "notInLibrary": "Kitabxananızda yoxdur",
"inTheseLibraries": "Bu kitabxanalarda var", "inTheseLibraries": "Bu kitabxanalarda var",
"checkingLibrary": "Kitabxananız yoxlanılır...", "checkingLibrary": "Kitabxananız yoxlanılır...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Герои", "characters": "Герои",
"addToWatchlist": "Добави в списъка за гледане", "addToWatchlist": "Добави в списъка за гледане",
"removeFromWatchlist": "Премахни от списъка за гледане", "removeFromWatchlist": "Премахни от списъка за гледане",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Неуспешно обновяване на списъка за гледане", "watchlistUpdateFailed": "Неуспешно обновяване на списъка за гледане",
"watchlistNoMatch": "",
"notInLibrary": "Не е в твоята библиотека", "notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки", "inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...", "checkingLibrary": "Проверка на твоята библиотека...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Figurer", "characters": "Figurer",
"addToWatchlist": "Føj til ønskeliste", "addToWatchlist": "Føj til ønskeliste",
"removeFromWatchlist": "Fjern fra ønskeliste", "removeFromWatchlist": "Fjern fra ønskeliste",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kunne ikke opdatere ønskelisten", "watchlistUpdateFailed": "Kunne ikke opdatere ønskelisten",
"watchlistNoMatch": "",
"notInLibrary": "Ikke i dit bibliotek", "notInLibrary": "Ikke i dit bibliotek",
"inTheseLibraries": "I disse biblioteker", "inTheseLibraries": "I disse biblioteker",
"checkingLibrary": "Tjekker dit bibliotek...", "checkingLibrary": "Tjekker dit bibliotek...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Charaktere", "characters": "Charaktere",
"addToWatchlist": "Zur Merkliste hinzufügen", "addToWatchlist": "Zur Merkliste hinzufügen",
"removeFromWatchlist": "Von Merkliste entfernen", "removeFromWatchlist": "Von Merkliste entfernen",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Merkliste konnte nicht aktualisiert werden", "watchlistUpdateFailed": "Merkliste konnte nicht aktualisiert werden",
"watchlistNoMatch": "",
"notInLibrary": "Nicht in deiner Mediathek", "notInLibrary": "Nicht in deiner Mediathek",
"inTheseLibraries": "In diesen Mediatheken", "inTheseLibraries": "In diesen Mediatheken",
"checkingLibrary": "Deine Mediathek wird überprüft …", "checkingLibrary": "Deine Mediathek wird überprüft …",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Characters", "characters": "Characters",
"addToWatchlist": "Add to Watchlist", "addToWatchlist": "Add to Watchlist",
"removeFromWatchlist": "Remove from Watchlist", "removeFromWatchlist": "Remove from Watchlist",
"addedToWatchlist": "Added to watchlist",
"removedFromWatchlist": "Removed from watchlist",
"watchlistUpdateFailed": "Couldn't update watchlist", "watchlistUpdateFailed": "Couldn't update watchlist",
"watchlistNoMatch": "Couldn't match this item to a watchlist",
"notInLibrary": "Not in your library", "notInLibrary": "Not in your library",
"inTheseLibraries": "In these libraries", "inTheseLibraries": "In these libraries",
"checkingLibrary": "Checking your library...", "checkingLibrary": "Checking your library...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personajes", "characters": "Personajes",
"addToWatchlist": "Añadir a la lista de seguimiento", "addToWatchlist": "Añadir a la lista de seguimiento",
"removeFromWatchlist": "Quitar de la lista de seguimiento", "removeFromWatchlist": "Quitar de la lista de seguimiento",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "No se pudo actualizar la lista de seguimiento", "watchlistUpdateFailed": "No se pudo actualizar la lista de seguimiento",
"watchlistNoMatch": "",
"notInLibrary": "No está en tu biblioteca", "notInLibrary": "No está en tu biblioteca",
"inTheseLibraries": "En estas bibliotecas", "inTheseLibraries": "En estas bibliotecas",
"checkingLibrary": "Comprobando tu biblioteca...", "checkingLibrary": "Comprobando tu biblioteca...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personnages", "characters": "Personnages",
"addToWatchlist": "Ajouter à la liste de suivi", "addToWatchlist": "Ajouter à la liste de suivi",
"removeFromWatchlist": "Retirer de la liste de suivi", "removeFromWatchlist": "Retirer de la liste de suivi",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Impossible de mettre à jour la liste de suivi", "watchlistUpdateFailed": "Impossible de mettre à jour la liste de suivi",
"watchlistNoMatch": "",
"notInLibrary": "Absent de votre bibliothèque", "notInLibrary": "Absent de votre bibliothèque",
"inTheseLibraries": "Dans ces bibliothèques", "inTheseLibraries": "Dans ces bibliothèques",
"checkingLibrary": "Vérification de votre bibliothèque...", "checkingLibrary": "Vérification de votre bibliothèque...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Karakterek", "characters": "Karakterek",
"addToWatchlist": "Hozzáadás a Néznivalókhoz", "addToWatchlist": "Hozzáadás a Néznivalókhoz",
"removeFromWatchlist": "Eltávolítás a Néznivalókból", "removeFromWatchlist": "Eltávolítás a Néznivalókból",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Nem sikerült a Néznivalók frissítése", "watchlistUpdateFailed": "Nem sikerült a Néznivalók frissítése",
"watchlistNoMatch": "",
"notInLibrary": "Nincs a könyvtáradban", "notInLibrary": "Nincs a könyvtáradban",
"inTheseLibraries": "Ezekben a könyvtárakban", "inTheseLibraries": "Ezekben a könyvtárakban",
"checkingLibrary": "Könyvtár ellenőrzése...", "checkingLibrary": "Könyvtár ellenőrzése...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personaggi", "characters": "Personaggi",
"addToWatchlist": "Aggiungi alla lista da guardare", "addToWatchlist": "Aggiungi alla lista da guardare",
"removeFromWatchlist": "Rimuovi dalla lista da guardare", "removeFromWatchlist": "Rimuovi dalla lista da guardare",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Impossibile aggiornare la lista da guardare", "watchlistUpdateFailed": "Impossibile aggiornare la lista da guardare",
"watchlistNoMatch": "",
"notInLibrary": "Non è nella tua libreria", "notInLibrary": "Non è nella tua libreria",
"inTheseLibraries": "In queste librerie", "inTheseLibraries": "In queste librerie",
"checkingLibrary": "Ricerca nella tua libreria...", "checkingLibrary": "Ricerca nella tua libreria...",
+3
View File
@@ -1075,7 +1075,10 @@
"characters": "キャラクター", "characters": "キャラクター",
"addToWatchlist": "ウォッチリストに追加", "addToWatchlist": "ウォッチリストに追加",
"removeFromWatchlist": "ウォッチリストから削除", "removeFromWatchlist": "ウォッチリストから削除",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "ウォッチリストを更新できませんでした", "watchlistUpdateFailed": "ウォッチリストを更新できませんでした",
"watchlistNoMatch": "",
"notInLibrary": "ライブラリにありません", "notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります", "inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…", "checkingLibrary": "ライブラリを確認中…",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Кейіпкерлер", "characters": "Кейіпкерлер",
"addToWatchlist": "Көру тізіміне қосу", "addToWatchlist": "Көру тізіміне қосу",
"removeFromWatchlist": "Көру тізімінен өшіру", "removeFromWatchlist": "Көру тізімінен өшіру",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Көру тізімін жаңарту мүмкін болмады", "watchlistUpdateFailed": "Көру тізімін жаңарту мүмкін болмады",
"watchlistNoMatch": "",
"notInLibrary": "Кітапханаңызда жоқ", "notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар", "inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...", "checkingLibrary": "Кітапхана тексерілуде...",
+3
View File
@@ -1075,7 +1075,10 @@
"characters": "캐릭터", "characters": "캐릭터",
"addToWatchlist": "관심 목록에 추가", "addToWatchlist": "관심 목록에 추가",
"removeFromWatchlist": "관심 목록에서 제거", "removeFromWatchlist": "관심 목록에서 제거",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "관심 목록을 업데이트하지 못했습니다", "watchlistUpdateFailed": "관심 목록을 업데이트하지 못했습니다",
"watchlistNoMatch": "",
"notInLibrary": "라이브러리에 없음", "notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음", "inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...", "checkingLibrary": "라이브러리 확인 중...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Figurer", "characters": "Figurer",
"addToWatchlist": "Legg til i ønskeliste", "addToWatchlist": "Legg til i ønskeliste",
"removeFromWatchlist": "Fjern fra ønskeliste", "removeFromWatchlist": "Fjern fra ønskeliste",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kunne ikke oppdatere ønskelisten", "watchlistUpdateFailed": "Kunne ikke oppdatere ønskelisten",
"watchlistNoMatch": "",
"notInLibrary": "Ikke i biblioteket ditt", "notInLibrary": "Ikke i biblioteket ditt",
"inTheseLibraries": "I disse bibliotekene", "inTheseLibraries": "I disse bibliotekene",
"checkingLibrary": "Sjekker biblioteket ditt...", "checkingLibrary": "Sjekker biblioteket ditt...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personages", "characters": "Personages",
"addToWatchlist": "Toevoegen aan kijklijst", "addToWatchlist": "Toevoegen aan kijklijst",
"removeFromWatchlist": "Verwijderen uit kijklijst", "removeFromWatchlist": "Verwijderen uit kijklijst",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kon kijklijst niet bijwerken", "watchlistUpdateFailed": "Kon kijklijst niet bijwerken",
"watchlistNoMatch": "",
"notInLibrary": "Niet in je bibliotheek", "notInLibrary": "Niet in je bibliotheek",
"inTheseLibraries": "In deze bibliotheken", "inTheseLibraries": "In deze bibliotheken",
"checkingLibrary": "Je bibliotheek controleren...", "checkingLibrary": "Je bibliotheek controleren...",
+3
View File
@@ -1090,7 +1090,10 @@
"characters": "Postacie", "characters": "Postacie",
"addToWatchlist": "Dodaj do listy do obejrzenia", "addToWatchlist": "Dodaj do listy do obejrzenia",
"removeFromWatchlist": "Usuń z listy do obejrzenia", "removeFromWatchlist": "Usuń z listy do obejrzenia",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Nie udało się zaktualizować listy do obejrzenia", "watchlistUpdateFailed": "Nie udało się zaktualizować listy do obejrzenia",
"watchlistNoMatch": "",
"notInLibrary": "Nie ma tego w Twojej bibliotece", "notInLibrary": "Nie ma tego w Twojej bibliotece",
"inTheseLibraries": "W tych bibliotekach", "inTheseLibraries": "W tych bibliotekach",
"checkingLibrary": "Sprawdzanie Twojej biblioteki...", "checkingLibrary": "Sprawdzanie Twojej biblioteki...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Personagens", "characters": "Personagens",
"addToWatchlist": "Adicionar à lista para assistir", "addToWatchlist": "Adicionar à lista para assistir",
"removeFromWatchlist": "Remover da lista para assistir", "removeFromWatchlist": "Remover da lista para assistir",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Não foi possível atualizar a lista para assistir", "watchlistUpdateFailed": "Não foi possível atualizar a lista para assistir",
"watchlistNoMatch": "",
"notInLibrary": "Não está na sua biblioteca", "notInLibrary": "Não está na sua biblioteca",
"inTheseLibraries": "Nestas bibliotecas", "inTheseLibraries": "Nestas bibliotecas",
"checkingLibrary": "Verificando sua biblioteca...", "checkingLibrary": "Verificando sua biblioteca...",
+3
View File
@@ -1090,7 +1090,10 @@
"characters": "Персонажи", "characters": "Персонажи",
"addToWatchlist": "Добавить в список для просмотра", "addToWatchlist": "Добавить в список для просмотра",
"removeFromWatchlist": "Удалить из списка для просмотра", "removeFromWatchlist": "Удалить из списка для просмотра",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Не удалось обновить список для просмотра", "watchlistUpdateFailed": "Не удалось обновить список для просмотра",
"watchlistNoMatch": "",
"notInLibrary": "Нет в вашей библиотеке", "notInLibrary": "Нет в вашей библиотеке",
"inTheseLibraries": "В этих библиотеках", "inTheseLibraries": "В этих библиотеках",
"checkingLibrary": "Проверка вашей библиотеки...", "checkingLibrary": "Проверка вашей библиотеки...",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 22 /// Locales: 22
/// Strings: 38356 (1743 per locale) /// Strings: 38359 (1743 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+16 -4
View File
@@ -3054,9 +3054,18 @@ class Translations$explore$en {
/// en: 'Remove from Watchlist' /// en: 'Remove from Watchlist'
String get removeFromWatchlist => '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' /// en: 'Couldn't update watchlist'
String get watchlistUpdateFailed => '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' /// en: 'Not in your library'
String get notInLibrary => 'Not in your library'; String get notInLibrary => 'Not in your library';
@@ -7065,7 +7074,10 @@ extension on Translations {
'explore.characters' => 'Characters', 'explore.characters' => 'Characters',
'explore.addToWatchlist' => 'Add to Watchlist', 'explore.addToWatchlist' => 'Add to Watchlist',
'explore.removeFromWatchlist' => 'Remove from Watchlist', 'explore.removeFromWatchlist' => 'Remove from Watchlist',
'explore.addedToWatchlist' => 'Added to watchlist',
'explore.removedFromWatchlist' => 'Removed from watchlist',
'explore.watchlistUpdateFailed' => 'Couldn\'t update watchlist', 'explore.watchlistUpdateFailed' => 'Couldn\'t update watchlist',
'explore.watchlistNoMatch' => 'Couldn\'t match this item to a watchlist',
'explore.notInLibrary' => 'Not in your library', 'explore.notInLibrary' => 'Not in your library',
'explore.inTheseLibraries' => 'In these libraries', 'explore.inTheseLibraries' => 'In these libraries',
'explore.checkingLibrary' => 'Checking your library...', 'explore.checkingLibrary' => 'Checking your library...',
@@ -7102,11 +7114,11 @@ extension on Translations {
'explore.stats.viewersWeek' => ({required Object n}) => '${n} watched this week', 'explore.stats.viewersWeek' => ({required Object n}) => '${n} watched this week',
'explore.stats.viewersMonth' => ({required Object n}) => '${n} watched this month', 'explore.stats.viewersMonth' => ({required Object n}) => '${n} watched this month',
'explore.stats.viewersYear' => ({required Object n}) => '${n} watched this year', 'explore.stats.viewersYear' => ({required Object n}) => '${n} watched this year',
_ => null,
} ?? switch (path) {
'explore.stats.viewersAllTime' => ({required Object n}) => '${n} viewers', 'explore.stats.viewersAllTime' => ({required Object n}) => '${n} viewers',
'explore.stats.planning' => ({required Object n}) => '${n} planning to watch', 'explore.stats.planning' => ({required Object n}) => '${n} planning to watch',
'explore.stats.favorited' => ({required Object n}) => '${n} favorites', 'explore.stats.favorited' => ({required Object n}) => '${n} favorites',
_ => null,
} ?? switch (path) {
'explore.stats.dropRate' => ({required Object percent}) => '${percent} dropped it', '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.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', 'explore.stats.votes' => ({required Object n}) => '${n} votes',
@@ -7616,11 +7628,11 @@ extension on Translations {
'performanceOverlay.player' => 'Player', 'performanceOverlay.player' => 'Player',
'performanceOverlay.memory' => 'Memory', 'performanceOverlay.memory' => 'Memory',
'performanceOverlay.uiFps' => 'UI FPS', 'performanceOverlay.uiFps' => 'UI FPS',
_ => null,
} ?? switch (path) {
'externalPlayer.title' => 'External Player', 'externalPlayer.title' => 'External Player',
'externalPlayer.useExternalPlayer' => 'Use External Player', 'externalPlayer.useExternalPlayer' => 'Use External Player',
'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app', 'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app',
_ => null,
} ?? switch (path) {
'externalPlayer.selectPlayer' => 'Select Player', 'externalPlayer.selectPlayer' => 'Select Player',
'externalPlayer.customPlayers' => 'Custom Players', 'externalPlayer.customPlayers' => 'Custom Players',
'externalPlayer.systemDefault' => 'System Default', 'externalPlayer.systemDefault' => 'System Default',
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Karaktärer", "characters": "Karaktärer",
"addToWatchlist": "Lägg till i bevakningslista", "addToWatchlist": "Lägg till i bevakningslista",
"removeFromWatchlist": "Ta bort från bevakningslista", "removeFromWatchlist": "Ta bort från bevakningslista",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Det gick inte att uppdatera bevakningslistan", "watchlistUpdateFailed": "Det gick inte att uppdatera bevakningslistan",
"watchlistNoMatch": "",
"notInLibrary": "Finns inte i ditt bibliotek", "notInLibrary": "Finns inte i ditt bibliotek",
"inTheseLibraries": "I dessa bibliotek", "inTheseLibraries": "I dessa bibliotek",
"checkingLibrary": "Kontrollerar ditt bibliotek...", "checkingLibrary": "Kontrollerar ditt bibliotek...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Karakterler", "characters": "Karakterler",
"addToWatchlist": "İzleme Listesine Ekle", "addToWatchlist": "İzleme Listesine Ekle",
"removeFromWatchlist": "İzleme Listesinden Kaldır", "removeFromWatchlist": "İzleme Listesinden Kaldır",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "İzleme listesi güncellenemedi", "watchlistUpdateFailed": "İzleme listesi güncellenemedi",
"watchlistNoMatch": "",
"notInLibrary": "Kitaplığınızda yok", "notInLibrary": "Kitaplığınızda yok",
"inTheseLibraries": "Bu kitaplıklarda var", "inTheseLibraries": "Bu kitaplıklarda var",
"checkingLibrary": "Kitaplığınız kontrol ediliyor...", "checkingLibrary": "Kitaplığınız kontrol ediliyor...",
+3
View File
@@ -1080,7 +1080,10 @@
"characters": "Qahramonlar", "characters": "Qahramonlar",
"addToWatchlist": "Tomosha roʻyxatiga qoʻshish", "addToWatchlist": "Tomosha roʻyxatiga qoʻshish",
"removeFromWatchlist": "Tomosha roʻyxatidan oʻchirish", "removeFromWatchlist": "Tomosha roʻyxatidan oʻchirish",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Tomosha roʻyxatini yangilab boʻlmadi", "watchlistUpdateFailed": "Tomosha roʻyxatini yangilab boʻlmadi",
"watchlistNoMatch": "",
"notInLibrary": "Kutubxonangizda yoʻq", "notInLibrary": "Kutubxonangizda yoʻq",
"inTheseLibraries": "Ushbu kutubxonalarda bor", "inTheseLibraries": "Ushbu kutubxonalarda bor",
"checkingLibrary": "Kutubxona tekshirilmoqda...", "checkingLibrary": "Kutubxona tekshirilmoqda...",
+3
View File
@@ -1075,7 +1075,10 @@
"characters": "角色", "characters": "角色",
"addToWatchlist": "新增至待看清單", "addToWatchlist": "新增至待看清單",
"removeFromWatchlist": "從待看清單移除", "removeFromWatchlist": "從待看清單移除",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "無法更新待看清單", "watchlistUpdateFailed": "無法更新待看清單",
"watchlistNoMatch": "",
"notInLibrary": "不在您的媒體庫中", "notInLibrary": "不在您的媒體庫中",
"inTheseLibraries": "在這些媒體庫中", "inTheseLibraries": "在這些媒體庫中",
"checkingLibrary": "正在檢查您的媒體庫…", "checkingLibrary": "正在檢查您的媒體庫…",
+3
View File
@@ -1075,7 +1075,10 @@
"characters": "角色", "characters": "角色",
"addToWatchlist": "添加到想看列表", "addToWatchlist": "添加到想看列表",
"removeFromWatchlist": "从想看列表移除", "removeFromWatchlist": "从想看列表移除",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "无法更新想看列表", "watchlistUpdateFailed": "无法更新想看列表",
"watchlistNoMatch": "",
"notInLibrary": "不在你的媒体库中", "notInLibrary": "不在你的媒体库中",
"inTheseLibraries": "在这些媒体库中", "inTheseLibraries": "在这些媒体库中",
"checkingLibrary": "正在检查你的媒体库…", "checkingLibrary": "正在检查你的媒体库…",
+55 -2
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../connection/connection_registry.dart'; import '../connection/connection_registry.dart';
import '../mixins/disposable_change_notifier_mixin.dart'; import '../mixins/disposable_change_notifier_mixin.dart';
import '../models/catalog/catalog_item.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_connection_registry.dart';
import '../profiles/profile.dart'; import '../profiles/profile.dart';
import '../services/base_shared_preferences_service.dart'; import '../services/base_shared_preferences_service.dart';
import '../services/catalog/library_watchlist_candidates.dart';
import '../services/catalog/catalog_source.dart'; import '../services/catalog/catalog_source.dart';
import '../services/catalog/anilist_catalog_source.dart'; import '../services/catalog/anilist_catalog_source.dart';
import '../services/catalog/mal_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/seerr_catalog_source.dart';
import '../services/catalog/simkl_catalog_source.dart'; import '../services/catalog/simkl_catalog_source.dart';
import '../services/catalog/trakt_catalog_source.dart'; import '../services/catalog/trakt_catalog_source.dart';
import '../services/trackers/future_coalescer.dart';
import '../services/plex_discover_client.dart'; import '../services/plex_discover_client.dart';
import '../services/seerr/seerr_client.dart'; import '../services/seerr/seerr_client.dart';
import '../services/trackers/anilist/anilist_client.dart'; import '../services/trackers/anilist/anilist_client.dart';
@@ -115,6 +119,14 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
CatalogSourceId? _preferredSourceId; CatalogSourceId? _preferredSourceId;
String _activeUserUuid = ''; 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 => [ List<CatalogSource> get connectedSources => [
?_trakt.source, ?_trakt.source,
?_mal.source, ?_mal.source,
@@ -160,6 +172,40 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
return watchlistCapableSource; 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. /// Hydrate the per-profile active-source preference and Plex session.
Future<void> onActiveProfileChanged(String? userUuid) async { Future<void> onActiveProfileChanged(String? userUuid) async {
final generation = ++_profileBindingGeneration; final generation = ++_profileBindingGeneration;
@@ -196,6 +242,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
expectedProfileGeneration == _profileBindingGeneration && expectedProfileGeneration == _profileBindingGeneration &&
sessionGeneration == _plexSessionGeneration && sessionGeneration == _plexSessionGeneration &&
_plex.update(null)) { _plex.update(null)) {
_invalidateWatchlistCandidates();
safeNotifyListeners(); safeNotifyListeners();
} }
return; return;
@@ -205,7 +252,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
sessionGeneration != _plexSessionGeneration) { sessionGeneration != _plexSessionGeneration) {
return; return;
} }
if (_plex.update(session)) safeNotifyListeners(); if (_plex.update(session)) {
_invalidateWatchlistCandidates();
safeNotifyListeners();
}
} }
Future<void> setActiveSource(CatalogSourceId id) async { Future<void> setActiveSource(CatalogSourceId id) async {
@@ -225,7 +275,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
changed = _anilist.update(trackers.anilistCatalogClient) || changed; changed = _anilist.update(trackers.anilistCatalogClient) || changed;
changed = _simkl.update(trackers.simklCatalogClient) || changed; changed = _simkl.update(trackers.simklCatalogClient) || changed;
changed = _seerr.update(seerr.catalogClient) || changed; changed = _seerr.update(seerr.catalogClient) || changed;
if (changed) safeNotifyListeners(); if (changed) {
_invalidateWatchlistCandidates();
safeNotifyListeners();
}
} }
@override @override
+6 -21
View File
@@ -207,7 +207,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// Membership reads each source's session snapshot — no per-open API // Membership reads each source's session snapshot — no per-open API
// call. Filled when the item is on ANY source's watchlist; with several // call. Filled when the item is on ANY source's watchlist; with several
// candidates the press opens a source chooser. // 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 = [ final watchlistStates = [
for (final candidate in _watchlistCandidates) candidate.source.isOnWatchlist(metadata.kind, candidate.ids), 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. // shows that source's current membership.
final renderBox = _watchlistButtonKey.currentContext?.findRenderObject() as RenderBox?; final renderBox = _watchlistButtonKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return; if (renderBox == null) return;
final choice = await showAppMenu<WatchlistCandidate>( final choice = await showWatchlistSourceChooser(
context, context,
kind: metadata.kind,
candidates: candidates,
anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size, anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size,
focusFirstItem: true, 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; if (choice == null || !mounted) return;
await _toggleWatchlistOn(metadata, choice); await _toggleWatchlistOn(metadata, choice);
@@ -373,11 +362,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
try { try {
// Optimistic inside the source; the row/screens listening to // Optimistic inside the source; the row/screens listening to
// watchlistChanges (including this one) rebuild immediately. // watchlistChanges (including this one) rebuild immediately.
if (current) { await mutateWatchlistMembership(metadata.kind, candidate, add: !current);
await candidate.source.removeFromWatchlist(metadata.kind, candidate.ids);
} else {
await candidate.source.addToWatchlist(metadata.kind, candidate.ids);
}
} catch (_) { } catch (_) {
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
} finally { } finally {
+13 -29
View File
@@ -58,12 +58,12 @@ import '../services/settings_service.dart';
import '../services/watch_actions.dart'; import '../services/watch_actions.dart';
import '../widgets/settings_builder.dart'; import '../widgets/settings_builder.dart';
import '../utils/layout_constants.dart'; import '../utils/layout_constants.dart';
import '../models/catalog/catalog_item.dart';
import '../providers/catalog_sources_provider.dart'; import '../providers/catalog_sources_provider.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../providers/offline_watch_provider.dart'; import '../providers/offline_watch_provider.dart';
import '../providers/watch_state_store.dart'; import '../providers/watch_state_store.dart';
import '../services/catalog/catalog_source.dart'; import '../services/catalog/catalog_source.dart';
import '../services/catalog/library_watchlist_candidates.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import '../utils/scroll_utils.dart'; import '../utils/scroll_utils.dart';
@@ -71,12 +71,11 @@ import '../utils/dialogs.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import '../widgets/app_bar_back_button.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 '../widgets/desktop_app_bar.dart';
import '../utils/desktop_window_padding.dart'; import '../utils/desktop_window_padding.dart';
import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/horizontal_scroll_with_arrows.dart';
import '../widgets/media_context_menu.dart'; import '../widgets/media_context_menu.dart';
import '../widgets/watchlist_source_chooser.dart';
import 'libraries/state_messages.dart'; import 'libraries/state_messages.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../widgets/placeholder_container.dart'; import '../widgets/placeholder_container.dart';
@@ -118,10 +117,6 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
enum _SyncRuleAction { edit, remove, delete } 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 { class _SeasonEpisodePager {
final Map<String, PagedMediaListState<MediaItem>> _states = {}; final Map<String, PagedMediaListState<MediaItem>> _states = {};
final Set<String> _firstPageLoadsInFlight = {}; final Set<String> _firstPageLoadsInFlight = {};
@@ -685,37 +680,26 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// and for non-movie/show kinds. /// and for non-movie/show kinds.
void _initWatchlistState() { void _initWatchlistState() {
if (widget.isOffline || (!_metadata.isMovie && !_metadata.isShow)) return; if (widget.isOffline || (!_metadata.isMovie && !_metadata.isShow)) return;
final sources = Provider.of<CatalogSourcesProvider?>(context, listen: false)?.watchlistCapableSources; final catalogSources = Provider.of<CatalogSourcesProvider?>(context, listen: false);
if (sources == null || sources.isEmpty) return; final sources = catalogSources?.watchlistCapableSources ?? const <CatalogSource>[];
if (catalogSources == null || sources.isEmpty) return;
_watchlistListenedSources = sources; _watchlistListenedSources = sources;
for (final source in sources) { for (final source in sources) {
source.watchlistChanges.addListener(_onWatchlistSourceChanged); source.watchlistChanges.addListener(_onWatchlistSourceChanged);
unawaited(source.ensureWatchlistLoaded()); unawaited(source.ensureWatchlistLoaded());
} }
unawaited(_resolveWatchlistIds(sources)); unawaited(_resolveWatchlistIds(catalogSources));
} }
Future<void> _resolveWatchlistIds(List<CatalogSource> sources) async { Future<void> _resolveWatchlistIds(CatalogSourcesProvider catalogSources) async {
try { try {
final ids = await _getMediaClientForMetadata(context)?.fetchExternalIds(_metadata.id); // Session-cached on the provider and shared with the card context
if (!mounted || ids == null || !ids.hasAny) return; // menus; null means the item is outside a source's domain and the
// Sources can require their own id forms (MAL maps external ids to an // action shows for the sources that resolved.
// anime id via Fribb); null means the item is outside that source's final candidates = await catalogSources.watchlistCandidatesFor(
// domain. The action shows for the sources that resolved; with more _metadata,
// than one, the toggle opens a source chooser. client: _getMediaClientForMetadata(context),
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,
); );
}
}
if (!mounted || candidates.isEmpty) return; if (!mounted || candidates.isEmpty) return;
setState(() => _watchlistCandidates = candidates); setState(() => _watchlistCandidates = candidates);
} catch (e) { } 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);
}
}
+4 -14
View File
@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../models/catalog/catalog_item.dart'; import '../models/catalog/catalog_item.dart';
import '../providers/catalog_sources_provider.dart'; import '../providers/catalog_sources_provider.dart';
import '../services/catalog/library_watchlist_candidates.dart';
import '../utils/catalog_navigation_helper.dart'; import '../utils/catalog_navigation_helper.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
@@ -25,11 +26,6 @@ class _CatalogMenuAction {
static const toggleWatchlist = _CatalogMenuAction(_CatalogMenuActionType.toggleWatchlist); 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 /// Context menu for catalog stand-in cards (Explore tab). Replaces
/// [MediaContextMenu], whose entries are all server-backed and would break on /// [MediaContextMenu], whose entries are all server-backed and would break on
/// items with no server id. /// 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 // Re-read membership: it can have changed while the menu was open
// (snapshot load, another surface's toggle). // (snapshot load, another surface's toggle).
final current = source!.isOnWatchlist(item.kind, item.ids) ?? onWatchlist ?? false; 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 { try {
if (current) { // The shared guard keys by source+item so a re-opened menu can't
await source.removeFromWatchlist(item.kind, item.ids); // double-fire while one mutation is still in flight.
} else { await mutateWatchlistMembership(item.kind, (source: source, ids: item.ids), add: !current);
await source.addToWatchlist(item.kind, item.ids);
}
} catch (_) { } catch (_) {
if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
} finally {
_watchlistMutationsInFlight.remove(mutationKey);
} }
case _CatalogMenuActionType.openUrl: case _CatalogMenuActionType.openUrl:
final url = action.url; final url = action.url;
+118
View File
@@ -23,6 +23,7 @@ import '../services/music/music_playback_service.dart';
import '../services/offline_watch_sync_service.dart'; import '../services/offline_watch_sync_service.dart';
import '../services/playlist_items_loader.dart'; import '../services/playlist_items_loader.dart';
import '../services/watch_actions.dart'; import '../services/watch_actions.dart';
import '../services/catalog/library_watchlist_candidates.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../utils/delete_impact.dart'; import '../utils/delete_impact.dart';
@@ -34,6 +35,7 @@ import '../utils/global_key_utils.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../providers/offline_mode_provider.dart'; import '../providers/offline_mode_provider.dart';
import '../providers/catalog_sources_provider.dart';
import '../profiles/active_profile_provider.dart'; import '../profiles/active_profile_provider.dart';
import '../profiles/profile.dart'; import '../profiles/profile.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
@@ -62,6 +64,7 @@ import '../widgets/file_info_bottom_sheet.dart';
import 'pill_input_decoration.dart'; import 'pill_input_decoration.dart';
import '../widgets/focusable_list_tile.dart'; import '../widgets/focusable_list_tile.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import 'watchlist_source_chooser.dart';
import '../widgets/rating_bottom_sheet.dart'; import '../widgets/rating_bottom_sheet.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
@@ -315,6 +318,40 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind); 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 // Deletion is the one gate that asks the server per item; see
// [isMediaDeletionAllowed]. Only kinds that can actually be deleted pay // [isMediaDeletionAllowed]. Only kinds that can actually be deleted pay
// for the round trip, and only on a backend that answers it. // 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 — // Add to... (for episodes, movies, shows, and seasons). Plex-only —
// uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The // uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The
// MediaBrowser item-add APIs are different and not wired here yet. // MediaBrowser item-add APIs are different and not wired here yet.
@@ -833,6 +880,18 @@ class MediaContextMenuState extends State<MediaContextMenu> {
await _showAddToSubmenu(context); await _showAddToSubmenu(context);
break; break;
case 'toggle_watchlist':
await _handleWatchlistToggle(
context,
mediaItem!,
catalogSources!,
mediaClient,
removeOffered: watchlistRemoveOffered,
position: position,
openedFromKeyboard: openedFromKeyboard,
);
break;
case 'shuffle_play': case 'shuffle_play':
await _handleShufflePlayWithQueue(context); await _handleShufflePlayWithQueue(context);
break; 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 /// Execute an action with error handling and refresh
Future<void> _executeAction(BuildContext context, Future<void> Function() action, String successMessage) async { Future<void> _executeAction(BuildContext context, Future<void> Function() action, String successMessage) async {
try { try {
+43
View File
@@ -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),
),
],
);
}
@@ -1,8 +1,15 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; 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/models/catalog/catalog_item.dart';
import 'package:plezy/providers/catalog_sources_provider.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/services/plex_discover_client.dart';
import 'package:plezy/utils/external_ids.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
void main() { void main() {
@@ -78,4 +85,132 @@ void main() {
expect(calls, 3); expect(calls, 3);
expect(provider.connectedSources, isEmpty); 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));
});
});
}
+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/metadata_edit/metadata_edit_adapters.dart';
import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/models/plex/plex_config.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/profile.dart';
import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/providers/download_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/multi_server_provider.dart';
import 'package:plezy/providers/offline_mode_provider.dart'; import 'package:plezy/providers/offline_mode_provider.dart';
import 'package:plezy/providers/playback_state_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/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.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/deletion_notifier.dart';
import 'package:plezy/utils/external_ids.dart';
import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/media_server_timeouts.dart'; import 'package:plezy/utils/media_server_timeouts.dart';
@@ -1195,6 +1199,111 @@ void main() {
expect(harness.client.fileInfoRequests, isEmpty); 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( Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
@@ -1761,3 +1870,141 @@ JellyfinConnection _jellyfinConnection() {
createdAt: DateTime.fromMillisecondsSinceEpoch(0), 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);
}