From e6be5f9feff85a207ce98372069b41248e51e748 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:06:32 +0200 Subject: [PATCH] fix(player): surface a persistent HTTP 503 at open instead of retrying forever ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a server that keeps refusing the stream at open time left a silent black screen: ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached the screen. A new open-phase watchdog arms on the first 503 seen before any frame renders and, after 20s without one, synthesizes a server-http-503 error that shows an actionable dialog. Mid-stream 503s and live TV keep their existing ride-out paths. close #1830 --- lib/i18n/az.i18n.json | 2 + lib/i18n/bg.i18n.json | 2 + lib/i18n/da.i18n.json | 2 + lib/i18n/de.i18n.json | 2 + lib/i18n/en.i18n.json | 2 + lib/i18n/es.i18n.json | 2 + lib/i18n/fr.i18n.json | 2 + lib/i18n/hu.i18n.json | 2 + lib/i18n/it.i18n.json | 2 + lib/i18n/ja.i18n.json | 2 + lib/i18n/kk.i18n.json | 2 + lib/i18n/ko.i18n.json | 2 + lib/i18n/nb.i18n.json | 2 + lib/i18n/nl.i18n.json | 2 + lib/i18n/pl.i18n.json | 2 + lib/i18n/pt.i18n.json | 2 + lib/i18n/ru.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 16 +++- lib/i18n/sv.i18n.json | 2 + lib/i18n/tr.i18n.json | 2 + lib/i18n/uz.i18n.json | 2 + lib/i18n/zh-Hant.i18n.json | 2 + lib/i18n/zh.i18n.json | 2 + lib/mpv/models.dart | 11 ++- lib/mpv/player/player_base.dart | 7 +- .../video_player/open_http_503_watchdog.dart | 51 ++++++++++ .../parts/episode_navigation.dart | 2 + lib/screens/video_player/parts/errors.dart | 35 +++++++ .../video_player/parts/playback_services.dart | 2 + .../video_player/parts/playback_start.dart | 2 + .../video_player/playback_failure_action.dart | 12 ++- lib/screens/video_player_screen.dart | 7 ++ lib/utils/dialogs.dart | 23 +++++ .../sheets/video_settings_sheet.dart | 2 +- test/mpv/player_error_http_status_test.dart | 5 +- .../open_http_503_watchdog_test.dart | 92 +++++++++++++++++++ .../playback_failure_action_test.dart | 20 ++++ 38 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 lib/screens/video_player/open_http_503_watchdog.dart create mode 100644 test/screens/video_player/open_http_503_watchdog_test.dart diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 72d18dda..a428926c 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Server xətası (HTTP 500). Məhdudiyyət bu seansı rədd etdi.", "mediaUnreadableTitle": "Fayl əlçatan deyil", "mediaUnreadableBody": "Server bu elementi tapdı, lakin onun faylını oxuya bilmədi (HTTP 404). Fayl yəqin ki, köçürülüb, silinib və ya onun saxlanma yeri oflayndır. Server sahibindən faylı yoxlamasını və kitabxananı yenidən skan etməsini xahiş edin.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Jurnallar yükləndi", "logsUploadFailed": "Jurnallar yüklənə bilmədi", "logId": "Jurnal ID-si" diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index a455dae1..2892bdf1 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Грешка на сървъра (HTTP 500). Вероятно лимит за пропускателна способност/транскодиране е отхвърлил тази сесия. Помолете собственика да го коригира.", "mediaUnreadableTitle": "Файлът е недостъпен", "mediaUnreadableBody": "Сървърът намери този елемент, но не можа да прочете файла му (HTTP 404). Файлът вероятно е бил преместен, изтрит или хранилището му е офлайн. Помолете собственика на сървъра да провери файла и да сканира отново библиотеката.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Логовете са качени", "logsUploadFailed": "Неуспешно качване на логовете", "logId": "ID на лога" diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 35dc53de..35765c85 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Serverfejl (HTTP 500). En båndbredde- eller transkodningsgrænse afviste sandsynligvis sessionen. Bed ejeren om at justere den.", "mediaUnreadableTitle": "Fil utilgængelig", "mediaUnreadableBody": "Serveren fandt dette emne, men kunne ikke læse filen (HTTP 404). Filen er sandsynligvis flyttet, slettet, eller dens lager er offline. Bed serverejeren om at kontrollere filen og scanne biblioteket igen.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Logfilerne blev uploadet", "logsUploadFailed": "Logfilerne kunne ikke uploades", "logId": "Log-ID" diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index b5b20b8b..f5df6b8a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Serverfehler (HTTP 500). Vermutlich hat ein Bandbreiten- oder Transkodierungslimit diese Sitzung abgelehnt. Bitte den Besitzer, das Limit anzupassen.", "mediaUnreadableTitle": "Datei nicht verfügbar", "mediaUnreadableBody": "Der Server hat dieses Element gefunden, konnte seine Datei aber nicht lesen (HTTP 404). Die Datei wurde wahrscheinlich verschoben oder gelöscht, oder ihr Speicher ist offline. Bitte den Serverbesitzer, die Datei zu prüfen und die Mediathek neu zu scannen.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Protokolle hochgeladen", "logsUploadFailed": "Protokolle konnten nicht hochgeladen werden", "logId": "Protokoll-ID" diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index c2144553..60e2eadb 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.", "mediaUnreadableTitle": "File unavailable", "mediaUnreadableBody": "The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.", + "serverBusyTitle": "Stream unavailable", + "serverBusyBody": "The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.", "logsUploaded": "Logs uploaded", "logsUploadFailed": "Failed to upload logs", "logId": "Log ID" diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 770144c2..576c2daa 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Error del servidor (HTTP 500). Un límite de ancho de banda/transcodificación probablemente rechazó esta sesión. Pide al propietario que lo ajuste.", "mediaUnreadableTitle": "Archivo no disponible", "mediaUnreadableBody": "El servidor encontró este elemento pero no pudo leer su archivo (HTTP 404). Es probable que el archivo se haya movido o eliminado, o que su almacenamiento esté sin conexión. Pide al propietario del servidor que revise el archivo y vuelva a escanear la biblioteca.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Registros subidos", "logsUploadFailed": "Error al subir registros", "logId": "ID de registro" diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index aa4bc25f..e45d29a7 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Erreur serveur (HTTP 500). Une limite de bande passante/transcodage a probablement rejeté cette session. Demandez au propriétaire de l'ajuster.", "mediaUnreadableTitle": "Fichier indisponible", "mediaUnreadableBody": "Le serveur a trouvé cet élément mais n'a pas pu lire son fichier (HTTP 404). Le fichier a probablement été déplacé, supprimé, ou son stockage est hors ligne. Demandez au propriétaire du serveur de vérifier le fichier et de relancer l'analyse de la bibliothèque.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Journaux envoyés", "logsUploadFailed": "Échec de l’envoi des journaux", "logId": "Identifiant du journal" diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 134961ae..1ebe7965 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Szerverhiba (HTTP 500). A munkamenetet valószínűleg egy sávszélességi vagy átkódolási korlát utasította el. Kérd meg a tulajdonost a korlát módosítására.", "mediaUnreadableTitle": "A fájl nem érhető el", "mediaUnreadableBody": "A szerver megtalálta ezt az elemet, de nem tudta beolvasni a fájlját (HTTP 404). A fájl valószínűleg áthelyezésre vagy törlésre került, vagy a tárhelye offline. Kérje meg a szerver tulajdonosát, hogy ellenőrizze a fájlt, és vizsgálja újra a könyvtárat.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Naplók feltöltve", "logsUploadFailed": "Nem sikerült a naplók feltöltése", "logId": "Naplóazonosító" diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 9849d74c..ace22881 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Errore del server (HTTP 500). È probabile che un limite di banda o transcodifica abbia impedito questa sessione. Chiedi al proprietario di modificare il limite.", "mediaUnreadableTitle": "File non disponibile", "mediaUnreadableBody": "Il server ha trovato questo elemento ma non ha potuto leggere il suo file (HTTP 404). Il file è stato probabilmente spostato o eliminato, oppure il suo storage è offline. Chiedi al proprietario del server di controllare il file e di riscansionare la libreria.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Log caricati", "logsUploadFailed": "Impossibile caricare i log", "logId": "ID log" diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 13582f2d..47a180dc 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -740,6 +740,8 @@ "serverLimitBody": "サーバーエラー(HTTP 500)。帯域幅/トランスコード制限により拒否された可能性があります。所有者に調整を依頼してください。", "mediaUnreadableTitle": "ファイルを利用できません", "mediaUnreadableBody": "サーバーはこのアイテムを見つけましたが、そのファイルを読み取れませんでした(HTTP 404)。ファイルが移動・削除されたか、ストレージがオフラインの可能性があります。サーバーの管理者にファイルの確認とライブラリの再スキャンを依頼してください。", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "ログをアップロードしました", "logsUploadFailed": "ログのアップロードに失敗しました", "logId": "ログID" diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 3fc8bdd0..41f044e2 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Сервер қатесі (HTTP 500). Шектеу бұл сеансты қабылдамады.", "mediaUnreadableTitle": "Файл қолжетімсіз", "mediaUnreadableBody": "Сервер бұл элементті тапты, бірақ оның файлын оқи алмады (HTTP 404). Файл жылжытылған, өшірілген немесе оның сақтау орны қолжетімсіз болуы мүмкін. Сервер иесінен файлды тексеріп, кітапхананы қайта сканерлеуді сұраңыз.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Журналдар жүктелді", "logsUploadFailed": "Журналдарды жүктеу мүмкін болмады", "logId": "Журнал ID-сі" diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index a5d3f5ef..768c8809 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -740,6 +740,8 @@ "serverLimitBody": "서버 오류(HTTP 500). 대역폭/트랜스코딩 제한으로 세션이 거부된 것 같습니다. 소유자에게 조정을 요청하세요.", "mediaUnreadableTitle": "파일을 사용할 수 없음", "mediaUnreadableBody": "서버에서 이 항목을 찾았지만 파일을 읽을 수 없습니다(HTTP 404). 파일이 이동되거나 삭제되었거나 저장소가 오프라인 상태일 수 있습니다. 서버 관리자에게 파일 확인과 라이브러리 재스캔을 요청하세요.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "로그 업로드 완료", "logsUploadFailed": "로그 업로드 실패", "logId": "로그 ID" diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 787f7bd4..4ff95058 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Serverfeil (HTTP 500). En båndbredde-/transkodingsgrense avviste trolig økten. Be eieren justere den.", "mediaUnreadableTitle": "Fil utilgjengelig", "mediaUnreadableBody": "Serveren fant dette elementet, men kunne ikke lese filen (HTTP 404). Filen er sannsynligvis flyttet, slettet, eller lagringen er frakoblet. Be serverens eier om å sjekke filen og skanne biblioteket på nytt.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Logger lastet opp", "logsUploadFailed": "Kunne ikke laste opp logger", "logId": "Logg-ID" diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c9d71fa9..db36b315 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Serverfout (HTTP 500). Waarschijnlijk weigerde een bandbreedte-/transcodeerlimiet deze sessie. Vraag de eigenaar dit aan te passen.", "mediaUnreadableTitle": "Bestand niet beschikbaar", "mediaUnreadableBody": "De server heeft dit item gevonden maar kon het bestand niet lezen (HTTP 404). Het bestand is waarschijnlijk verplaatst of verwijderd, of de opslag is offline. Vraag de serverbeheerder om het bestand te controleren en de bibliotheek opnieuw te scannen.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Logbestanden geüpload", "logsUploadFailed": "Uploaden van logbestanden mislukt", "logId": "Logboek-ID" diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index d2035da0..ecaf0bea 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -752,6 +752,8 @@ "serverLimitBody": "Błąd serwera (HTTP 500). Limit przepustowości/transkodowania prawdopodobnie odrzucił tę sesję. Poproś właściciela o zmianę.", "mediaUnreadableTitle": "Plik niedostępny", "mediaUnreadableBody": "Serwer znalazł ten element, ale nie mógł odczytać jego pliku (HTTP 404). Plik został prawdopodobnie przeniesiony, usunięty albo jego pamięć masowa jest niedostępna. Poproś właściciela serwera o sprawdzenie pliku i ponowne przeskanowanie biblioteki.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Logi przesłane", "logsUploadFailed": "Nie udało się przesłać logów", "logId": "ID logu" diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 65bd33c2..fa06a975 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Erro do servidor (HTTP 500). Um limite de largura de banda ou transcodificação provavelmente rejeitou esta sessão. Peça ao proprietário do servidor para ajustá-lo.", "mediaUnreadableTitle": "Arquivo indisponível", "mediaUnreadableBody": "O servidor encontrou este item, mas não conseguiu ler o arquivo (HTTP 404). O arquivo provavelmente foi movido, excluído ou o armazenamento está offline. Peça ao responsável pelo servidor para verificar o arquivo e reexaminar a biblioteca.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Logs enviados", "logsUploadFailed": "Falha ao enviar logs", "logId": "ID do log" diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 83127d69..ab464982 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -752,6 +752,8 @@ "serverLimitBody": "Ошибка сервера (HTTP 500). Лимит пропускной способности/транскодирования, вероятно, отклонил сессию. Попросите владельца изменить настройки.", "mediaUnreadableTitle": "Файл недоступен", "mediaUnreadableBody": "Сервер нашёл этот элемент, но не смог прочитать его файл (HTTP 404). Вероятно, файл был перемещён, удалён или его хранилище недоступно. Попросите владельца сервера проверить файл и пересканировать библиотеку.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Логи загружены", "logsUploadFailed": "Не удалось загрузить логи", "logId": "ID лога" diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c37b14fd..0609849d 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 38874 (1767 per locale) +/// Strings: 38876 (1767 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 1da88df6..5e621942 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2209,6 +2209,12 @@ class Translations$messages$en { /// en: 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.' String get mediaUnreadableBody => 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.'; + /// en: 'Stream unavailable' + String get serverBusyTitle => 'Stream unavailable'; + + /// en: 'The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.' + String get serverBusyBody => 'The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file\'s storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file\'s storage.'; + /// en: 'Logs uploaded' String get logsUploaded => 'Logs uploaded'; @@ -6818,6 +6824,8 @@ extension on Translations { 'messages.serverLimitBody' => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.', 'messages.mediaUnreadableTitle' => 'File unavailable', 'messages.mediaUnreadableBody' => 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.', + 'messages.serverBusyTitle' => 'Stream unavailable', + 'messages.serverBusyBody' => 'The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file\'s storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file\'s storage.', 'messages.logsUploaded' => 'Logs uploaded', 'messages.logsUploadFailed' => 'Failed to upload logs', 'messages.logId' => 'Log ID', @@ -7145,10 +7153,10 @@ extension on Translations { 'explore.badge.partiallyAvailable' => 'Partly available', 'explore.badge.availableIn4k' => '4K available', 'explore.badge.requested' => 'Requested', - 'explore.badge.pendingApproval' => 'Pending approval', - 'explore.badge.processing' => 'Processing', _ => null, } ?? switch (path) { + 'explore.badge.pendingApproval' => 'Pending approval', + 'explore.badge.processing' => 'Processing', 'explore.badge.declined' => 'Declined', 'explore.badge.requestFailed' => 'Request failed', 'explore.badge.requested4k' => '4K requested', @@ -7659,10 +7667,10 @@ extension on Translations { 'performanceOverlay.displayFps' => 'Display FPS', 'performanceOverlay.avSync' => 'A/V Sync', 'performanceOverlay.dropped' => 'Dropped', - 'performanceOverlay.dvRpus' => 'DV RPUs', - 'performanceOverlay.dvRpuAverage' => 'DV RPU Avg', _ => null, } ?? switch (path) { + 'performanceOverlay.dvRpus' => 'DV RPUs', + 'performanceOverlay.dvRpuAverage' => 'DV RPU Avg', 'performanceOverlay.dvSampleAverage' => 'DV Sample Avg', 'performanceOverlay.maxLuma' => 'Max Luma', 'performanceOverlay.minLuma' => 'Min Luma', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 6b520e2e..6e8125dd 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Serverfel (HTTP 500). En bandbredds-/transkodningsgräns avvisade troligen sessionen. Be ägaren justera den.", "mediaUnreadableTitle": "Filen är otillgänglig", "mediaUnreadableBody": "Servern hittade objektet men kunde inte läsa dess fil (HTTP 404). Filen har troligen flyttats, tagits bort eller så är dess lagring offline. Be serverägaren kontrollera filen och skanna om biblioteket.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Loggarna har laddats upp", "logsUploadFailed": "Det gick inte att ladda upp loggarna", "logId": "Logg-ID" diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index c8078e0f..639e27b5 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Sunucu hatası (HTTP 500). Muhtemelen bir bant genişliği/kod dönüştürme sınırı bu oturumu reddetti. Sunucu sahibinden bunu ayarlamasını isteyin.", "mediaUnreadableTitle": "Dosya kullanılamıyor", "mediaUnreadableBody": "Sunucu bu ögeyi buldu ancak dosyasını okuyamadı (HTTP 404). Dosya büyük olasılıkla taşındı, silindi veya depolama alanı çevrimdışı. Sunucu sahibinden dosyayı kontrol edip kitaplığı yeniden taramasını isteyin.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Günlükler yüklendi", "logsUploadFailed": "Günlükler yüklenemedi", "logId": "Günlük Kimliği (ID)" diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index ee3bc8b4..7f8f110d 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -744,6 +744,8 @@ "serverLimitBody": "Server xatoligi (HTTP 500). Cheklov ushbu seansni rad etdi.", "mediaUnreadableTitle": "Fayl mavjud emas", "mediaUnreadableBody": "Server bu elementni topdi, lekin uning faylini oʻqiy olmadi (HTTP 404). Fayl koʻchirilgan, oʻchirilgan yoki uning saqlash joyi oflayn boʻlishi mumkin. Server egasidan faylni tekshirishni va kutubxonani qayta skanerlashni soʻrang.", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "Jurnallar yuklandi", "logsUploadFailed": "Jurnallarni yuklab boʻlmadi", "logId": "Jurnal ID-si" diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index cb9e7e3c..d98fd64b 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -740,6 +740,8 @@ "serverLimitBody": "伺服器錯誤(HTTP 500)。伺服器的頻寬或轉碼限制可能拒絕此播放要求。請聯絡伺服器擁有者調整設定。", "mediaUnreadableTitle": "檔案無法使用", "mediaUnreadableBody": "伺服器找到了此項目,但無法讀取其檔案(HTTP 404)。檔案可能已移動、刪除,或其儲存空間離線。請聯絡伺服器擁有者檢查檔案並重新掃描媒體庫。", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "日誌已上傳", "logsUploadFailed": "上傳日誌失敗", "logId": "日誌 ID" diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index c01a5d6b..df7014dc 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -740,6 +740,8 @@ "serverLimitBody": "服务器错误(HTTP 500)。此次会话可能因带宽或转码限制而被拒绝。请联系服务器所有者调整限制。", "mediaUnreadableTitle": "文件不可用", "mediaUnreadableBody": "服务器找到了此项目,但无法读取其文件(HTTP 404)。文件可能已被移动、删除,或其存储已离线。请联系服务器管理员检查文件并重新扫描媒体库。", + "serverBusyTitle": "", + "serverBusyBody": "", "logsUploaded": "日志已上传", "logsUploadFailed": "上传日志失败", "logId": "日志 ID" diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index 521f15d1..3358fd94 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -12,7 +12,9 @@ final RegExp _httpStatusPattern = RegExp(r'\b(?:HTTP error |Response code: )(\d{ /// Server statuses that end playback outright: nothing client-side recovers a /// transcoding-limit rejection or a file the server cannot read. Everything -/// else (notably the 503 the reconnect path retries) is transient. +/// else is transient — notably 503, which the reconnect path retries +/// mid-stream (#1520) and the player screen's open-phase watchdog bounds at +/// open time instead of latching here (#1830). const Set fatalPlaybackHttpStatuses = {404, 500}; /// [cause] is an optional machine-readable tag (e.g. `server-http-500`), @@ -32,6 +34,13 @@ sealed class PlayerError with _$PlayerError { /// on unavailable storage), so no retry or backend switch can recover it. static const String serverHttp404 = 'server-http-404'; + /// Cause tag for a persistent HTTP 503 on the media stream during the open + /// phase. Mid-stream 503 is transient — ffmpeg reconnects through server + /// restarts (#1520) — so this tag is never derived from a raw status: only + /// the player screen's open-phase watchdog synthesizes it, after a refused + /// open has produced no frame for the whole patience window (#1830). + static const String serverHttp503 = 'server-http-503'; + /// HTTP status [logText] reports, or null when it names none. /// /// A [PlayerError] carries no status field: mpv only ever tells us the diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index b4b8f6c1..4100a69b 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -1344,8 +1344,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { /// stream with [status]. Used by the in-player debug buttons to preview the /// end-to-end detection path without needing a real misbehaving server: 500 /// is a shared-user bandwidth/transcoding limit, 404 a file the server can no - /// longer read. The warn-level log mirrors ffmpeg's real wording, which is - /// what [PlayerError.httpStatusFromLog] parses. + /// longer read, 503 a server that keeps refusing the stream (the error event + /// stands in for the open-phase watchdog, which cannot arm once playback has + /// a frame). The warn-level log mirrors ffmpeg's real wording, which is what + /// [PlayerError.httpStatusFromLog] parses. void debugSimulateServerHttpError(int status) { if (_disposed) return; logController.add( @@ -1354,6 +1356,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final cause = switch (status) { 500 => PlayerError.serverHttp500, 404 => PlayerError.serverHttp404, + 503 => PlayerError.serverHttp503, _ => null, }; errorController.add(PlayerError('HTTP $status', cause: cause)); diff --git a/lib/screens/video_player/open_http_503_watchdog.dart b/lib/screens/video_player/open_http_503_watchdog.dart new file mode 100644 index 00000000..11179d34 --- /dev/null +++ b/lib/screens/video_player/open_http_503_watchdog.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +/// How long an open gets to produce a first frame after the server starts +/// answering the stream with HTTP 503 before the failure is surfaced. +/// +/// ffmpeg's reconnect loop retries 503 with exponential backoff (0s, 1s, 3s, +/// 7s, ...), so this window covers several attempts — enough for the short +/// server restart #1520 rides out mid-stream, while a server still refusing +/// after all of them is treated as down for this item (#1830). +const Duration openHttp503Patience = Duration(seconds: 20); + +/// Bounds a media open the server answers with HTTP 503. +/// +/// Mid-stream, ffmpeg deliberately reconnects on 503 forever (#1520) and mpv +/// just reports buffering. At open time — before any frame has rendered — +/// that same loop is a silent black screen with no error event (#1830): +/// nothing client-side ever fails. This watchdog arms on the first 503 +/// observed while the open is still frameless and calls [onPersistent] if +/// [disarm] has not been called within [patience]. +/// +/// The caller decides which opens are eligible (live TV stays out — its +/// fallback ladder owns retries there) and owns the synthesized error. Only +/// the timer bookkeeping lives here so it is testable with `fakeAsync`. +class OpenHttp503Watchdog { + OpenHttp503Watchdog({required this.onPersistent, this.patience = openHttp503Patience}); + + final Duration patience; + final void Function() onPersistent; + + Timer? _timer; + + bool get isArmed => _timer != null; + + /// A 503 was observed while the current open has no frame yet. Arms once; + /// later 503s from the same reconnect loop keep the original deadline. + void onOpenPhase503() { + _timer ??= Timer(patience, _fire); + } + + /// The open produced output, was replaced, or the screen is going away: + /// any further 503s belong to the reconnect path (or the next open). + void disarm() { + _timer?.cancel(); + _timer = null; + } + + void _fire() { + _timer = null; + onPersistent(); + } +} diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index f8eb753d..4b3fea76 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -771,6 +771,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { shouldContinue: isCurrentReload, onOpening: () { _hasRenderedFirstFrame = false; + // 503s observed from here on belong to the replacement open. + _http503Watchdog.disarm(); }, onOpened: () { // The player now owns the new file — publish the session at the diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index 96ce3c50..1a043b82 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -37,6 +37,10 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { _hasFatalPlaybackError = true; _progressTracker?.stopTracking(); unawaited(_showMediaUnreadableDialog()); + case PlaybackFailureAction.serverBusyDialog: + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); + unawaited(_showServerBusyDialog()); // The bounded retry operation owns errors raised while applying/opening // its replacement stream. Do not let the same error close the route. case PlaybackFailureAction.ignore: @@ -59,12 +63,33 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { void _onPlayerLog(PlayerLog log) { final status = PlayerError.httpStatusFromLog(log.text); if (status != null && fatalPlaybackHttpStatuses.contains(status)) _fatalHttpStatuses.add(status); + // An open the server answers with 503 never fails on its own: ffmpeg's + // reconnect loop retries 503 forever and mpv just reports buffering + // (#1830). Bound it. Live TV stays out — its ladder owns retries there. + // A sidecar subtitle fetch shares this log stream and could arm the + // watchdog too, but a first frame disarms it, so that only matters when + // the primary media is itself stuck. + if (status == 503 && !widget.isLive && !_hasRenderedFirstFrame && !_hasFatalPlaybackError) { + _http503Watchdog.onOpenPhase503(); + } if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) { appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}'); _lastLogError = _redactPlayerError(log.text.trim()); } } + /// The open-phase 503 watchdog's deadline passed with no first frame: the + /// server is still refusing the stream. Synthesize the error the reconnect + /// loop will never raise on its own so the normal failure policy runs. + void _onOpenHttp503Persistent() { + if (!mounted || _isExiting.value || _hasRenderedFirstFrame || _hasFatalPlaybackError) return; + appLogger.w( + 'Server kept answering the stream with HTTP 503 for ' + '${openHttp503Patience.inSeconds}s without a first frame — giving up on this open', + ); + _onPlayerError(const PlayerError('HTTP 503', cause: PlayerError.serverHttp503)); + } + String _redactPlayerError(String message) => LogRedactionManager.redact(message); Future _showServerLimitDialog() async { @@ -79,6 +104,16 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { if (mounted) unawaited(_handleBackButton()); } + Future _showServerBusyDialog() async { + if (!mounted) return; + // The reconnect loop is still running behind the modal; pause so a server + // that recovers mid-dialog cannot start playing under it. Best-effort — + // the route is left on dialog close either way. + unawaited(player?.pause().catchError((_) {})); + await showServerBusyDialog(context); + if (mounted) unawaited(_handleBackButton()); + } + /// Handle notification when native player switched from ExoPlayer to MPV Future _onBackendSwitched() async { _playerBackendLabel = 'mpv'; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index f7f85c2b..b8f01506 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -41,6 +41,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _hasRenderedFirstFrame = true; _hasFirstFrame.value = true; + _http503Watchdog.disarm(); unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'))); final progressTracker = _progressTracker; if (progressTracker != null && currentPlayer.state.isActive) { @@ -323,6 +324,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _isBuffering.value = false; _hasFirstFrame.value = false; _hasRenderedFirstFrame = false; + _http503Watchdog.disarm(); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index b7e83f29..1ef39e91 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -7,6 +7,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final attempt = _beginPlaybackAttempt(currentPlayer); _hasRenderedFirstFrame = false; _hasFatalPlaybackError = false; + // 503s observed from here on belong to this attempt's open. + _http503Watchdog.disarm(); // Live TV mode: bypass standard playback initialization if (widget.isLive) { diff --git a/lib/screens/video_player/playback_failure_action.dart b/lib/screens/video_player/playback_failure_action.dart index 4f708f9c..e68cc48d 100644 --- a/lib/screens/video_player/playback_failure_action.dart +++ b/lib/screens/video_player/playback_failure_action.dart @@ -11,6 +11,9 @@ enum PlaybackFailureAction { /// Server could not read the file behind the item (HTTP 404). mediaUnreadableDialog, + /// Server kept refusing the stream with HTTP 503 for the whole open phase. + serverBusyDialog, + /// A live retry already owns the player and its error UI. ignore, @@ -34,7 +37,10 @@ enum PlaybackFailureAction { /// playlist, or a transcode session restarting under us, answers 404 mid-stream, /// and the bounded ladder exists to ride that out. Only on-demand playback /// treats 404 as terminal, where it does mean the file is unreadable. 500 stays -/// terminal for both — a limit rejection is not something a retry clears. +/// terminal for both — a limit rejection is not something a retry clears. 503 +/// arrives only as the open-phase watchdog's cause tag (it never latches into +/// [fatalHttpStatuses]); by then the reconnect loop has had its chances, so +/// on-demand playback surfaces it while live TV keeps its ladder. PlaybackFailureAction resolvePlaybackFailureAction({ required String? cause, required Set fatalHttpStatuses, @@ -51,6 +57,10 @@ PlaybackFailureAction resolvePlaybackFailureAction({ return PlaybackFailureAction.mediaUnreadableDialog; } + if (!isLive && cause == PlayerError.serverHttp503) { + return PlaybackFailureAction.serverBusyDialog; + } + if (isLive) { if (liveRetrying) return PlaybackFailureAction.ignore; if (liveFallbackLevel < maxLiveFallbackLevel) return PlaybackFailureAction.liveRetry; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e5a94bff..e6bf5f5d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -91,6 +91,7 @@ import 'video_player/live_stream_retry.dart'; import 'video_player/live_timeline_report.dart'; import 'video_player/wakelock_controller.dart'; import 'video_player/playback_failure_action.dart'; +import 'video_player/open_http_503_watchdog.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; import 'video_player/tv_background_suspend_policy.dart'; @@ -1765,6 +1766,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _autoPlayTimer?.cancel(); _tvBackgroundPlayerSuspendTimer?.cancel(); + _http503Watchdog.disarm(); _stillWatchingTimer?.cancel(); @@ -1977,6 +1979,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// transient status must never mask the fatal one that follows. final Set _fatalHttpStatuses = {}; + /// Bounds an open the server keeps answering with HTTP 503 — that loop + /// otherwise never raises an error (#1830). Armed from [_onPlayerLog], + /// disarmed on first frame, on every new-open reset, and in [dispose]. + late final OpenHttp503Watchdog _http503Watchdog = OpenHttp503Watchdog(onPersistent: _onOpenHttp503Persistent); + // OS Media Controls Integration /// Navigate to a specific queue item (called from QueueSheet) diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index c1505e04..3c4a6a37 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -152,6 +152,29 @@ Future showMediaUnreadableDialog(BuildContext context) async { ); } +/// Shows the server-side 503 modal: the server kept refusing to serve the +/// stream for the whole open-phase watchdog window, so the reconnect loop is +/// not going to start this playback (#1830). +Future showServerBusyDialog(BuildContext context) async { + await showScopedDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(t.messages.serverBusyTitle), + content: Text(t.messages.serverBusyBody), + actions: [ + DialogActionButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(), + label: t.common.close, + isPrimary: true, + style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), + ), + ], + ), + ); +} + /// Shows a delete confirmation dialog. /// Convenience wrapper around [showConfirmDialog] with destructive styling. Future showDeleteConfirmation( diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 439d4e7d..c148ed45 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -727,7 +727,7 @@ class _VideoSettingsSheetState extends State { ), if (kDebugMode) - for (final status in const [500, 404]) + for (final status in const [500, 404, 503]) FocusableListTile( leading: AppIcon(Symbols.bug_report_rounded, fill: 1, color: tokens(context).textMuted), title: Text('Simulate HTTP $status from server'), diff --git a/test/mpv/player_error_http_status_test.dart b/test/mpv/player_error_http_status_test.dart index d4c398a0..7a465041 100644 --- a/test/mpv/player_error_http_status_test.dart +++ b/test/mpv/player_error_http_status_test.dart @@ -42,10 +42,11 @@ void main() { }); group('fatalPlaybackHttpStatuses', () { - test('excludes the 503 the reconnect path deliberately retries', () { // stream-lavf-o sets reconnect_on_http_error=503, so a 503 is expected - // mid-playback and must not latch as fatal. + // mid-playback and must not latch as fatal. At open time the player + // screen's OpenHttp503Watchdog bounds the loop instead — only its + // synthesized cause tag, never a raw latched status, ends playback. expect(fatalPlaybackHttpStatuses.contains(503), isFalse); expect(PlayerError.httpStatusFromLog('http: HTTP error 503 Service Unavailable'), 503); }); diff --git a/test/screens/video_player/open_http_503_watchdog_test.dart b/test/screens/video_player/open_http_503_watchdog_test.dart new file mode 100644 index 00000000..9871382c --- /dev/null +++ b/test/screens/video_player/open_http_503_watchdog_test.dart @@ -0,0 +1,92 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/video_player/open_http_503_watchdog.dart'; + +// An open the server answers with 503 never fails on its own: ffmpeg's +// reconnect loop retries 503 forever (#1520) and mpv just reports buffering, +// so a server that keeps refusing leaves a silent black screen (#1830). The +// watchdog is the only thing that ends that open; these tests pin its arming, +// deadline, and disarm contract. +void main() { + test('fires once patience elapses after the first open-phase 503', () { + fakeAsync((async) { + var fired = 0; + final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++); + + watchdog.onOpenPhase503(); + expect(watchdog.isArmed, isTrue); + + async.elapse(openHttp503Patience - const Duration(milliseconds: 1)); + expect(fired, 0); + + async.elapse(const Duration(milliseconds: 1)); + expect(fired, 1); + expect(watchdog.isArmed, isFalse); + }); + }); + + test('repeated 503s from the reconnect loop keep the original deadline', () { + fakeAsync((async) { + var fired = 0; + final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++); + + watchdog.onOpenPhase503(); + async.elapse(openHttp503Patience - const Duration(seconds: 1)); + // ffmpeg retries with backoff; each retry logs another 503. None of + // them may push the deadline out, or a permanently refusing server + // keeps the watchdog pending forever — the exact bug it exists to end. + watchdog.onOpenPhase503(); + watchdog.onOpenPhase503(); + + async.elapse(const Duration(seconds: 1)); + expect(fired, 1); + }); + }); + + test('disarming before the deadline prevents the failure', () { + fakeAsync((async) { + var fired = 0; + final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++); + + watchdog.onOpenPhase503(); + async.elapse(openHttp503Patience - const Duration(seconds: 1)); + // First frame rendered: the open succeeded, later 503s are the + // reconnect path's business. + watchdog.disarm(); + expect(watchdog.isArmed, isFalse); + + async.elapse(openHttp503Patience * 2); + expect(fired, 0); + }); + }); + + test('a 503 after disarm re-arms with a fresh deadline', () { + fakeAsync((async) { + var fired = 0; + final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++); + + watchdog.onOpenPhase503(); + async.elapse(openHttp503Patience - const Duration(seconds: 1)); + watchdog.disarm(); + + // A new open starts 503ing: the old open's elapsed time must not count + // against it. + watchdog.onOpenPhase503(); + async.elapse(openHttp503Patience - const Duration(seconds: 1)); + expect(fired, 0); + + async.elapse(const Duration(seconds: 1)); + expect(fired, 1); + }); + }); + + test('never fires without an observed 503', () { + fakeAsync((async) { + var fired = 0; + OpenHttp503Watchdog(onPersistent: () => fired++); + + async.elapse(openHttp503Patience * 3); + expect(fired, 0); + }); + }); +} diff --git a/test/screens/video_player/playback_failure_action_test.dart b/test/screens/video_player/playback_failure_action_test.dart index 94a9c36f..75a6aaa6 100644 --- a/test/screens/video_player/playback_failure_action_test.dart +++ b/test/screens/video_player/playback_failure_action_test.dart @@ -59,6 +59,26 @@ void main() { }); }); + group('HTTP 503', () { + test('the open-phase watchdog tag is terminal for on-demand playback', () { + // The tag only exists once the watchdog has already waited out the + // reconnect loop's chances (#1830) — no further retry recovers it. + expect(resolve(cause: PlayerError.serverHttp503), PlaybackFailureAction.serverBusyDialog); + }); + + test('live TV keeps its fallback ladder', () { + // The watchdog never arms for live opens, but a defensively passed tag + // must still ride the ladder rather than kill a recoverable stream. + expect(resolve(cause: PlayerError.serverHttp503, isLive: true), PlaybackFailureAction.liveRetry); + }); + + test('a latched fatal status outranks the watchdog tag', () { + // A 500/404 seen on the same open is the more specific diagnosis. + expect(resolve(cause: PlayerError.serverHttp503, statuses: {500}), PlaybackFailureAction.serverLimitDialog); + expect(resolve(cause: PlayerError.serverHttp503, statuses: {404}), PlaybackFailureAction.mediaUnreadableDialog); + }); + }); + group('live fallback ladder', () { test('climbs every rung below the bound', () { for (var level = 0; level < maxLiveFallbackLevel; level++) {