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