From 3dd5b5d48eabcc64d6fd296bc296dbfd93091ce0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:21:11 +0200 Subject: [PATCH] fix(details): invalidate deleted items and pending actions Deleting a detail item or its ancestor could leave stale controls visible and allow pending actions to complete. Make exact and complete bulk deletion terminal for the detail subtree, invalidate asynchronous continuations, and retain a localized unavailable state when the first route cannot pop. Normalize translation sources and regenerate the affected output. --- lib/i18n/az.i18n.json | 8 +- lib/i18n/bg.i18n.json | 8 +- lib/i18n/da.i18n.json | 8 +- lib/i18n/de.i18n.json | 8 +- lib/i18n/en.i18n.json | 4 +- lib/i18n/es.i18n.json | 8 +- lib/i18n/fr.i18n.json | 8 +- lib/i18n/hu.i18n.json | 8 +- lib/i18n/it.i18n.json | 8 +- lib/i18n/ja.i18n.json | 8 +- lib/i18n/kk.i18n.json | 8 +- lib/i18n/ko.i18n.json | 8 +- lib/i18n/nb.i18n.json | 8 +- lib/i18n/nl.i18n.json | 8 +- lib/i18n/pl.i18n.json | 8 +- lib/i18n/pt.i18n.json | 8 +- lib/i18n/ru.i18n.json | 8 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 14 +- lib/i18n/sv.i18n.json | 8 +- lib/i18n/tr.i18n.json | 8 +- lib/i18n/uz.i18n.json | 8 +- lib/i18n/zh-Hant.i18n.json | 8 +- lib/i18n/zh.i18n.json | 8 +- lib/screens/media_detail/action_buttons.dart | 68 ++- .../media_detail/playback_tracks_status.dart | 7 +- lib/screens/media_detail_screen.dart | 165 +++++-- test/screens/media_detail_screen_test.dart | 445 +++++++++++++++++- 28 files changed, 753 insertions(+), 120 deletions(-) diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 88f947a6a..799e1d8b9 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.", "playbackCancelled": "Oynatma ləğv edildi.", "playbackFailed": "Oynatma başladılarkən xəta.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fayl məlumatı yüklənərkən xəta: ${error}", "errorLoadingSeries": "Serial yüklənərkən xəta", "musicNotSupported": "Musiqi oynatması hələ dəstəklənmir", @@ -1660,7 +1661,8 @@ "timedOut": "Rele serveri vaxtında cavab vermədi", "connectionLost": "Bağlantı seans hazır olmamış kəsildi", "invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi", - "sessionEnded": "Təşkilatçı seansı bitirdi" + "sessionEnded": "Təşkilatçı seansı bitirdi", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr sessiya kukisi yaratmadı", "freshCookieRejected": "Seerr yeni sessiya kukisini rədd etdi", "noUserInformation": "Seerr istifadəçi məlumatlarını qaytarmadı", - "sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi" + "sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Xidmətlər", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 37971ecdb..111828074 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.", "playbackCancelled": "Възпроизвеждането беше отменено.", "playbackFailed": "Възпроизвеждането не можа да бъде стартирано.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}", "errorLoadingSeries": "Грешка при зареждане на сериала", "musicNotSupported": "Възпроизвеждането на музика все още не се поддържа", @@ -1660,7 +1661,8 @@ "timedOut": "Релейният сървър не отговори навреме", "connectionLost": "Връзката се затвори, преди сесията да е готова", "invalidRelayResponse": "Релейният сървър изпрати неочакван отговор", - "sessionEnded": "Организаторът прекрати сесията" + "sessionEnded": "Организаторът прекрати сесията", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr не издаде бисквитка за сесията", "freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията", "noUserInformation": "Seerr не върна информация за потребителя", - "sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход" + "sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Услуги", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index a87e2aa8c..9026e9ea6 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.", "playbackCancelled": "Afspilningen blev annulleret.", "playbackFailed": "Afspilningen kunne ikke startes.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}", "errorLoadingSeries": "Fejl ved indlæsning af serie", "musicNotSupported": "Musikafspilning understøttes endnu ikke", @@ -1660,7 +1661,8 @@ "timedOut": "Relayserveren svarede ikke i tide", "connectionLost": "Forbindelsen blev lukket, før sessionen var klar", "invalidRelayResponse": "Relayserveren sendte et uventet svar", - "sessionEnded": "Værten afsluttede sessionen" + "sessionEnded": "Værten afsluttede sessionen", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr udstedte ikke en sessionscookie", "freshCookieRejected": "Seerr afviste den nye sessionscookie", "noUserInformation": "Seerr returnerede ikke brugeroplysninger", - "sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen" + "sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Tjenester", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 36910fd2c..d52dc45bd 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.", "playbackCancelled": "Die Wiedergabe wurde abgebrochen.", "playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}", "errorLoadingSeries": "Fehler beim Laden der Serie", "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", @@ -1660,7 +1661,8 @@ "timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet", "connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war", "invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet", - "sessionEnded": "Der Host hat die Sitzung beendet" + "sessionEnded": "Der Host hat die Sitzung beendet", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr hat kein Sitzungscookie ausgestellt", "freshCookieRejected": "Seerr hat das neue Sitzungscookie abgelehnt", "noUserInformation": "Seerr hat keine Benutzerinformationen zurückgegeben", - "sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt" + "sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Dienste", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 277925c11..4e69d65f9 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "The server returned invalid playback information.", "playbackCancelled": "Playback was canceled.", "playbackFailed": "Playback could not be started.", + "mediaUnavailable": "This content is no longer available.", "errorLoadingFileInfo": "Error loading file info: ${error}", "errorLoadingSeries": "Error loading series", "musicNotSupported": "Music playback is not yet supported", @@ -1660,7 +1661,8 @@ "timedOut": "The relay did not respond in time", "connectionLost": "The connection closed before the session was ready", "invalidRelayResponse": "The relay sent an unexpected response", - "sessionEnded": "The host ended the session" + "sessionEnded": "The host ended the session", + "sessionUnavailable": "Unable to resume this session. Join or create a room to continue." } }, "downloads": { diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 9da3bba85..0cddd989c 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "El servidor devolvió información de reproducción no válida.", "playbackCancelled": "Se canceló la reproducción.", "playbackFailed": "No se pudo iniciar la reproducción.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Error al cargar la información del archivo: ${error}", "errorLoadingSeries": "Error al cargar la serie", "musicNotSupported": "La reproducción de música aún no es compatible", @@ -1660,7 +1661,8 @@ "timedOut": "El servidor de retransmisión no respondió a tiempo", "connectionLost": "La conexión se cerró antes de que la sesión estuviera lista", "invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada", - "sessionEnded": "El anfitrión finalizó la sesión" + "sessionEnded": "El anfitrión finalizó la sesión", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr no proporcionó una cookie de sesión", "freshCookieRejected": "Seerr rechazó la nueva cookie de sesión", "noUserInformation": "Seerr no devolvió información del usuario", - "sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión" + "sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Servicios", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 45825061e..4875d0383 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Le serveur a renvoyé des informations de lecture non valides.", "playbackCancelled": "La lecture a été annulée.", "playbackFailed": "Impossible de démarrer la lecture.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier : ${error}", "errorLoadingSeries": "Erreur lors du chargement de la série", "musicNotSupported": "La lecture de musique n'est pas encore prise en charge", @@ -1660,7 +1661,8 @@ "timedOut": "Le serveur relais n’a pas répondu à temps", "connectionLost": "La connexion s’est fermée avant que la session ne soit prête", "invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue", - "sessionEnded": "L’hôte a mis fin à la session" + "sessionEnded": "L’hôte a mis fin à la session", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr n’a pas fourni de cookie de session", "freshCookieRejected": "Seerr a refusé le nouveau cookie de session", "noUserInformation": "Seerr n’a renvoyé aucune information sur l’utilisateur", - "sessionRejectedAfterReauth": "La session a été refusée après la reconnexion" + "sessionRejectedAfterReauth": "La session a été refusée après la reconnexion", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Services", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 306351511..16410c52b 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "A szerver érvénytelen lejátszási adatokat küldött.", "playbackCancelled": "A lejátszás megszakítva.", "playbackFailed": "Nem sikerült elindítani a lejátszást.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Hiba a fájlinformációk betöltésekor: ${error}", "errorLoadingSeries": "Hiba a sorozat betöltésekor", "musicNotSupported": "A zenelejátszás még nem támogatott", @@ -1660,7 +1661,8 @@ "timedOut": "A relészerver nem válaszolt időben", "connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna", "invalidRelayResponse": "A relészerver váratlan választ küldött", - "sessionEnded": "A házigazda befejezte a munkamenetet" + "sessionEnded": "A házigazda befejezte a munkamenetet", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "A Seerr nem adott ki munkamenet-sütit", "freshCookieRejected": "A Seerr elutasította az új munkamenet-sütit", "noUserInformation": "A Seerr nem adott vissza felhasználói adatokat", - "sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították" + "sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Szolgáltatások", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 04515376f..b65f8e03e 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.", "playbackCancelled": "Riproduzione annullata.", "playbackFailed": "Impossibile avviare la riproduzione.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Errore durante il caricamento delle informazioni sul file: ${error}", "errorLoadingSeries": "Errore durante il caricamento della serie", "musicNotSupported": "La riproduzione musicale non è ancora supportata", @@ -1660,7 +1661,8 @@ "timedOut": "Il server relay non ha risposto in tempo", "connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta", "invalidRelayResponse": "Il server relay ha inviato una risposta imprevista", - "sessionEnded": "L’host ha terminato la sessione" + "sessionEnded": "L’host ha terminato la sessione", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr non ha generato un cookie di sessione", "freshCookieRejected": "Seerr ha rifiutato il nuovo cookie di sessione", "noUserInformation": "Seerr non ha restituito le informazioni sull'utente", - "sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso" + "sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Servizi", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 6ea2e7fbc..e3985d889 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "サーバーから無効な再生情報が返されました。", "playbackCancelled": "再生がキャンセルされました。", "playbackFailed": "再生を開始できませんでした。", + "mediaUnavailable": "", "errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}", "errorLoadingSeries": "シリーズの読み込みエラー", "musicNotSupported": "音楽の再生はまだサポートされていません", @@ -1649,7 +1650,8 @@ "timedOut": "リレーサーバーが時間内に応答しませんでした", "connectionLost": "セッションの準備が整う前に接続が閉じられました", "invalidRelayResponse": "リレーサーバーから予期しない応答が返されました", - "sessionEnded": "ホストがセッションを終了しました" + "sessionEnded": "ホストがセッションを終了しました", + "sessionUnavailable": "" } }, "downloads": { @@ -2105,7 +2107,9 @@ "noSessionCookie": "Seerrからセッションクッキーが発行されませんでした", "freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました", "noUserInformation": "Seerrからユーザー情報が返されませんでした", - "sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました" + "sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "サービス", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 0d4c1bc1a..501d35552 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.", "playbackCancelled": "Ойнатудан бас тартылды.", "playbackFailed": "Ойнатуды іске қосу қатесі.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}", "errorLoadingSeries": "Сериалды жүктеу қатесі", "musicNotSupported": "Музыка ойнату әлі қолдау таппайды", @@ -1660,7 +1661,8 @@ "timedOut": "Реле сервері уақытында жауап бермеді", "connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді", "invalidRelayResponse": "Реле сервері күтпеген жауап жіберді", - "sessionEnded": "Ұйымдастырушы сеансты аяқтады" + "sessionEnded": "Ұйымдастырушы сеансты аяқтады", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr сеанс cookie файлын бермеді", "freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады", "noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады", - "sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады" + "sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Қызметтер", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 50906d1cc..8ced698b7 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.", "playbackCancelled": "재생이 취소되었습니다.", "playbackFailed": "재생을 시작할 수 없습니다.", + "mediaUnavailable": "", "errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}", "errorLoadingSeries": "시리즈 로딩 중 오류", "musicNotSupported": "음악 재생 미지원", @@ -1649,7 +1650,8 @@ "timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다", "connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다", "invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다", - "sessionEnded": "호스트가 세션을 종료했습니다" + "sessionEnded": "호스트가 세션을 종료했습니다", + "sessionUnavailable": "" } }, "downloads": { @@ -2105,7 +2107,9 @@ "noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다", "freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다", "noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다", - "sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다" + "sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "서비스", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index a8367c3f5..5aa636605 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.", "playbackCancelled": "Avspillingen ble avbrutt.", "playbackFailed": "Kunne ikke starte avspillingen.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}", "errorLoadingSeries": "Feil ved lasting av serie", "musicNotSupported": "Musikkavspilling støttes ikke ennå", @@ -1660,7 +1661,8 @@ "timedOut": "Reléserveren svarte ikke i tide", "connectionLost": "Tilkoblingen ble lukket før økten var klar", "invalidRelayResponse": "Reléserveren sendte et uventet svar", - "sessionEnded": "Verten avsluttet økten" + "sessionEnded": "Verten avsluttet økten", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel", "freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen", "noUserInformation": "Seerr returnerte ingen brukerinformasjon", - "sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging" + "sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Tjenester", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c70e182cc..4af7781c3 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.", "playbackCancelled": "Het afspelen is geannuleerd.", "playbackFailed": "Het afspelen kon niet worden gestart.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}", "errorLoadingSeries": "Fout bij laden van serie", "musicNotSupported": "Muziek afspelen wordt nog niet ondersteund", @@ -1660,7 +1661,8 @@ "timedOut": "De relayserver heeft niet op tijd gereageerd", "connectionLost": "De verbinding is verbroken voordat de sessie gereed was", "invalidRelayResponse": "De relayserver heeft een onverwacht antwoord verzonden", - "sessionEnded": "De host heeft de sessie beëindigd" + "sessionEnded": "De host heeft de sessie beëindigd", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr heeft geen sessiecookie verstrekt", "freshCookieRejected": "Seerr heeft de nieuwe sessiecookie geweigerd", "noUserInformation": "Seerr heeft geen gebruikersgegevens geretourneerd", - "sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd" + "sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Diensten", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index e29f15b21..60ec17062 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -787,6 +787,7 @@ "playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.", "playbackCancelled": "Odtwarzanie zostało anulowane.", "playbackFailed": "Nie udało się rozpocząć odtwarzania.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}", "errorLoadingSeries": "Błąd ładowania serialu", "musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane", @@ -1682,7 +1683,8 @@ "timedOut": "Serwer pośredniczący nie odpowiedział w wymaganym czasie", "connectionLost": "Połączenie zostało zamknięte, zanim sesja była gotowa", "invalidRelayResponse": "Serwer pośredniczący wysłał nieoczekiwaną odpowiedź", - "sessionEnded": "Gospodarz zakończył sesję" + "sessionEnded": "Gospodarz zakończył sesję", + "sessionUnavailable": "" } }, "downloads": { @@ -2138,7 +2140,9 @@ "noSessionCookie": "Seerr nie utworzył pliku cookie sesji", "freshCookieRejected": "Seerr odrzucił nowy plik cookie sesji", "noUserInformation": "Seerr nie zwrócił informacji o użytkowniku", - "sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu" + "sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Usługi", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index bf8abe643..4e15e554a 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "O servidor retornou informações de reprodução inválidas.", "playbackCancelled": "A reprodução foi cancelada.", "playbackFailed": "Não foi possível iniciar a reprodução.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Erro ao carregar as informações do arquivo: ${error}", "errorLoadingSeries": "Erro ao carregar série", "musicNotSupported": "A reprodução de música ainda não é compatível", @@ -1660,7 +1661,8 @@ "timedOut": "O servidor de retransmissão não respondeu a tempo", "connectionLost": "A conexão foi encerrada antes de a sessão ficar pronta", "invalidRelayResponse": "O servidor de retransmissão enviou uma resposta inesperada", - "sessionEnded": "O anfitrião encerrou a sessão" + "sessionEnded": "O anfitrião encerrou a sessão", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "O Seerr não forneceu um cookie de sessão", "freshCookieRejected": "O Seerr rejeitou o novo cookie de sessão", "noUserInformation": "O Seerr não retornou informações do usuário", - "sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente" + "sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Serviços", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 3dff07c84..825510a71 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -787,6 +787,7 @@ "playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.", "playbackCancelled": "Воспроизведение отменено.", "playbackFailed": "Не удалось начать воспроизведение.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}", "errorLoadingSeries": "Ошибка загрузки сериала", "musicNotSupported": "Воспроизведение музыки пока не поддерживается", @@ -1682,7 +1683,8 @@ "timedOut": "Сервер ретрансляции не ответил вовремя", "connectionLost": "Соединение закрылось до того, как сессия была готова", "invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ", - "sessionEnded": "Организатор завершил сессию" + "sessionEnded": "Организатор завершил сессию", + "sessionUnavailable": "" } }, "downloads": { @@ -2138,7 +2140,9 @@ "noSessionCookie": "Seerr не выдал cookie-файл сеанса", "freshCookieRejected": "Seerr отклонил новый cookie-файл сеанса", "noUserInformation": "Seerr не вернул сведения о пользователе", - "sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа" + "sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Сервисы", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 81380025e..87ab78fba 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 43685 (1985 per locale) +/// Strings: 43687 (1985 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 457d6a27b..aa9bcab44 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2272,6 +2272,9 @@ class Translations$messages$en { /// en: 'Playback could not be started.' String get playbackFailed => 'Playback could not be started.'; + /// en: 'This content is no longer available.' + String get mediaUnavailable => 'This content is no longer available.'; + /// en: 'Error loading file info: ${error}' String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}'; @@ -6565,6 +6568,9 @@ class Translations$watchTogether$errors$en { /// en: 'The host ended the session' String get sessionEnded => 'The host ended the session'; + + /// en: 'Unable to resume this session. Join or create a room to continue.' + String get sessionUnavailable => 'Unable to resume this session. Join or create a room to continue.'; } // Path: downloads.backgroundWarning @@ -7743,6 +7749,7 @@ extension on Translations { 'messages.playbackDataInvalid' => 'The server returned invalid playback information.', 'messages.playbackCancelled' => 'Playback was canceled.', 'messages.playbackFailed' => 'Playback could not be started.', + 'messages.mediaUnavailable' => 'This content is no longer available.', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}', 'messages.errorLoadingSeries' => 'Error loading series', 'messages.musicNotSupported' => 'Music playback is not yet supported', @@ -8038,9 +8045,9 @@ extension on Translations { 'libraries.folders' => 'folders', 'libraries.tabs.recommended' => 'Recommended', 'libraries.tabs.browse' => 'Browse', - 'libraries.tabs.collections' => 'Collections', _ => null, } ?? switch (path) { + 'libraries.tabs.collections' => 'Collections', 'libraries.tabs.playlists' => 'Playlists', 'libraries.groupings.title' => 'Grouping', 'libraries.groupings.all' => 'All', @@ -8517,6 +8524,7 @@ extension on Translations { 'watchTogether.errors.connectionLost' => 'The connection closed before the session was ready', 'watchTogether.errors.invalidRelayResponse' => 'The relay sent an unexpected response', 'watchTogether.errors.sessionEnded' => 'The host ended the session', + 'watchTogether.errors.sessionUnavailable' => 'Unable to resume this session. Join or create a room to continue.', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', @@ -8551,10 +8559,10 @@ extension on Translations { 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}', 'downloads.noDownloadsTree' => 'No downloads', 'downloads.pauseAll' => 'Pause all', - 'downloads.resumeAll' => 'Resume all', - 'downloads.deleteAll' => 'Delete all', _ => null, } ?? switch (path) { + 'downloads.resumeAll' => 'Resume all', + 'downloads.deleteAll' => 'Delete all', 'downloads.selectVersion' => 'Select Version', 'downloads.allEpisodes' => 'All episodes', 'downloads.unwatchedOnly' => 'Unwatched only', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 425c8fd86..d9f5a9e70 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.", "playbackCancelled": "Uppspelningen avbröts.", "playbackFailed": "Det gick inte att starta uppspelningen.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}", "errorLoadingSeries": "Fel vid laddning av serie", "musicNotSupported": "Musikuppspelning stöds inte ännu", @@ -1660,7 +1661,8 @@ "timedOut": "Reläservern svarade inte i tid", "connectionLost": "Anslutningen stängdes innan sessionen var klar", "invalidRelayResponse": "Reläservern skickade ett oväntat svar", - "sessionEnded": "Värden avslutade sessionen" + "sessionEnded": "Värden avslutade sessionen", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr skapade ingen sessionscookie", "freshCookieRejected": "Seerr avvisade den nya sessionscookien", "noUserInformation": "Seerr returnerade ingen användarinformation", - "sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen" + "sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Tjänster", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 46ccbae1d..744408ba7 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.", "playbackCancelled": "Oynatma iptal edildi.", "playbackFailed": "Oynatma başlatılamadı.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}", "errorLoadingSeries": "Dizi yüklenirken hata", "musicNotSupported": "Müzik oynatma henüz desteklenmiyor", @@ -1660,7 +1661,8 @@ "timedOut": "Aktarıcı sunucusu zamanında yanıt vermedi", "connectionLost": "Oturum hazır olmadan bağlantı kapandı", "invalidRelayResponse": "Aktarıcı sunucusu beklenmeyen bir yanıt gönderdi", - "sessionEnded": "Kurucu oturumu sonlandırdı" + "sessionEnded": "Kurucu oturumu sonlandırdı", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr bir oturum çerezi oluşturmadı", "freshCookieRejected": "Seerr yeni oturum çerezini reddetti", "noUserInformation": "Seerr kullanıcı bilgilerini döndürmedi", - "sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi" + "sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Servisler", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 345ad925f..9d338092f 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.", "playbackCancelled": "Ijro bekor qilindi.", "playbackFailed": "Ijroni ishga tushirishda xatolik.", + "mediaUnavailable": "", "errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}", "errorLoadingSeries": "Serialni yuklashda xatolik", "musicNotSupported": "Musiqa ijrosi hali qoʻllab-quvvatlanmaydi", @@ -1660,7 +1661,8 @@ "timedOut": "Rele serveri oʻz vaqtida javob bermadi", "connectionLost": "Seans tayyor boʻlmasidan ulanish uzildi", "invalidRelayResponse": "Rele serveri kutilmagan javob yubordi", - "sessionEnded": "Tashkilotchi seansni tugatdi" + "sessionEnded": "Tashkilotchi seansni tugatdi", + "sessionUnavailable": "" } }, "downloads": { @@ -2116,7 +2118,9 @@ "noSessionCookie": "Seerr seans cookie-faylini bermadi", "freshCookieRejected": "Seerr yangi seans cookie-faylini rad etdi", "noUserInformation": "Seerr foydalanuvchi maʼlumotlarini qaytarmadi", - "sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi" + "sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "Xizmatlar", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index bd2c1b1b1..52fe5b129 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "伺服器傳回的播放資訊無效。", "playbackCancelled": "播放已取消。", "playbackFailed": "無法開始播放。", + "mediaUnavailable": "", "errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}", "errorLoadingSeries": "載入影集時發生錯誤", "musicNotSupported": "目前不支援播放音樂", @@ -1649,7 +1650,8 @@ "timedOut": "中繼伺服器未及時回應", "connectionLost": "工作階段就緒前連線已中斷", "invalidRelayResponse": "中繼伺服器傳回了非預期的回應", - "sessionEnded": "主持人已結束工作階段" + "sessionEnded": "主持人已結束工作階段", + "sessionUnavailable": "" } }, "downloads": { @@ -2105,7 +2107,9 @@ "noSessionCookie": "Seerr 未發出工作階段 Cookie", "freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie", "noUserInformation": "Seerr 未傳回使用者資訊", - "sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕" + "sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "外部服務", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 14a59c0a7..36b3f60b6 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "服务器返回了无效的播放信息。", "playbackCancelled": "播放已取消。", "playbackFailed": "无法开始播放。", + "mediaUnavailable": "", "errorLoadingFileInfo": "加载文件信息时出错:${error}", "errorLoadingSeries": "加载剧集时出错", "musicNotSupported": "尚不支持播放音乐", @@ -1649,7 +1650,8 @@ "timedOut": "中继服务器未及时响应", "connectionLost": "会话准备就绪前连接已断开", "invalidRelayResponse": "中继服务器返回了意外的响应", - "sessionEnded": "主持人已结束会话" + "sessionEnded": "主持人已结束会话", + "sessionUnavailable": "" } }, "downloads": { @@ -2105,7 +2107,9 @@ "noSessionCookie": "Seerr 未签发会话 Cookie", "freshCookieRejected": "Seerr 拒绝了新的会话 Cookie", "noUserInformation": "Seerr 未返回用户信息", - "sessionRejectedAfterReauth": "重新登录后会话被拒绝" + "sessionRejectedAfterReauth": "重新登录后会话被拒绝", + "permissionDenied": "", + "permissionRevoked": "" }, "services": { "title": "服务", diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 74808de8e..d73821612 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -2,6 +2,12 @@ part of '../media_detail_screen.dart'; extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildActionButtons(MediaItem metadata) { + // Tie asynchronous playback prompts to the actionable subtree, not the + // route's State: a deleted first-route detail intentionally stays mounted. + return Builder(builder: (context) => _buildAvailableActionButtons(context, metadata)); + } + + Widget _buildAvailableActionButtons(BuildContext context, MediaItem metadata) { final isTv = PlatformDetector.isTV(); final tvScale = TvLayoutConstants.scaleOf(context); final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0; @@ -42,6 +48,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { : null; Future onPlayPressed() async { + if (!_canUseDetail) return; // For TV shows, play the episode the hero describes (focused on TV, // otherwise on-deck); with neither, the first episode of the first season. if (metadata.isShow) { @@ -53,6 +60,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { metadata: episode, isOffline: widget.isOffline, onRefresh: _refreshWatchState, + isLaunchCurrent: () => _canUseDetail, ); } else { // No on deck episode, fetch first episode of first season @@ -66,6 +74,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { metadata: _episodes.first, isOffline: widget.isOffline, onRefresh: _refreshWatchState, + isLaunchCurrent: () => _canUseDetail, ); } else { await _playFirstEpisode(); @@ -78,11 +87,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { metadata: metadata, isOffline: widget.isOffline, onRefresh: _refreshWatchState, + isLaunchCurrent: () => _canUseDetail, ); } } Future onPlayVersionPressed() async { + if (!_canUseDetail) return; final didNavigate = await promptAndPlayVersion(context, metadata); // Same post-playback refresh plain Play gets from // navigateToVideoPlayerWithRefresh; the split segment is online-only. @@ -142,7 +153,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // ⋮ menu item so the trailer stays reachable when the row hides its button. final VoidCallback? onPlayTrailer = primaryTrailer == null ? null - : () => unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer)); + : () => + unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer, isLaunchCurrent: () => _canUseDetail)); final gap = isTv ? 8.0 * tvScale : 12.0; @@ -271,7 +283,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final downloadAction = !widget.isOffline && !PlatformDetector.isAppleTV() ? FocusableAction( debugLabel: 'detail_download', - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), builder: (context, state) => _buildDownloadButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus), ) @@ -433,6 +445,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } Future _handleWatchlistTogglePressed(MediaItem metadata) async { + if (!_canUseDetail) return; final candidates = _watchlistCandidates; if (candidates.isEmpty || _watchlistMutationInFlight) return; // Parity with the disabled pointer button: while every membership is @@ -460,11 +473,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size, focusFirstItem: true, ); - if (choice == null || !mounted) return; + if (choice == null || !_canUseDetail) return; await _toggleWatchlistOn(metadata, choice); } Future _toggleWatchlistOn(MediaItem metadata, WatchlistCandidate candidate) async { + if (!_canUseDetail) return; final current = candidate.source.isOnWatchlist(metadata.kind, candidate.ids); if (current == null || _watchlistMutationInFlight) return; _watchlistMutationInFlight = true; @@ -480,10 +494,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } Future _handleWatchedTogglePressed(MediaItem metadata) async { + if (!_canUseDetail) return; try { final isWatched = metadata.isWatched; final outcome = await WatchActions.setWatched(context, metadata, watched: !isWatched, offline: widget.isOffline); - if (!mounted) return; + if (!mounted || !_canUseDetail) return; switch (outcome) { case WatchMarkOutcome.queuedOffline: showAppSnackBar(context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline); @@ -532,6 +547,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { child: Builder( builder: (buttonContext) => IconButton.filledTonal( onPressed: () { + if (!_canUseDetail) return; final renderBox = buttonContext.findRenderObject() as RenderBox?; if (renderBox != null) { final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero)); @@ -550,15 +566,17 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { /// restrictions, re-resolve the version, then replace the existing download /// with a fresh queue entry. Future _retryDownload(DownloadProvider downloadProvider, MediaItem metadata, String globalKey) async { - if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return; + if (!_canUseDetail) return; + if (!await confirmBackgroundDownloadRestrictions(context) || !mounted || !_canUseDetail) return; final client = _getMediaClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); - if (versionConfig == null || !mounted) return; + if (versionConfig == null || !_canUseDetail) return; await downloadProvider.deleteDownload(globalKey); + if (!_canUseDetail) return; try { await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); if (mounted) showSuccessSnackBar(context, t.downloads.downloadQueued); @@ -567,7 +585,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } } - Future _handleDownloadButtonPressed(MediaItem metadata) async { + Future _handleDownloadButtonPressed(BuildContext context, MediaItem metadata) async { + if (!_canUseDetail || !context.mounted) return; final downloadProvider = context.read(); final globalKey = metadata.globalKey; final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata); @@ -583,7 +602,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final client = _getMediaClientForMetadata(context); if (client == null) return; await downloadProvider.resumeDownload(globalKey, client); - if (mounted) showAppSnackBar(context, t.downloads.downloadResumed); + if (_canUseDetail && context.mounted) showAppSnackBar(context, t.downloads.downloadResumed); return; } @@ -602,11 +621,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { cancelText: t.common.delete, confirmText: t.common.retry, ); + if (!_canUseDetail || !context.mounted) return; - if (!retry && mounted) { + if (!retry) { await downloadProvider.deleteDownload(globalKey); - if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted); - } else if (retry && mounted) { + if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted); + } else { await _retryDownload(downloadProvider, metadata, globalKey); } return; @@ -617,16 +637,16 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { await _showSyncRuleActions(context, downloadProvider, metadata, ruleKey: ruleKey, downloadGlobalKey: globalKey); return; } - if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return; + if (!await confirmBackgroundDownloadRestrictions(context) || !_canUseDetail || !context.mounted) return; final client = _getMediaClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); - if (versionConfig == null || !mounted) return; + if (versionConfig == null || !_canUseDetail || !context.mounted) return; final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig); - if (mounted) { + if (_canUseDetail && context.mounted) { final message = count > 0 ? t.downloads.episodesQueued(count: count) : t.downloads.allEpisodesAlreadyDownloaded; showAppSnackBar(context, message); } @@ -646,9 +666,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { title: t.downloads.deleteDownload, message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); - if (confirmed && mounted) { + if (confirmed && _canUseDetail && context.mounted) { await downloadProvider.deleteDownload(globalKey); - if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted); + if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted); } } @@ -741,7 +761,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // State 4: Paused (can resume) if (progress?.status == DownloadStatus.paused) { return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), tooltip: t.downloads.resumeDownload, iconSize: iconSize, @@ -752,7 +772,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // State 5: Failed (can retry) if (progress?.status == DownloadStatus.failed) { return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), tooltip: t.downloads.retryDownload, iconSize: iconSize, @@ -763,7 +783,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // State 6: Cancelled (can delete or retry) if (progress?.status == DownloadStatus.cancelled) { return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: const AppIcon(Symbols.cancel_rounded, fill: 1), tooltip: t.downloads.cancelledDownload, iconSize: iconSize, @@ -788,7 +808,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { : t.downloads.keepSynced; return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), tooltip: tooltip, icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), iconSize: iconSize, @@ -801,7 +821,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { : t.downloads.partialDownloadClickToComplete; return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), tooltip: tooltip, icon: const AppIcon(Symbols.downloading_rounded, fill: 1), iconSize: iconSize, @@ -818,7 +838,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final syncRule = downloadProvider.getSyncRule(ruleKey); final isEnabled = syncRule?.enabled ?? true; return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), iconSize: iconSize, @@ -830,7 +850,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final canDownloadMore = metadata.isShow || metadata.isSeason; return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: const AppIcon(Symbols.download_rounded, fill: 1), tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload, iconSize: iconSize, @@ -840,7 +860,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // State 9: Not downloaded (default - can download) return IconButton.filledTonal( - onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)), + onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)), icon: const AppIcon(Symbols.download_rounded, fill: 1), tooltip: t.downloads.downloadNow, iconSize: iconSize, diff --git a/lib/screens/media_detail/playback_tracks_status.dart b/lib/screens/media_detail/playback_tracks_status.dart index 9eda91cc6..d54ed6989 100644 --- a/lib/screens/media_detail/playback_tracks_status.dart +++ b/lib/screens/media_detail/playback_tracks_status.dart @@ -10,6 +10,7 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState { /// episode for a season, the item itself otherwise. Null while a show has /// not resolved any episode yet. MediaItem? _playbackTargetItem(MediaItem metadata) { + if (!_canUseDetail) return null; final MediaItem? target; if (metadata.isShow) { target = _showPlayEpisode(); @@ -23,12 +24,13 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState { Future _listenForPlaybackVersionChanges() async { final settings = await SettingsService.getInstance(); - if (!mounted) return; + if (!_canUseDetail) return; _playbackVersionPreferences = settings.listenableOf(SettingsService.mediaVersionPreferences); _playbackVersionPreferences!.addListener(_onPlaybackVersionChanged); } void _onPlaybackVersionChanged() { + if (!_canUseDetail) return; _invalidatePlaybackProbes(refreshItems: false); } @@ -45,6 +47,7 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState { /// Rendering never starts PlaybackInfo or a transcode. Offline only reads /// the completed download's identity, never a server or track probe. void _scheduleTargetProbe(BuildContext context, MediaItem target) { + if (!_canUseDetail) return; final key = target.globalKey; if (_playbackSources.containsKey(key) || _playbackProbeRequests.containsKey(key)) return; final client = widget.isOffline ? null : _getMediaClientForMetadata(context); @@ -67,7 +70,7 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState { final key = target.globalKey; final request = Object(); _playbackProbeRequests[key] = request; - bool current() => mounted && generation == _playbackProbeGeneration && _playbackProbeRequests[key] == request; + bool current() => _canUseDetail && generation == _playbackProbeGeneration && _playbackProbeRequests[key] == request; try { MediaItem item; MediaSourceInfo? source; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a97f88fc5..d7b2be6ab 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -27,6 +27,7 @@ import '../focus/input_mode_tracker.dart'; import '../widgets/cast_member_strip.dart'; import '../widgets/focus_builders.dart'; import '../media/library_query.dart'; +import '../media/library_change_event.dart'; import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; import '../utils/plex_season_display.dart'; @@ -87,6 +88,7 @@ import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; +import '../utils/library_content_notifier.dart'; import '../utils/global_key_utils.dart'; import '../utils/tone_mapped_logo_image.dart'; import '../widgets/episode_card.dart'; @@ -316,13 +318,7 @@ PageRoute mediaDetailRoute({ } class _MediaDetailScreenState extends State - with - WatchStateAware, - DeletionAware, - DeletionMirrorsWatchState, - MountedSetStateMixin, - ServerBoundMediaMixin, - RouteAware { + with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware { /// Public input alias — used as the live source of truth until the detail /// fetch returns. Holds backend-neutral [MediaItem] data. MediaItem get _metadata => _fullMetadata ?? widget.metadata; @@ -337,6 +333,10 @@ class _MediaDetailScreenState extends State MediaItem? _fullMetadata; MediaItem? _onDeckEpisode; bool _isLoadingMetadata = true; + bool _isDeleted = false; + StreamSubscription? _libraryContentSubscription; + + bool get _canUseDetail => mounted && !_isDeleted; List? _extras; List _relatedHubs = []; List> _relatedHubKeys = []; @@ -462,9 +462,8 @@ class _MediaDetailScreenState extends State @override bool get isServerBoundOffline => widget.isOffline; - // WatchStateAware: watch the show/movie and all season/episode ratingKeys. - // DeletionMirrorsWatchState reuses these three getters for deletion events — - // the same items are on screen either way. + // Watch state follows displayed items; deletion also follows the detail's + // known ancestors, even before any children have loaded. @override Set? get watchedIds { final keys = {_metadata.id}; @@ -495,8 +494,39 @@ class _MediaDetailScreenState extends State return keys; } + Set get _terminalDeletionIds => { + widget.metadata.id, + ...widget.metadata.parentChain.where((id) => id.isNotEmpty), + _metadata.id, + ..._metadata.parentChain.where((id) => id.isNotEmpty), + }; + + @override + String? get deletionServerId => serverBoundServerId; + + @override + Set get deletionIds => {...?watchedIds, ..._terminalDeletionIds}; + + @override + Set? get deletionGlobalKeys { + final serverId = serverBoundServerId; + if (serverId == null) return null; + return { + ...?watchedGlobalKeys, + for (final id in _terminalDeletionIds) toServerBoundGlobalKey(id, serverId: ServerId(serverId)), + }; + } + + void _onLibraryContentChanged(LibraryChangeEvent event) { + if (!_canUseDetail || widget.isOffline || event.serverId != serverBoundServerId) return; + // Exact ids are authoritative even when the producer omits its per-item + // fanout. Advisory flags alone do not prove this detail was removed. + if (_terminalDeletionIds.any(event.removedItemIds.contains)) _markDetailDeleted(); + } + @override void onWatchStateChanged(WatchStateEvent event) { + if (!_canUseDetail) return; _watchStateChanged = true; if (event.changeType == WatchStateChangeType.removedFromContinueWatching) return; @@ -588,6 +618,7 @@ class _MediaDetailScreenState extends State } Future _refreshItemInPlace(MediaItem source) async { + if (!_canUseDetail) return; final serverId = source.serverId; if (serverId == null) return; final client = context.tryGetMediaClientForServer(ServerId(serverId)); @@ -595,7 +626,7 @@ class _MediaDetailScreenState extends State try { final refreshed = await client.fetchItem(source.id); - if (refreshed == null || !mounted) return; + if (refreshed == null || !_canUseDetail) return; setStateIfMounted(() { _patchItemEverywhere(source.globalKey, refreshed); }); @@ -606,10 +637,16 @@ class _MediaDetailScreenState extends State @override void onDeletionEvent(DeletionEvent event) { + if (!_canUseDetail) return; // Download-only deletions should only remove items when viewing offline content if (event.isDownloadOnly && !widget.isOffline) return; if (!event.isDownloadOnly && widget.isOffline) return; + if (_terminalDeletionIds.contains(event.itemId)) { + _markDetailDeleted(); + return; + } + // Drop the episode from any visible/cached list. This fires whether we're // showing a flattened episode list or a season-tabs view of a show. final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); @@ -627,7 +664,7 @@ class _MediaDetailScreenState extends State if (epIndex != -1 && _showEpisodesDirectly) { if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) { - _closeDeletedDetail(); + _markDetailDeleted(); } return; } @@ -641,7 +678,7 @@ class _MediaDetailScreenState extends State // If the show has no more seasons, navigate back up to the library if (_seasons.isEmpty && mounted) { - _closeDeletedDetail(); + _markDetailDeleted(); return; } _refreshWatchState(); @@ -664,7 +701,7 @@ class _MediaDetailScreenState extends State // Otherwise we have no more seasons, so navigate up if (_seasons.isEmpty && mounted) { - _closeDeletedDetail(); + _markDetailDeleted(); return; } } else { @@ -679,15 +716,27 @@ class _MediaDetailScreenState extends State } } + void _markDetailDeleted() { + if (!_canUseDetail) return; + setState(() { + _isDeleted = true; + // Retire in-flight pages and probes before navigation. A first route + // remains mounted, so mounted checks alone cannot fence late results. + _episodesLoadGeneration++; + _invalidatePlaybackProbes(refreshItems: true); + }); + if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete(); + _closeDeletedDetail(); + } + /// The detail's content is gone; leave via the detail's OWN route. A blind /// `Navigator.pop` takes whatever is topmost on the profile navigator — the /// download progress dialog, a player, a hostless sheet fallback, another /// detail pushed on top — and leaves this empty screen behind. /// - /// `isActive` is the double-delivery guard (a container delete emits one - /// event per leaf and then the container; stacked details of the same show - /// each handle the same event). `isFirst` keeps a detail that is the - /// navigator's only route from stranding a blank navigator. + /// The terminal transition handles duplicate delivery; `isActive` also + /// avoids removing a route already leaving for another reason. `isFirst` + /// retains the unavailable state instead of stranding a blank navigator. void _closeDeletedDetail() { final route = _route ?? ModalRoute.of(context); if (route == null || route.isFirst || !route.isActive) return; @@ -702,7 +751,7 @@ class _MediaDetailScreenState extends State /// Lightweight refresh for watch state changes - no loader, preserves scroll Future _refreshWatchState() async { - if (!mounted) return; + if (!_canUseDetail) return; _invalidatePlaybackProbes(refreshItems: true); if (widget.isOffline) return; // Backend-neutral. Plex bundles metadata + on-deck in one round-trip @@ -717,6 +766,7 @@ class _MediaDetailScreenState extends State try { final result = await mediaClient.fetchItemWithOnDeck(_metadata.id); + if (!_canUseDetail) return; final metadata = result.item; final onDeckEpisode = result.onDeckEpisode; if (metadata != null) { @@ -759,6 +809,7 @@ class _MediaDetailScreenState extends State _overviewFocusNode = FocusNode(debugLabel: 'overview'); _tvDetailInfoFocusNode = FocusNode(debugLabel: 'tv_detail_info'); _infoRowsFocusNode = FocusNode(debugLabel: 'info_rows'); + _libraryContentSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged); _loadFullMetadata(); unawaited(_listenForPlaybackVersionChanges()); _initWatchlistState(); @@ -789,7 +840,7 @@ class _MediaDetailScreenState extends State _metadata, client: _getMediaClientForMetadata(context), ); - if (!mounted || candidates.isEmpty) return; + if (!_canUseDetail || candidates.isEmpty) return; setState(() => _watchlistCandidates = candidates); } catch (e) { appLogger.d('Watchlist external-id resolution failed', error: e); @@ -891,14 +942,14 @@ class _MediaDetailScreenState extends State _tvDetailRevealScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!_canUseDetail) return; setState(() { _tvDetailStableRailHeight = _tvDetailPendingRailHeight ?? railHeight; _tvDetailRevealScheduled = false; _tvDetailRevealed = true; }); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!_canUseDetail) return; if (focusPrimaryAction) { _playButtonFocusNode.requestFocus(); } else { @@ -933,6 +984,7 @@ class _MediaDetailScreenState extends State @override void dispose() { + _libraryContentSubscription?.cancel(); for (final source in _watchlistListenedSources) { source.watchlistChanges.removeListener(_onWatchlistSourceChanged); } @@ -1426,7 +1478,7 @@ class _MediaDetailScreenState extends State ], ); - if (selected == null || !context.mounted) return; + if (selected == null || !_canUseDetail || !context.mounted) return; switch (selected) { case _SyncRuleAction.edit: @@ -1469,6 +1521,7 @@ class _MediaDetailScreenState extends State } Future _loadFullMetadata() async { + if (!_canUseDetail) return; _invalidatePlaybackProbes(refreshItems: true); setState(() { _isLoadingMetadata = true; @@ -1482,7 +1535,7 @@ class _MediaDetailScreenState extends State final cachedMetadata = serverId == null ? null : await context.read().lookupOfflineMetadata(serverId, _metadata.id); - if (!mounted) return; + if (!_canUseDetail) return; setState(() { _fullMetadata = cachedMetadata ?? _metadata; _isLoadingMetadata = false; @@ -1568,12 +1621,12 @@ class _MediaDetailScreenState extends State final result = await client.fetchItemWithOnDeck( _metadata.id, onItemReady: (item) { - if (mounted) publish(item); + if (_canUseDetail) publish(item); }, ); final metadata = result.item; - if (!mounted) return; + if (!_canUseDetail) return; final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true); if (base.isShow) { @@ -1590,7 +1643,7 @@ class _MediaDetailScreenState extends State unawaited(_loadRelatedHubs()); } catch (e) { // Fallback to passed metadata on error - if (!mounted) return; + if (!_canUseDetail) return; setState(() { _fullMetadata = _metadata; _isLoadingMetadata = false; @@ -1609,6 +1662,7 @@ class _MediaDetailScreenState extends State } Future _loadSeasons() async { + if (!_canUseDetail) return; _seasonsCompleter = Completer(); setStateIfMounted(() { _isLoadingSeasons = true; @@ -1642,6 +1696,7 @@ class _MediaDetailScreenState extends State : Future.value({}); final results = await Future.wait([seasonsFuture, prefsFuture]); + if (!_canUseDetail) return; final seasons = results.first as List; final prefs = results[1] as Map; @@ -1698,6 +1753,7 @@ class _MediaDetailScreenState extends State } } catch (e, st) { appLogger.w('Seasons load failed', error: e, stackTrace: st); + if (!_canUseDetail) return; setStateIfMounted(() { _isLoadingSeasons = false; _seasonsLoadFailed = true; @@ -1712,6 +1768,7 @@ class _MediaDetailScreenState extends State /// Load seasons from downloaded episodes (offline mode) void _loadSeasonsFromDownloads() { + if (!_canUseDetail) return; _seasonsCompleter = Completer(); setState(() { _isLoadingSeasons = true; @@ -1796,6 +1853,7 @@ class _MediaDetailScreenState extends State /// Load episodes from downloaded content for a season void _loadEpisodesFromDownloads() { + if (!_canUseDetail) return; final downloadProvider = context.read(); final seasonEpisodes = _downloadedEpisodesForSeason(downloadProvider, _metadata.parentId ?? '', _metadata.index); @@ -1833,6 +1891,7 @@ class _MediaDetailScreenState extends State /// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward /// the end, so a 1000+ episode season never blocks on one giant request. Future _fetchSeasonEpisodes(int seasonIndex) async { + if (!_canUseDetail) return; if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; final seasonId = season.id; @@ -1933,6 +1992,7 @@ class _MediaDetailScreenState extends State } Future _prefetchSeasonEpisodeFirstPage(int seasonIndex) async { + if (!_canUseDetail) return; if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; final seasonId = season.id; @@ -1949,7 +2009,10 @@ class _MediaDetailScreenState extends State start: 0, size: _episodesPageSize, ); - if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) { + if (!_canUseDetail || + _showEpisodesDirectly || + seasonIndex >= _seasons.length || + _seasons[seasonIndex].id != seasonId) { return; } setStateIfMounted(() { @@ -1963,7 +2026,7 @@ class _MediaDetailScreenState extends State if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); } catch (e, st) { appLogger.d('TV adjacent season episode prefetch failed', error: e, stackTrace: st); - if (mounted && _isSelectedSeason(seasonIndex, seasonId)) { + if (_canUseDetail && _isSelectedSeason(seasonIndex, seasonId)) { setStateIfMounted(() { _seasonEpisodePager.failFirstPage(seasonId); }); @@ -1976,6 +2039,7 @@ class _MediaDetailScreenState extends State /// Load the next page of the selected season's episodes and append it. /// No-op when nothing more remains, offline, or a page is already in flight. Future _loadMoreSeasonEpisodes() async { + if (!_canUseDetail) return; final seasonIndex = _selectedSeasonIndex; if (widget.isOffline || seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; @@ -2063,7 +2127,9 @@ class _MediaDetailScreenState extends State /// Load extras (trailers, featurettes, behind-the-scenes, etc.). Future _loadExtras() async { + if (!_canUseDetail) return; void markLoaded() { + if (!_canUseDetail) return; setStateIfMounted(() { _hasLoadedExtras = true; }); @@ -2089,6 +2155,7 @@ class _MediaDetailScreenState extends State } final extras = await client.fetchExtras(_metadata.id); + if (!_canUseDetail) return; // Preserve serverId for each extra (needed for multi-server setups). final extrasWithServerId = extras @@ -2114,7 +2181,9 @@ class _MediaDetailScreenState extends State /// Backend-neutral — both Plex and Jellyfin implement /// [MediaServerClient.fetchRelatedHubs]. Future _loadRelatedHubs() async { + if (!_canUseDetail) return; void markLoaded() { + if (!_canUseDetail) return; setStateIfMounted(() { _hasLoadedRelatedHubs = true; }); @@ -2139,6 +2208,7 @@ class _MediaDetailScreenState extends State try { final relatedHubs = await client.fetchRelatedHubs(_metadata.id); + if (!_canUseDetail) return; setStateIfMounted(() { _relatedHubs = relatedHubs; @@ -2244,7 +2314,7 @@ class _MediaDetailScreenState extends State void _applyInitialMobileFocus(FocusNode node, GlobalKey sectionKey) { _initialDetailFocusApplied = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!_canUseDetail) return; node.requestFocus(); _scrollSectionIntoView(sectionKey); }); @@ -2268,7 +2338,7 @@ class _MediaDetailScreenState extends State _initialEpisodePagingInFlight = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _episodesContainInitialTarget) { + if (!_canUseDetail || _episodesContainInitialTarget) { _initialEpisodePagingInFlight = false; return; } @@ -2555,7 +2625,7 @@ class _MediaDetailScreenState extends State isOwnerActive: () => mounted, onShortPress: () { if (_focusedExtraIndex < extras.length) { - navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex]); + navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex], isLaunchCurrent: () => _canUseDetail); } }, onLongPress: () { @@ -2770,6 +2840,7 @@ class _MediaDetailScreenState extends State metadata: episode, isOffline: widget.isOffline, onRefresh: () => unawaited(_refreshItemInPlace(episode)), + isLaunchCurrent: () => _canUseDetail, ); }, onRefresh: widget.isOffline ? null : _refreshItemInPlace, @@ -2862,6 +2933,7 @@ class _MediaDetailScreenState extends State } Future _fetchAllEpisodes() async { + if (!_canUseDetail) return; final generation = ++_episodesLoadGeneration; if (_seasons.isEmpty) { setStateIfMounted(() { @@ -2904,6 +2976,7 @@ class _MediaDetailScreenState extends State }); } catch (e, st) { appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st); + if (!_canUseDetail || generation != _episodesLoadGeneration) return; setStateIfMounted(() { _allEpisodes = _allEpisodes.failInitialLoad(); _hasLoadedEpisodes = true; @@ -3011,6 +3084,7 @@ class _MediaDetailScreenState extends State /// Load the next page of the flatten/all-episodes list (single-season show or /// season detail) on demand and append it. Future _loadMoreAllEpisodes() async { + if (!_canUseDetail) return; if (widget.isOffline || !_allEpisodes.hasMore || _allEpisodes.isLoadingMore) return; final serverId = _metadata.serverId; final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); @@ -3087,8 +3161,10 @@ class _MediaDetailScreenState extends State /// Load the next unwatched episode for offline mode (offline OnDeck) Future _loadOfflineOnDeckEpisode() async { + if (!_canUseDetail) return; final offlineWatchProvider = context.read(); final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id); + if (!_canUseDetail) return; setStateIfMounted(() { _onDeckEpisode = nextEpisode; @@ -3108,7 +3184,7 @@ class _MediaDetailScreenState extends State void _ensureFallbackOnDeckEpisode() { // Reached via unawaited fetch continuations — the screen may be gone by // now, and _freshAll reads providers through State.context. - if (!mounted) return; + if (!_canUseDetail) return; if (_onDeckEpisode != null) return; final next = firstUnwatchedEpisode(_freshAll(_episodes)); if (next == null) return; @@ -3118,6 +3194,7 @@ class _MediaDetailScreenState extends State } Future _playFirstEpisode() async { + if (!_canUseDetail) return; // Loading seasons and resolving the first episode cost network round // trips; show the shared scoped loading dialog so Play gives immediate // feedback. The offline branch reads local state only and needs none. @@ -3140,7 +3217,7 @@ class _MediaDetailScreenState extends State await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {}); } - if (!mounted) return; + if (!mounted || !_canUseDetail) return; if (_seasons.isEmpty) { if (mounted) { @@ -3153,7 +3230,6 @@ class _MediaDetailScreenState extends State // Get the first episode of the first season. MediaItem? firstEpisode; - if (!mounted) return; if (widget.isOffline) { // In offline mode, get episodes from downloads (filtered to this season). final downloadProvider = context.read(); @@ -3169,6 +3245,8 @@ class _MediaDetailScreenState extends State ); } + if (!_canUseDetail) return; + if (firstEpisode == null) { if (mounted) { showErrorSnackBar(context, t.messages.noEpisodesFound); @@ -3188,17 +3266,18 @@ class _MediaDetailScreenState extends State libraryId: firstEpisode.libraryId ?? _metadata.libraryId, libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle, ); - if (mounted) { + if (mounted && _canUseDetail) { appLogger.d('Playing first episode: ${episodeWithServerId.title}'); await navigateToVideoPlayerWithRefresh( context, metadata: episodeWithServerId, isOffline: widget.isOffline, onRefresh: _refreshWatchState, + isLaunchCurrent: () => _canUseDetail, ); } } catch (e) { - if (mounted) { + if (mounted && _canUseDetail) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } finally { @@ -3210,6 +3289,7 @@ class _MediaDetailScreenState extends State /// so Plex uses its server-side `/playQueues` and Jellyfin builds a local /// shuffled queue from `fetchClientSideEpisodeQueue`. Future _handleShufflePlayWithQueue(BuildContext context, MediaItem metadata) async { + if (!_canUseDetail) return; if (widget.isOffline) { if (context.mounted) { showErrorSnackBar(context, t.mediaMenu.shuffleNotAvailableOffline); @@ -3226,6 +3306,13 @@ class _MediaDetailScreenState extends State @override Widget build(BuildContext context) { + if (_isDeleted) { + return Scaffold( + body: SafeArea( + child: EmptyStateWidget(message: t.messages.mediaUnavailable, icon: Symbols.block_rounded), + ), + ); + } // Session-fresh hero: server snapshot resolved against the watch-state // store (onWatchStateChanged rebuilds on relevant events). final metadata = _fresh(_fullMetadata ?? _metadata); @@ -4257,6 +4344,7 @@ class _MediaDetailScreenState extends State metadata: item, isOffline: widget.isOffline, onRefresh: () => unawaited(_refreshItemInPlace(item)), + isLaunchCurrent: () => _canUseDetail, ); return true; } @@ -4777,7 +4865,8 @@ class _MediaDetailScreenState extends State child: FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isFocused, - onTap: () => navigateToVideoPlayer(context, metadata: extra), + onTap: () => + navigateToVideoPlayer(context, metadata: extra, isLaunchCurrent: () => _canUseDetail), delegateFocusBorder: true, child: MediaCard( key: cardKey, diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 30b5b9e6e..5e1ab7bfd 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/library_change_event.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; @@ -21,6 +22,7 @@ import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/mixins/deletion_aware.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/watch_state_store.dart'; @@ -44,6 +46,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/deletion_notifier.dart'; +import 'package:plezy/utils/library_content_notifier.dart'; import 'package:plezy/utils/video_player_navigation.dart'; import 'package:plezy/widgets/collapsible_text.dart'; import 'package:plezy/widgets/cycling_media_backdrop.dart'; @@ -1892,6 +1895,64 @@ void main() { expect(client.earlyPaints, hasLength(1)); }); + testWidgets('deleting a first-route detail cancels plain Play while preferences resolve', (tester) async { + final movie = testMediaItem( + id: 'deleted_movie', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Deleted movie', + serverId: 'server_1', + ); + final client = _FakeMediaServerClient(show: movie, childrenByParent: const {}); + final observer = _RecordingNavigatorObserver(popVideoPlayerImmediately: true); + await pumpPhoneDetail(tester, client, movie, observer: observer); + observer.pushedRouteNames.clear(); + final play = find.ancestor( + of: find.descendant(of: find.byType(FocusableActionBar), matching: find.byIcon(Symbols.play_arrow_rounded)), + matching: find.byType(FilledButton), + ); + final detail = tester.state(find.byType(MediaDetailScreen)) as DeletionAware; + // Invoke the real control synchronously so deletion lands during Play's + // first await, before its preferences continuation can push the player. + tester.widget(play).onPressed!(); + detail.onDeletionEvent( + DeletionEvent( + itemId: movie.id, + serverId: ServerId('server_1'), + parentChain: const [], + mediaType: 'movie', + origin: DeletionOrigin.serverPush, + ), + ); + await tester.pumpAndSettle(); + expect(observer.pushedRouteNames, isNot(contains(kVideoPlayerRouteName))); + expect(find.text(t.messages.mediaUnavailable), findsOneWidget); + }); + + testWidgets('deleting a first-route show cancels its pending download options', (tester) async { + final show = buildShow(); + final client = _FakeMediaServerClient(show: show, childrenByParent: const {}); + await pumpPhoneDetail(tester, client, show); + await tester.tap(find.byTooltip(t.downloads.downloadNow)); + await tester.pumpAndSettle(); + expect(find.text(t.downloads.unwatchedOnly), findsOneWidget); + DeletionNotifier().notify( + DeletionEvent( + itemId: show.id, + serverId: ServerId('server_1'), + parentChain: const [], + mediaType: 'show', + origin: DeletionOrigin.serverPush, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.downloads.unwatchedOnly)); + await tester.pumpAndSettle(); + expect(find.text(t.downloads.keepSynced), findsNothing); + expect(find.byType(Dialog), findsNothing); + expect(find.text(t.messages.mediaUnavailable), findsOneWidget); + }); + testWidgets('shows directors when they are the only additional info', (tester) async { final movie = testMediaItem( id: 'director_only', @@ -2407,6 +2468,14 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); + final movie = testMediaItem( + id: 'movie_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'The Movie', + serverId: show.serverId, + serverName: show.serverName, + ); /// Past the metadata/episode loads and the detail route's transition. Future settleDetail(WidgetTester tester) async { @@ -2421,14 +2490,16 @@ void main() { /// fallbacks all share) whose initial route is [initialRoute]. Future> pumpProfileNavigator( WidgetTester tester, - Route Function(RouteSettings settings) initialRoute, - ) async { + Route Function(RouteSettings settings) initialRoute, { + _FakeMediaServerClient? client, + DownloadProvider? downloads, + }) async { await SettingsService.getInstance(); tester.view.physicalSize = const Size(1280, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final client = _FakeMediaServerClient( + client ??= _FakeMediaServerClient( show: season, childrenByParent: { season.id: [episode], @@ -2438,8 +2509,11 @@ void main() { final profileKey = GlobalKey(); await tester.pumpWidget( TranslationProvider( - child: ChangeNotifierProvider.value( - value: provider, + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + if (downloads != null) ChangeNotifierProvider.value(value: downloads), + ], child: MaterialApp( theme: monoTheme(dark: true), home: ProfileNavigationScope( @@ -2474,6 +2548,345 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); } + void deleteExact(String id, {String serverId = 'server_1', bool downloadOnly = false}) { + DeletionNotifier().notify( + DeletionEvent( + itemId: id, + serverId: ServerId(serverId), + parentChain: const [], + mediaType: 'unknown', + isDownloadOnly: downloadOnly, + origin: DeletionOrigin.serverPush, + ), + ); + } + + void expectUnavailable() { + expect(find.byType(MediaDetailScreen), findsOneWidget); + expect(find.text(t.messages.mediaUnavailable), findsOneWidget); + expect(find.byType(FocusableActionBar), findsNothing); + expect(find.byType(TvBrowseRail), findsNothing); + expect(find.byType(EpisodeCard), findsNothing); + expect(find.byKey(const ValueKey('detail_playback_tracks')), findsNothing); + } + + testWidgets('an exact episode push with no parent information closes its own detail', (tester) async { + final profileKey = await pumpProfileNavigator( + tester, + homeRoute, + client: _FakeMediaServerClient(show: episode, childrenByParent: {}), + ); + unawaited(profileKey.currentState!.push(mediaDetailRoute(metadata: episode))); + await settleDetail(tester); + expect(find.byType(FocusableActionBar), findsOneWidget); + + deleteExact(episode.id); + await settleDetail(tester); + + expect(find.byType(MediaDetailScreen), findsNothing); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('an exact movie deletion leaves the first route unavailable rather than playable', (tester) async { + final profileKey = await pumpProfileNavigator( + tester, + (_) => mediaDetailRoute(metadata: movie), + client: _FakeMediaServerClient(show: movie, childrenByParent: {}), + ); + expect(find.byType(FocusableActionBar), findsOneWidget); + unawaited( + profileKey.currentState!.push(MaterialPageRoute(builder: (_) => const Scaffold(body: Text('cover')))), + ); + await settleDetail(tester); + + deleteExact(movie.id); + await settleDetail(tester); + expect(find.text('cover'), findsOneWidget); + expect(find.byType(MediaDetailScreen, skipOffstage: false), findsOneWidget); + profileKey.currentState!.pop(); + await settleDetail(tester); + + expectUnavailable(); + expect(profileKey.currentState!.canPop(), isFalse); + expect(find.text(movie.displayTitle), findsNothing); + deleteExact(movie.id); + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent(serverId: ServerId('server_1'), removedItemIds: {movie.id}), + ); + await settleDetail(tester); + expectUnavailable(); + expect(profileKey.currentState!.canPop(), isFalse); + }); + + testWidgets('known input ancestors remain deletion interests after a sparse metadata refresh', (tester) async { + final client = _FakeMediaServerClient( + show: episode.copyWith(parentId: null, grandparentId: null), + childrenByParent: {}, + ); + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: episode), client: client); + expect(find.byType(FocusableActionBar), findsOneWidget); + + deleteExact(season.id); + await settleDetail(tester); + + expectUnavailable(); + }); + + testWidgets('an exact show deletion fences its pending seasons load', (tester) async { + final seasons = Completer>(); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + season.id: [episode], + }, + childrenFutures: {show.id: seasons.future}, + ); + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: show), client: client); + + deleteExact(show.id); + await settleDetail(tester); + expectUnavailable(); + + seasons.complete([season]); + await settleDetail(tester); + expectUnavailable(); + expect(find.text('Only Episode'), findsNothing); + expect(client.childrenPageCalls, isEmpty, reason: 'deleted shows must not start child loads'); + }); + + testWidgets('an exact season deletion fences its pending episode page', (tester) async { + final episodes = Completer>(); + final client = _FakeMediaServerClient( + show: season, + childrenByParent: {}, + childrenPageFutures: {season.id: episodes.future}, + ); + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: season), client: client); + + deleteExact(season.id); + await settleDetail(tester); + expectUnavailable(); + + episodes.complete([episode]); + await settleDetail(tester); + expectUnavailable(); + expect(find.text('Only Episode'), findsNothing); + }); + + testWidgets('deleting a known show ancestor removes stacked season and episode details under a player', ( + tester, + ) async { + final profileKey = await pumpProfileNavigator(tester, homeRoute); + final seasonClosed = profileKey.currentState!.push(mediaDetailRoute(metadata: season)); + await settleDetail(tester); + final episodeClosed = profileKey.currentState!.push(mediaDetailRoute(metadata: episode)); + await settleDetail(tester); + unawaited( + profileKey.currentState!.push( + MaterialPageRoute( + settings: const RouteSettings(name: kVideoPlayerRouteName), + builder: (_) => const Scaffold(body: Text('player')), + ), + ), + ); + await settleDetail(tester); + + deleteExact(show.id); + await settleDetail(tester); + + expect(find.text('player'), findsOneWidget); + expect(find.byType(MediaDetailScreen, skipOffstage: false), findsNothing); + expect(await seasonClosed, isNull); + expect(await episodeClosed, isNull); + profileKey.currentState!.pop(); + await settleDetail(tester); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('full bulk IDs close an episode through an ancestor learned by the metadata load', (tester) async { + final profileKey = await pumpProfileNavigator( + tester, + homeRoute, + client: _FakeMediaServerClient(show: episode, childrenByParent: {}), + ); + unawaited( + profileKey.currentState!.push( + mediaDetailRoute(metadata: episode.copyWith(parentId: null, grandparentId: null)), + ), + ); + await settleDetail(tester); + + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent( + serverId: ServerId('server_1'), + itemsRemoved: true, + removedItemIds: {for (var i = 0; i < 80; i++) 'other_$i', season.id}, + ), + ); + await settleDetail(tester); + + expect(find.byType(MediaDetailScreen), findsNothing); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('bulk and per-item duplicate delivery leave a covering dialog and underlying home intact', ( + tester, + ) async { + final profileKey = await pumpProfileNavigator( + tester, + homeRoute, + client: _FakeMediaServerClient(show: movie, childrenByParent: {}), + ); + final detailClosed = profileKey.currentState!.push(mediaDetailRoute(metadata: movie)); + await settleDetail(tester); + final dialogClosed = showDialog( + context: tester.element(find.byType(MediaDetailScreen)), + useRootNavigator: false, + builder: (_) => const AlertDialog(content: Text('dialog')), + ); + await settleDetail(tester); + final bulk = LibraryChangeEvent( + serverId: ServerId('server_1'), + removedItemIds: {for (var i = 0; i < 80; i++) 'other_$i', movie.id}, + itemsRemoved: true, + ); + + LibraryContentNotifier().notifyChanged(bulk); + deleteExact(movie.id); + LibraryContentNotifier().notifyChanged(bulk); + await settleDetail(tester); + + expect(find.text('dialog'), findsOneWidget); + expect(await detailClosed, isNull); + expect(find.byType(MediaDetailScreen, skipOffstage: false), findsNothing); + // Delivery after disposal must not affect the now-uncovered home. + profileKey.currentState!.pop(); + await dialogClosed; + LibraryContentNotifier().notifyChanged(bulk); + deleteExact(movie.id); + await settleDetail(tester); + expect(find.text('home'), findsOneWidget); + expect(profileKey.currentState!.canPop(), isFalse); + }); + + testWidgets('late early-paint metadata and settled on-deck results cannot revive a deleted first route', ( + tester, + ) async { + final client = + _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season], + }, + ) + ..metadataGate = Completer() + ..onDeckGate = Completer() + ..onDeckEpisode = episode; + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: show), client: client); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + deleteExact(show.id); + await settleDetail(tester); + expectUnavailable(); + client.metadataGate!.complete(); + await settleDetail(tester); + expectUnavailable(); + client.onDeckGate!.complete(); + await settleDetail(tester); + expectUnavailable(); + expect(find.text('Only Episode'), findsNothing); + }); + + testWidgets('a playback track probe completing after deletion cannot restore playback controls', (tester) async { + final raw = _previewItem(movie.id); + final client = _FakeMediaServerClient(show: movie, childrenByParent: {}, rawItems: {movie.id: raw}) + ..sourceGate = Completer(); + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: movie), client: client); + await settleDetail(tester); + expect(client.sourceReads, 1, reason: 'the track probe must actually be in flight'); + + deleteExact(movie.id); + await settleDetail(tester); + client.sourceGate!.complete(); + await settleDetail(tester); + + expectUnavailable(); + }); + + testWidgets('online detail ignores wrong-server IDs, download removals and ambiguous library flags', ( + tester, + ) async { + final client = _FakeMediaServerClient(show: episode, childrenByParent: {}); + await pumpProfileNavigator(tester, (_) => mediaDetailRoute(metadata: episode), client: client); + final readsBefore = client.onDeckReads; + client.onDeckError = StateError('server is unreachable'); + + deleteExact(episode.id, serverId: 'other_server'); + deleteExact(season.id, downloadOnly: true); + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent(serverId: ServerId('other_server'), removedItemIds: {episode.id, season.id}), + ); + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent(serverId: ServerId('server_1'), itemsRemoved: true, itemsUpdated: true), + ); + await settleDetail(tester); + + expect(find.text(t.messages.mediaUnavailable), findsNothing); + expect(find.byType(FocusableActionBar), findsOneWidget); + expect(client.onDeckReads, readsBefore, reason: 'ambiguous invalidation is not a reason to refetch'); + // A child removal is not proof that its ancestor was deleted. + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent(serverId: ServerId('server_1'), removedItemIds: {'unrelated_episode'}), + ); + await settleDetail(tester); + expect(find.byType(FocusableActionBar), findsOneWidget); + }); + + testWidgets('offline detail ignores server removals but accepts a matching download ancestor deletion', ( + tester, + ) async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + JellyfinApiCache.initialize(database); + final manager = DownloadManagerService( + database: database, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + )..recoveryFuture = Future.value(); + final downloads = DownloadProvider.forTesting(downloadManager: manager, database: database); + await tester.runAsync(downloads.ensureInitialized); + downloads.debugSeedState( + ownedDownloadKeys: {episode.globalKey}, + metadata: {episode.globalKey: episode}, + downloads: { + episode.globalKey: DownloadProgress(globalKey: episode.globalKey, status: DownloadStatus.completed), + }, + ); + addTearDown(() async { + downloads.dispose(); + manager.dispose(); + await database.close(); + }); + await pumpProfileNavigator( + tester, + (_) => mediaDetailRoute(metadata: episode, isOffline: true), + downloads: downloads, + ); + + deleteExact(episode.id); + LibraryContentNotifier().notifyChanged( + LibraryChangeEvent(serverId: ServerId('server_1'), removedItemIds: {episode.id, season.id, show.id}), + ); + deleteExact(show.id, serverId: 'other_server', downloadOnly: true); + await settleDetail(tester); + expect(find.text(t.messages.mediaUnavailable), findsNothing); + expect(find.byType(FocusableActionBar), findsOneWidget); + + deleteExact(show.id, downloadOnly: true); + await settleDetail(tester); + expectUnavailable(); + }); + testWidgets('removes a covered detail and keeps the route on top', (tester) async { final profileKey = await pumpProfileNavigator(tester, homeRoute); var pushSettled = false; @@ -2534,6 +2947,7 @@ void main() { // Nothing to pop back to: the detail stays rather than stranding an // empty navigator. expect(find.byType(MediaDetailScreen), findsOneWidget); + expectUnavailable(); }); }); } @@ -2588,6 +3002,7 @@ class _FakeMediaServerClient implements MediaServerClient { final MediaItem show; final Map> childrenByParent; final Map>> childrenPageFutures; + final Map>> childrenFutures; final Map childrenPageErrors; final Future>? pendingPlayableDescendants; final Map mediaSourcesById; @@ -2605,6 +3020,9 @@ class _FakeMediaServerClient implements MediaServerClient { /// Held open to keep the on-deck half of a load in flight while the item half /// has already been published. Completer? onDeckGate; + Completer? metadataGate; + int onDeckReads = 0; + Object? onDeckError; /// Items handed to `onItemReady` — i.e. painted before on-deck settled. final earlyPaints = []; @@ -2613,6 +3031,7 @@ class _FakeMediaServerClient implements MediaServerClient { required this.show, required this.childrenByParent, this.childrenPageFutures = const {}, + this.childrenFutures = const {}, this.childrenPageErrors = const {}, this.pendingPlayableDescendants, this.mediaSourcesById = const {}, @@ -2636,15 +3055,23 @@ class _FakeMediaServerClient implements MediaServerClient { String id, { void Function(MediaItem item)? onItemReady, }) async { + onDeckReads++; + final error = onDeckError; + if (error != null) throw error; + final pendingMetadata = metadataGate; + if (pendingMetadata != null) await pendingMetadata.future; + final item = id == show.id + ? show + : childrenByParent.values.expand((items) => items).where((item) => item.id == id).firstOrNull ?? show; // Mirrors the Jellyfin shape: the item is known first, on-deck needs a // second round trip. if (onItemReady != null) { - earlyPaints.add(show); - onItemReady(show); + earlyPaints.add(item); + onItemReady(item); } final gate = onDeckGate; if (gate != null) await gate.future; - return (item: show, onDeckEpisode: onDeckEpisode); + return (item: item, onDeckEpisode: onDeckEpisode); } @override @@ -2685,7 +3112,7 @@ class _FakeMediaServerClient implements MediaServerClient { @override Future> fetchChildren(String parentId) async { - return childrenByParent[parentId] ?? const []; + return await (childrenFutures[parentId] ?? Future.value(childrenByParent[parentId] ?? const [])); } @override