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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "Услуги",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サービス",
|
||||
|
||||
@@ -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": "Қызметтер",
|
||||
|
||||
@@ -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": "서비스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сервисы",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "外部服務",
|
||||
|
||||
@@ -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": "服务",
|
||||
|
||||
@@ -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<void> 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<void> 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<void> _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<void> _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<void> _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<void> _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<void> _handleDownloadButtonPressed(MediaItem metadata) async {
|
||||
Future<void> _handleDownloadButtonPressed(BuildContext context, MediaItem metadata) async {
|
||||
if (!_canUseDetail || !context.mounted) return;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
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,
|
||||
|
||||
@@ -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<void> _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;
|
||||
|
||||
@@ -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<bool> mediaDetailRoute({
|
||||
}
|
||||
|
||||
class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
MediaItem? _fullMetadata;
|
||||
MediaItem? _onDeckEpisode;
|
||||
bool _isLoadingMetadata = true;
|
||||
bool _isDeleted = false;
|
||||
StreamSubscription<LibraryChangeEvent>? _libraryContentSubscription;
|
||||
|
||||
bool get _canUseDetail => mounted && !_isDeleted;
|
||||
List<MediaItem>? _extras;
|
||||
List<MediaHub> _relatedHubs = [];
|
||||
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
|
||||
@@ -462,9 +462,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
@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<String>? get watchedIds {
|
||||
final keys = <String>{_metadata.id};
|
||||
@@ -495,8 +494,39 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return keys;
|
||||
}
|
||||
|
||||
Set<String> 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<String> get deletionIds => {...?watchedIds, ..._terminalDeletionIds};
|
||||
|
||||
@override
|
||||
Set<String>? 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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
|
||||
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<MediaDetailScreen>
|
||||
|
||||
@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<MediaDetailScreen>
|
||||
|
||||
if (epIndex != -1 && _showEpisodesDirectly) {
|
||||
if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) {
|
||||
_closeDeletedDetail();
|
||||
_markDetailDeleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -641,7 +678,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// 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<MediaDetailScreen>
|
||||
|
||||
// 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<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
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<MediaDetailScreen>
|
||||
|
||||
/// Lightweight refresh for watch state changes - no loader, preserves scroll
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
|
||||
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<MediaDetailScreen>
|
||||
_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<MediaDetailScreen>
|
||||
_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<MediaDetailScreen>
|
||||
|
||||
_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<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_libraryContentSubscription?.cancel();
|
||||
for (final source in _watchlistListenedSources) {
|
||||
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
|
||||
}
|
||||
@@ -1426,7 +1478,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
],
|
||||
);
|
||||
|
||||
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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
if (!_canUseDetail) return;
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
@@ -1482,7 +1535,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final cachedMetadata = serverId == null
|
||||
? null
|
||||
: await context.read<DownloadProvider>().lookupOfflineMetadata(serverId, _metadata.id);
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
setState(() {
|
||||
_fullMetadata = cachedMetadata ?? _metadata;
|
||||
_isLoadingMetadata = false;
|
||||
@@ -1568,12 +1621,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadSeasons() async {
|
||||
if (!_canUseDetail) return;
|
||||
_seasonsCompleter = Completer<void>();
|
||||
setStateIfMounted(() {
|
||||
_isLoadingSeasons = true;
|
||||
@@ -1642,6 +1696,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
: Future.value(<String, dynamic>{});
|
||||
|
||||
final results = await Future.wait([seasonsFuture, prefsFuture]);
|
||||
if (!_canUseDetail) return;
|
||||
final seasons = results.first as List<MediaItem>;
|
||||
final prefs = results[1] as Map<String, dynamic>;
|
||||
|
||||
@@ -1698,6 +1753,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
} 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<MediaDetailScreen>
|
||||
|
||||
/// Load seasons from downloaded episodes (offline mode)
|
||||
void _loadSeasonsFromDownloads() {
|
||||
if (!_canUseDetail) return;
|
||||
_seasonsCompleter = Completer<void>();
|
||||
setState(() {
|
||||
_isLoadingSeasons = true;
|
||||
@@ -1796,6 +1853,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
/// Load episodes from downloaded content for a season
|
||||
void _loadEpisodesFromDownloads() {
|
||||
if (!_canUseDetail) return;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final seasonEpisodes = _downloadedEpisodesForSeason(downloadProvider, _metadata.parentId ?? '', _metadata.index);
|
||||
|
||||
@@ -1833,6 +1891,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward
|
||||
/// the end, so a 1000+ episode season never blocks on one giant request.
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
/// 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<void> _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<MediaDetailScreen>
|
||||
|
||||
/// Load extras (trailers, featurettes, behind-the-scenes, etc.).
|
||||
Future<void> _loadExtras() async {
|
||||
if (!_canUseDetail) return;
|
||||
void markLoaded() {
|
||||
if (!_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_hasLoadedExtras = true;
|
||||
});
|
||||
@@ -2089,6 +2155,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
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<MediaDetailScreen>
|
||||
/// Backend-neutral — both Plex and Jellyfin implement
|
||||
/// [MediaServerClient.fetchRelatedHubs].
|
||||
Future<void> _loadRelatedHubs() async {
|
||||
if (!_canUseDetail) return;
|
||||
void markLoaded() {
|
||||
if (!_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_hasLoadedRelatedHubs = true;
|
||||
});
|
||||
@@ -2139,6 +2208,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
try {
|
||||
final relatedHubs = await client.fetchRelatedHubs(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
setStateIfMounted(() {
|
||||
_relatedHubs = relatedHubs;
|
||||
@@ -2244,7 +2314,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
|
||||
_initialEpisodePagingInFlight = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _episodesContainInitialTarget) {
|
||||
if (!_canUseDetail || _episodesContainInitialTarget) {
|
||||
_initialEpisodePagingInFlight = false;
|
||||
return;
|
||||
}
|
||||
@@ -2555,7 +2625,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
metadata: episode,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: () => unawaited(_refreshItemInPlace(episode)),
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
},
|
||||
onRefresh: widget.isOffline ? null : _refreshItemInPlace,
|
||||
@@ -2862,6 +2933,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _fetchAllEpisodes() async {
|
||||
if (!_canUseDetail) return;
|
||||
final generation = ++_episodesLoadGeneration;
|
||||
if (_seasons.isEmpty) {
|
||||
setStateIfMounted(() {
|
||||
@@ -2904,6 +2976,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
});
|
||||
} 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<MediaDetailScreen>
|
||||
/// Load the next page of the flatten/all-episodes list (single-season show or
|
||||
/// season detail) on demand and append it.
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
|
||||
/// Load the next unwatched episode for offline mode (offline OnDeck)
|
||||
Future<void> _loadOfflineOnDeckEpisode() async {
|
||||
if (!_canUseDetail) return;
|
||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
setStateIfMounted(() {
|
||||
_onDeckEpisode = nextEpisode;
|
||||
@@ -3108,7 +3184,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
|
||||
// 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<DownloadProvider>();
|
||||
@@ -3169,6 +3245,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
if (firstEpisode == null) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.noEpisodesFound);
|
||||
@@ -3188,17 +3266,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
/// so Plex uses its server-side `/playQueues` and Jellyfin builds a local
|
||||
/// shuffled queue from `fetchClientSideEpisodeQueue`.
|
||||
Future<void> _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<MediaDetailScreen>
|
||||
|
||||
@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<MediaDetailScreen>
|
||||
metadata: item,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: () => unawaited(_refreshItemInPlace(item)),
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -4777,7 +4865,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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,
|
||||
|
||||
@@ -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<FilledButton>(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<void> settleDetail(WidgetTester tester) async {
|
||||
@@ -2421,14 +2490,16 @@ void main() {
|
||||
/// fallbacks all share) whose initial route is [initialRoute].
|
||||
Future<GlobalKey<NavigatorState>> pumpProfileNavigator(
|
||||
WidgetTester tester,
|
||||
Route<dynamic> Function(RouteSettings settings) initialRoute,
|
||||
) async {
|
||||
Route<dynamic> 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<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: provider),
|
||||
if (downloads != null) ChangeNotifierProvider<DownloadProvider>.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<void>(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<List<MediaItem>>();
|
||||
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<List<MediaItem>>();
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<void>()
|
||||
..onDeckGate = Completer<void>()
|
||||
..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<void>();
|
||||
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<void>.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<String, List<MediaItem>> childrenByParent;
|
||||
final Map<String, Future<List<MediaItem>>> childrenPageFutures;
|
||||
final Map<String, Future<List<MediaItem>>> childrenFutures;
|
||||
final Map<String, Object> childrenPageErrors;
|
||||
final Future<List<MediaItem>>? pendingPlayableDescendants;
|
||||
final Map<String, MediaSourceInfo> 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<void>? onDeckGate;
|
||||
Completer<void>? metadataGate;
|
||||
int onDeckReads = 0;
|
||||
Object? onDeckError;
|
||||
|
||||
/// Items handed to `onItemReady` — i.e. painted before on-deck settled.
|
||||
final earlyPaints = <MediaItem>[];
|
||||
@@ -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<List<MediaItem>> fetchChildren(String parentId) async {
|
||||
return childrenByParent[parentId] ?? const [];
|
||||
return await (childrenFutures[parentId] ?? Future.value(childrenByParent[parentId] ?? const <MediaItem>[]));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user