From 24a041977bc3b7438cdc3eb67c81730475fb154c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:28:31 +0200 Subject: [PATCH] fix(sheets): size sheets to their content instead of 75% of the window Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load. --- lib/i18n/az.i18n.json | 1 + lib/i18n/bg.i18n.json | 1 + lib/i18n/da.i18n.json | 1 + lib/i18n/de.i18n.json | 1 + lib/i18n/en.i18n.json | 1 + lib/i18n/es.i18n.json | 1 + lib/i18n/fr.i18n.json | 1 + lib/i18n/hu.i18n.json | 1 + lib/i18n/it.i18n.json | 1 + lib/i18n/ja.i18n.json | 1 + lib/i18n/kk.i18n.json | 1 + lib/i18n/ko.i18n.json | 1 + lib/i18n/nb.i18n.json | 1 + lib/i18n/nl.i18n.json | 1 + lib/i18n/pl.i18n.json | 1 + lib/i18n/pt.i18n.json | 1 + lib/i18n/ru.i18n.json | 1 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_az.g.dart | 6 +- lib/i18n/strings_bg.g.dart | 6 +- lib/i18n/strings_da.g.dart | 6 +- lib/i18n/strings_de.g.dart | 6 +- lib/i18n/strings_en.g.dart | 8 +- lib/i18n/strings_es.g.dart | 6 +- lib/i18n/strings_fr.g.dart | 6 +- lib/i18n/strings_hu.g.dart | 6 +- lib/i18n/strings_it.g.dart | 6 +- lib/i18n/strings_ja.g.dart | 6 +- lib/i18n/strings_kk.g.dart | 6 +- lib/i18n/strings_ko.g.dart | 6 +- lib/i18n/strings_nb.g.dart | 6 +- lib/i18n/strings_nl.g.dart | 6 +- lib/i18n/strings_pl.g.dart | 6 +- lib/i18n/strings_pt.g.dart | 6 +- lib/i18n/strings_ru.g.dart | 6 +- lib/i18n/strings_sv.g.dart | 6 +- lib/i18n/strings_tr.g.dart | 6 +- lib/i18n/strings_uz.g.dart | 6 +- lib/i18n/strings_zh.g.dart | 6 +- lib/i18n/strings_zh_Hant.g.dart | 6 +- lib/i18n/sv.i18n.json | 1 + lib/i18n/tr.i18n.json | 1 + lib/i18n/uz.i18n.json | 1 + lib/i18n/zh-Hant.i18n.json | 1 + lib/i18n/zh.i18n.json | 1 + .../libraries/filters_bottom_sheet.dart | 53 +- .../libraries/tabs/library_browse_tab.dart | 2 - lib/screens/livetv/record_options_sheet.dart | 7 + .../livetv/reorder_favorites_sheet.dart | 3 +- lib/screens/main_screen.dart | 2 - .../widgets/watch_together_overlay.dart | 1 + lib/widgets/bottom_sheet_page_scaffold.dart | 19 +- lib/widgets/file_info_bottom_sheet.dart | 4 +- lib/widgets/library_management_sheet.dart | 12 +- lib/widgets/overlay_sheet.dart | 65 ++- lib/widgets/rating_bottom_sheet.dart | 123 ++--- .../video_controls/sheets/chapter_sheet.dart | 14 +- .../video_controls/sheets/queue_sheet.dart | 9 +- .../sheets/sheet_column_header.dart | 5 + .../sheets/sheet_selection_column.dart | 8 +- .../sheets/sheet_split_columns.dart | 39 ++ .../sheets/subtitle_search_sheet.dart | 8 + .../video_controls/sheets/track_sheet.dart | 11 +- .../sheets/version_quality_sheet.dart | 10 +- .../sheets/video_settings_sheet.dart | 24 + .../widgets/sleep_timer_active_status.dart | 1 + .../widgets/sleep_timer_content.dart | 44 +- .../libraries/filters_bottom_sheet_test.dart | 75 ++- test/widgets/overlay_sheet_test.dart | 457 ++++++++++++++++++ test/widgets/rating_bottom_sheet_test.dart | 190 ++++++++ test/widgets/subtitle_search_sheet_test.dart | 73 +++ 71 files changed, 1210 insertions(+), 207 deletions(-) create mode 100644 lib/widgets/video_controls/sheets/sheet_split_columns.dart create mode 100644 test/widgets/rating_bottom_sheet_test.dart diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 583df7d3..72d18dda 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Hissələr əlçatan deyil", "queue": "Növbə", "noQueueItems": "Növbədə element yoxdur", + "noAudioDevicesAvailable": "Səs cihazları əlçatan deyil", "searchSubtitles": "Altyazı axtar", "language": "Dil", "noSubtitlesFound": "Altyazı tapılmadı", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 236133fe..a455dae1 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Няма налични глави", "queue": "Опашка", "noQueueItems": "Няма елементи в опашката", + "noAudioDevicesAvailable": "Няма налични аудио устройства", "searchSubtitles": "Търсене на субтитри", "language": "Език", "noSubtitlesFound": "Не са намерени субтитри", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 26563a15..35dc53de 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Ingen kapitler tilgængelige", "queue": "Kø", "noQueueItems": "Ingen elementer i køen", + "noAudioDevicesAvailable": "Ingen lydenheder tilgængelige", "searchSubtitles": "Søg undertekster", "language": "Sprog", "noSubtitlesFound": "Ingen undertekster fundet", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 5ab6d7dc..b5b20b8b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Keine Kapitel verfügbar", "queue": "Warteschlange", "noQueueItems": "Keine Elemente in der Warteschlange", + "noAudioDevicesAvailable": "Keine Audiogeräte verfügbar", "searchSubtitles": "Untertitel suchen", "language": "Sprache", "noSubtitlesFound": "Keine Untertitel gefunden", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4f0eebf7..c2144553 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "No chapters available", "queue": "Queue", "noQueueItems": "No items in queue", + "noAudioDevicesAvailable": "No audio devices available", "searchSubtitles": "Search Subtitles", "language": "Language", "noSubtitlesFound": "No subtitles found", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 41858282..770144c2 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "No hay capítulos disponibles", "queue": "Cola", "noQueueItems": "No hay elementos en la cola", + "noAudioDevicesAvailable": "No hay dispositivos de audio disponibles", "searchSubtitles": "Buscar subtítulos", "language": "Idioma", "noSubtitlesFound": "No se encontraron subtítulos", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 51bdfc07..aa4bc25f 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Aucun chapitre disponible", "queue": "File d'attente", "noQueueItems": "Aucun élément dans la file d'attente", + "noAudioDevicesAvailable": "Aucun appareil audio disponible", "searchSubtitles": "Rechercher des sous-titres", "language": "Langue", "noSubtitlesFound": "Aucun sous-titre trouvé", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index b985badb..134961ae 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Nincsenek elérhető fejezetek", "queue": "Lejátszási sor", "noQueueItems": "Nincsenek elemek a sorban", + "noAudioDevicesAvailable": "Nincsenek elérhető audioeszközök", "searchSubtitles": "Feliratok keresése", "language": "Nyelv", "noSubtitlesFound": "Nem találhatók feliratok", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index b7f60d26..9849d74c 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Nessun capitolo disponibile", "queue": "Coda", "noQueueItems": "Nessun elemento in coda", + "noAudioDevicesAvailable": "Nessun dispositivo audio disponibile", "searchSubtitles": "Cerca sottotitoli", "language": "Lingua", "noSubtitlesFound": "Nessun sottotitolo trovato", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index df572b51..13582f2d 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -678,6 +678,7 @@ "noChaptersAvailable": "チャプターがありません", "queue": "キュー", "noQueueItems": "キューにアイテムがありません", + "noAudioDevicesAvailable": "オーディオデバイスがありません", "searchSubtitles": "字幕を検索", "language": "言語", "noSubtitlesFound": "字幕が見つかりません", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 4a111207..3fc8bdd0 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Бөлімдер қолжетімсіз", "queue": "Кезек", "noQueueItems": "Кезекте элементтер жоқ", + "noAudioDevicesAvailable": "Қолжетімді аудио құрылғылар жоқ", "searchSubtitles": "Субтитр іздеу", "language": "Тіл", "noSubtitlesFound": "Субтитр табылмады", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index d322d220..a5d3f5ef 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -678,6 +678,7 @@ "noChaptersAvailable": "사용 가능한 챕터가 없습니다", "queue": "재생 대기열", "noQueueItems": "대기열에 항목이 없습니다", + "noAudioDevicesAvailable": "사용 가능한 오디오 기기가 없습니다", "searchSubtitles": "자막 검색", "language": "언어", "noSubtitlesFound": "자막을 찾을 수 없습니다", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 8b90c5fe..787f7bd4 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Ingen kapitler tilgjengelig", "queue": "Kø", "noQueueItems": "Ingen elementer i kø", + "noAudioDevicesAvailable": "Ingen lydenheter tilgjengelig", "searchSubtitles": "Søk etter undertekster", "language": "Språk", "noSubtitlesFound": "Ingen undertekster funnet", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c3dbb599..c9d71fa9 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Geen hoofdstukken beschikbaar", "queue": "Wachtrij", "noQueueItems": "Geen items in de wachtrij", + "noAudioDevicesAvailable": "Geen audioapparaten beschikbaar", "searchSubtitles": "Ondertitels zoeken", "language": "Taal", "noSubtitlesFound": "Geen ondertitels gevonden", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 376cde0d..d2035da0 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -687,6 +687,7 @@ "noChaptersAvailable": "Brak dostępnych rozdziałów", "queue": "Kolejka", "noQueueItems": "Brak elementów w kolejce", + "noAudioDevicesAvailable": "Brak dostępnych urządzeń audio", "searchSubtitles": "Szukaj napisów", "language": "Język", "noSubtitlesFound": "Nie znaleziono napisów", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 26a4975e..65bd33c2 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Nenhum capítulo disponível", "queue": "Fila", "noQueueItems": "Nenhum item na fila", + "noAudioDevicesAvailable": "Nenhum dispositivo de áudio disponível", "searchSubtitles": "Pesquisar legendas", "language": "Idioma", "noSubtitlesFound": "Nenhuma legenda encontrada", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index d13a3b84..83127d69 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -687,6 +687,7 @@ "noChaptersAvailable": "Главы недоступны", "queue": "Очередь", "noQueueItems": "В очереди нет элементов", + "noAudioDevicesAvailable": "Нет доступных аудиоустройств", "searchSubtitles": "Поиск субтитров", "language": "Язык", "noSubtitlesFound": "Субтитры не найдены", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index ae5f916e..c37b14fd 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: 38852 (1766 per locale) +/// Strings: 38874 (1767 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart index 7badba3e..779da56b 100644 --- a/lib/i18n/strings_az.g.dart +++ b/lib/i18n/strings_az.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$az extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Hissələr əlçatan deyil'; @override String get queue => 'Növbə'; @override String get noQueueItems => 'Növbədə element yoxdur'; + @override String get noAudioDevicesAvailable => 'Səs cihazları əlçatan deyil'; @override String get searchSubtitles => 'Altyazı axtar'; @override String get language => 'Dil'; @override String get noSubtitlesFound => 'Altyazı tapılmadı'; @@ -3233,6 +3234,7 @@ extension on TranslationsAz { 'videoControls.noChaptersAvailable' => 'Hissələr əlçatan deyil', 'videoControls.queue' => 'Növbə', 'videoControls.noQueueItems' => 'Növbədə element yoxdur', + 'videoControls.noAudioDevicesAvailable' => 'Səs cihazları əlçatan deyil', 'videoControls.searchSubtitles' => 'Altyazı axtar', 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı tapılmadı', @@ -3618,9 +3620,9 @@ extension on TranslationsAz { 'explore.badge.requested' => 'Sorğu göndərildi', 'explore.badge.pendingApproval' => 'Təsdiq gözlənilir', 'explore.badge.processing' => 'Emal edilir', - 'explore.badge.declined' => 'Rədd edildi', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Rədd edildi', 'explore.badge.requestFailed' => 'Sorğu uğursuz oldu', 'explore.badge.requested4k' => '4K sorğu göndərildi', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} mövsüm', @@ -4132,9 +4134,9 @@ extension on TranslationsAz { 'performanceOverlay.dropped' => 'İtirilmiş kadrlar', 'performanceOverlay.dvRpus' => 'DV RPU-ları', 'performanceOverlay.dvRpuAverage' => 'DV RPU Ort.', - 'performanceOverlay.dvSampleAverage' => 'DV Nümunə Ort.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV Nümunə Ort.', 'performanceOverlay.maxLuma' => 'Maks Luma', 'performanceOverlay.minLuma' => 'Min Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 6b09a8cd..55128269 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$bg extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Няма налични глави'; @override String get queue => 'Опашка'; @override String get noQueueItems => 'Няма елементи в опашката'; + @override String get noAudioDevicesAvailable => 'Няма налични аудио устройства'; @override String get searchSubtitles => 'Търсене на субтитри'; @override String get language => 'Език'; @override String get noSubtitlesFound => 'Не са намерени субтитри'; @@ -3233,6 +3234,7 @@ extension on TranslationsBg { 'videoControls.noChaptersAvailable' => 'Няма налични глави', 'videoControls.queue' => 'Опашка', 'videoControls.noQueueItems' => 'Няма елементи в опашката', + 'videoControls.noAudioDevicesAvailable' => 'Няма налични аудио устройства', 'videoControls.searchSubtitles' => 'Търсене на субтитри', 'videoControls.language' => 'Език', 'videoControls.noSubtitlesFound' => 'Не са намерени субтитри', @@ -3618,9 +3620,9 @@ extension on TranslationsBg { 'explore.badge.requested' => 'Заявен', 'explore.badge.pendingApproval' => 'В очакване на одобрение', 'explore.badge.processing' => 'Обработва се', - 'explore.badge.declined' => 'Отхвърлен', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Отхвърлен', 'explore.badge.requestFailed' => 'Заявката се провали', 'explore.badge.requested4k' => 'Заявен в 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} сезона', @@ -4132,9 +4134,9 @@ extension on TranslationsBg { 'performanceOverlay.dropped' => 'Пропуснати кадри', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'Средно DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Средно DV семпл', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Средно DV семпл', 'performanceOverlay.maxLuma' => 'Макс. яркост', 'performanceOverlay.minLuma' => 'Мин. яркост', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 7b2c9d49..77727aa5 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$da extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Ingen kapitler tilgængelige'; @override String get queue => 'Kø'; @override String get noQueueItems => 'Ingen elementer i køen'; + @override String get noAudioDevicesAvailable => 'Ingen lydenheder tilgængelige'; @override String get searchSubtitles => 'Søg undertekster'; @override String get language => 'Sprog'; @override String get noSubtitlesFound => 'Ingen undertekster fundet'; @@ -3233,6 +3234,7 @@ extension on TranslationsDa { 'videoControls.noChaptersAvailable' => 'Ingen kapitler tilgængelige', 'videoControls.queue' => 'Kø', 'videoControls.noQueueItems' => 'Ingen elementer i køen', + 'videoControls.noAudioDevicesAvailable' => 'Ingen lydenheder tilgængelige', 'videoControls.searchSubtitles' => 'Søg undertekster', 'videoControls.language' => 'Sprog', 'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet', @@ -3618,9 +3620,9 @@ extension on TranslationsDa { 'explore.badge.requested' => 'Anmodet', 'explore.badge.pendingApproval' => 'Afventer godkendelse', 'explore.badge.processing' => 'Behandles', - 'explore.badge.declined' => 'Afvist', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Afvist', 'explore.badge.requestFailed' => 'Anmodningen mislykkedes', 'explore.badge.requested4k' => '4K anmodet', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} sæsoner', @@ -4132,9 +4134,9 @@ extension on TranslationsDa { 'performanceOverlay.dropped' => 'Tabte', 'performanceOverlay.dvRpus' => 'DV RPU’er', 'performanceOverlay.dvRpuAverage' => 'DV RPU gns.', - 'performanceOverlay.dvSampleAverage' => 'DV-sample gns.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-sample gns.', 'performanceOverlay.maxLuma' => 'Maks. luma', 'performanceOverlay.minLuma' => 'Min. luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 07d49c79..96d51cf9 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$de extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Keine Kapitel verfügbar'; @override String get queue => 'Warteschlange'; @override String get noQueueItems => 'Keine Elemente in der Warteschlange'; + @override String get noAudioDevicesAvailable => 'Keine Audiogeräte verfügbar'; @override String get searchSubtitles => 'Untertitel suchen'; @override String get language => 'Sprache'; @override String get noSubtitlesFound => 'Keine Untertitel gefunden'; @@ -3233,6 +3234,7 @@ extension on TranslationsDe { 'videoControls.noChaptersAvailable' => 'Keine Kapitel verfügbar', 'videoControls.queue' => 'Warteschlange', 'videoControls.noQueueItems' => 'Keine Elemente in der Warteschlange', + 'videoControls.noAudioDevicesAvailable' => 'Keine Audiogeräte verfügbar', 'videoControls.searchSubtitles' => 'Untertitel suchen', 'videoControls.language' => 'Sprache', 'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden', @@ -3618,9 +3620,9 @@ extension on TranslationsDe { 'explore.badge.requested' => 'Angefragt', 'explore.badge.pendingApproval' => 'Genehmigung ausstehend', 'explore.badge.processing' => 'Wird verarbeitet', - 'explore.badge.declined' => 'Abgelehnt', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Abgelehnt', 'explore.badge.requestFailed' => 'Anfrage fehlgeschlagen', 'explore.badge.requested4k' => '4K angefragt', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} Staffeln', @@ -4132,9 +4134,9 @@ extension on TranslationsDe { 'performanceOverlay.dropped' => 'Verworfen', 'performanceOverlay.dvRpus' => 'DV-RPUs', 'performanceOverlay.dvRpuAverage' => 'DV-RPU Ø', - 'performanceOverlay.dvSampleAverage' => 'DV-Sample Ø', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-Sample Ø', 'performanceOverlay.maxLuma' => 'Max. Luma', 'performanceOverlay.minLuma' => 'Min. Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 8e0847f1..1da88df6 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2026,6 +2026,9 @@ class Translations$videoControls$en { /// en: 'No items in queue' String get noQueueItems => 'No items in queue'; + /// en: 'No audio devices available' + String get noAudioDevicesAvailable => 'No audio devices available'; + /// en: 'Search Subtitles' String get searchSubtitles => 'Search Subtitles'; @@ -6758,6 +6761,7 @@ extension on Translations { 'videoControls.noChaptersAvailable' => 'No chapters available', 'videoControls.queue' => 'Queue', 'videoControls.noQueueItems' => 'No items in queue', + 'videoControls.noAudioDevicesAvailable' => 'No audio devices available', 'videoControls.searchSubtitles' => 'Search Subtitles', 'videoControls.language' => 'Language', 'videoControls.noSubtitlesFound' => 'No subtitles found', @@ -7143,9 +7147,9 @@ extension on Translations { 'explore.badge.requested' => 'Requested', 'explore.badge.pendingApproval' => 'Pending approval', 'explore.badge.processing' => 'Processing', - 'explore.badge.declined' => 'Declined', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Declined', 'explore.badge.requestFailed' => 'Request failed', 'explore.badge.requested4k' => '4K requested', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} seasons', @@ -7657,9 +7661,9 @@ extension on Translations { 'performanceOverlay.dropped' => 'Dropped', 'performanceOverlay.dvRpus' => 'DV RPUs', 'performanceOverlay.dvRpuAverage' => 'DV RPU Avg', - 'performanceOverlay.dvSampleAverage' => 'DV Sample Avg', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV Sample Avg', 'performanceOverlay.maxLuma' => 'Max Luma', 'performanceOverlay.minLuma' => 'Min Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 750c765e..68579956 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$es extends Translations$videoControls$en { @override String get noChaptersAvailable => 'No hay capítulos disponibles'; @override String get queue => 'Cola'; @override String get noQueueItems => 'No hay elementos en la cola'; + @override String get noAudioDevicesAvailable => 'No hay dispositivos de audio disponibles'; @override String get searchSubtitles => 'Buscar subtítulos'; @override String get language => 'Idioma'; @override String get noSubtitlesFound => 'No se encontraron subtítulos'; @@ -3233,6 +3234,7 @@ extension on TranslationsEs { 'videoControls.noChaptersAvailable' => 'No hay capítulos disponibles', 'videoControls.queue' => 'Cola', 'videoControls.noQueueItems' => 'No hay elementos en la cola', + 'videoControls.noAudioDevicesAvailable' => 'No hay dispositivos de audio disponibles', 'videoControls.searchSubtitles' => 'Buscar subtítulos', 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos', @@ -3618,9 +3620,9 @@ extension on TranslationsEs { 'explore.badge.requested' => 'Solicitado', 'explore.badge.pendingApproval' => 'Pendiente de aprobación', 'explore.badge.processing' => 'Procesando', - 'explore.badge.declined' => 'Rechazado', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Rechazado', 'explore.badge.requestFailed' => 'La solicitud falló', 'explore.badge.requested4k' => 'Solicitado en 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} temporadas', @@ -4132,9 +4134,9 @@ extension on TranslationsEs { 'performanceOverlay.dropped' => 'Descartados', 'performanceOverlay.dvRpus' => 'DV RPUs', 'performanceOverlay.dvRpuAverage' => 'Prom. DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Prom. muestra DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Prom. muestra DV', 'performanceOverlay.maxLuma' => 'Luma máx.', 'performanceOverlay.minLuma' => 'Luma mín.', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 9589153f..41c1cb6f 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$fr extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Aucun chapitre disponible'; @override String get queue => 'File d\'attente'; @override String get noQueueItems => 'Aucun élément dans la file d\'attente'; + @override String get noAudioDevicesAvailable => 'Aucun appareil audio disponible'; @override String get searchSubtitles => 'Rechercher des sous-titres'; @override String get language => 'Langue'; @override String get noSubtitlesFound => 'Aucun sous-titre trouvé'; @@ -3233,6 +3234,7 @@ extension on TranslationsFr { 'videoControls.noChaptersAvailable' => 'Aucun chapitre disponible', 'videoControls.queue' => 'File d\'attente', 'videoControls.noQueueItems' => 'Aucun élément dans la file d\'attente', + 'videoControls.noAudioDevicesAvailable' => 'Aucun appareil audio disponible', 'videoControls.searchSubtitles' => 'Rechercher des sous-titres', 'videoControls.language' => 'Langue', 'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé', @@ -3618,9 +3620,9 @@ extension on TranslationsFr { 'explore.badge.requested' => 'Demandé', 'explore.badge.pendingApproval' => 'En attente d\'approbation', 'explore.badge.processing' => 'En cours de traitement', - 'explore.badge.declined' => 'Refusé', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Refusé', 'explore.badge.requestFailed' => 'Échec de la demande', 'explore.badge.requested4k' => '4K demandé', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} saisons', @@ -4132,9 +4134,9 @@ extension on TranslationsFr { 'performanceOverlay.dropped' => 'Perdues', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'Moy. DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Moy. échant. DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Moy. échant. DV', 'performanceOverlay.maxLuma' => 'Luma max.', 'performanceOverlay.minLuma' => 'Luma min.', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index 2e0fcbf0..a71aa7e8 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$hu extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Nincsenek elérhető fejezetek'; @override String get queue => 'Lejátszási sor'; @override String get noQueueItems => 'Nincsenek elemek a sorban'; + @override String get noAudioDevicesAvailable => 'Nincsenek elérhető audioeszközök'; @override String get searchSubtitles => 'Feliratok keresése'; @override String get language => 'Nyelv'; @override String get noSubtitlesFound => 'Nem találhatók feliratok'; @@ -3233,6 +3234,7 @@ extension on TranslationsHu { 'videoControls.noChaptersAvailable' => 'Nincsenek elérhető fejezetek', 'videoControls.queue' => 'Lejátszási sor', 'videoControls.noQueueItems' => 'Nincsenek elemek a sorban', + 'videoControls.noAudioDevicesAvailable' => 'Nincsenek elérhető audioeszközök', 'videoControls.searchSubtitles' => 'Feliratok keresése', 'videoControls.language' => 'Nyelv', 'videoControls.noSubtitlesFound' => 'Nem találhatók feliratok', @@ -3618,9 +3620,9 @@ extension on TranslationsHu { 'explore.badge.requested' => 'Kérve', 'explore.badge.pendingApproval' => 'Jóváhagyásra vár', 'explore.badge.processing' => 'Feldolgozás alatt', - 'explore.badge.declined' => 'Elutasítva', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Elutasítva', 'explore.badge.requestFailed' => 'A kérés nem sikerült', 'explore.badge.requested4k' => '4K kérve', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} évad', @@ -4132,9 +4134,9 @@ extension on TranslationsHu { 'performanceOverlay.dropped' => 'Eldobva', 'performanceOverlay.dvRpus' => 'DV RPU-k', 'performanceOverlay.dvRpuAverage' => 'DV RPU-átlag', - 'performanceOverlay.dvSampleAverage' => 'DV-mintaátlag', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-mintaátlag', 'performanceOverlay.maxLuma' => 'Maximális luma', 'performanceOverlay.minLuma' => 'Minimális luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 08d24161..1d91396f 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$it extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Nessun capitolo disponibile'; @override String get queue => 'Coda'; @override String get noQueueItems => 'Nessun elemento in coda'; + @override String get noAudioDevicesAvailable => 'Nessun dispositivo audio disponibile'; @override String get searchSubtitles => 'Cerca sottotitoli'; @override String get language => 'Lingua'; @override String get noSubtitlesFound => 'Nessun sottotitolo trovato'; @@ -3233,6 +3234,7 @@ extension on TranslationsIt { 'videoControls.noChaptersAvailable' => 'Nessun capitolo disponibile', 'videoControls.queue' => 'Coda', 'videoControls.noQueueItems' => 'Nessun elemento in coda', + 'videoControls.noAudioDevicesAvailable' => 'Nessun dispositivo audio disponibile', 'videoControls.searchSubtitles' => 'Cerca sottotitoli', 'videoControls.language' => 'Lingua', 'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato', @@ -3618,9 +3620,9 @@ extension on TranslationsIt { 'explore.badge.requested' => 'Richiesto', 'explore.badge.pendingApproval' => 'In attesa di approvazione', 'explore.badge.processing' => 'In elaborazione', - 'explore.badge.declined' => 'Rifiutato', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Rifiutato', 'explore.badge.requestFailed' => 'Richiesta non riuscita', 'explore.badge.requested4k' => 'Richiesto in 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} stagioni', @@ -4132,9 +4134,9 @@ extension on TranslationsIt { 'performanceOverlay.dropped' => 'Scartati', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'Media DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Media camp. DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Media camp. DV', 'performanceOverlay.maxLuma' => 'Luma max', 'performanceOverlay.minLuma' => 'Luma min', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 6cee6015..fc755f8c 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -828,6 +828,7 @@ class _Translations$videoControls$ja extends Translations$videoControls$en { @override String get noChaptersAvailable => 'チャプターがありません'; @override String get queue => 'キュー'; @override String get noQueueItems => 'キューにアイテムがありません'; + @override String get noAudioDevicesAvailable => 'オーディオデバイスがありません'; @override String get searchSubtitles => '字幕を検索'; @override String get language => '言語'; @override String get noSubtitlesFound => '字幕が見つかりません'; @@ -3224,6 +3225,7 @@ extension on TranslationsJa { 'videoControls.noChaptersAvailable' => 'チャプターがありません', 'videoControls.queue' => 'キュー', 'videoControls.noQueueItems' => 'キューにアイテムがありません', + 'videoControls.noAudioDevicesAvailable' => 'オーディオデバイスがありません', 'videoControls.searchSubtitles' => '字幕を検索', 'videoControls.language' => '言語', 'videoControls.noSubtitlesFound' => '字幕が見つかりません', @@ -3609,9 +3611,9 @@ extension on TranslationsJa { 'explore.badge.requested' => 'リクエスト済み', 'explore.badge.pendingApproval' => '承認待ち', 'explore.badge.processing' => '処理中', - 'explore.badge.declined' => '却下', _ => null, } ?? switch (path) { + 'explore.badge.declined' => '却下', 'explore.badge.requestFailed' => 'リクエスト失敗', 'explore.badge.requested4k' => '4Kリクエスト済み', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => 'シーズン ${available}/${total}', @@ -4123,9 +4125,9 @@ extension on TranslationsJa { 'performanceOverlay.dropped' => 'ドロップ', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'DV RPU 平均', - 'performanceOverlay.dvSampleAverage' => 'DV サンプル平均', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV サンプル平均', 'performanceOverlay.maxLuma' => '最大輝度', 'performanceOverlay.minLuma' => '最小輝度', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart index 242011eb..eebc2ae8 100644 --- a/lib/i18n/strings_kk.g.dart +++ b/lib/i18n/strings_kk.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$kk extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Бөлімдер қолжетімсіз'; @override String get queue => 'Кезек'; @override String get noQueueItems => 'Кезекте элементтер жоқ'; + @override String get noAudioDevicesAvailable => 'Қолжетімді аудио құрылғылар жоқ'; @override String get searchSubtitles => 'Субтитр іздеу'; @override String get language => 'Тіл'; @override String get noSubtitlesFound => 'Субтитр табылмады'; @@ -3233,6 +3234,7 @@ extension on TranslationsKk { 'videoControls.noChaptersAvailable' => 'Бөлімдер қолжетімсіз', 'videoControls.queue' => 'Кезек', 'videoControls.noQueueItems' => 'Кезекте элементтер жоқ', + 'videoControls.noAudioDevicesAvailable' => 'Қолжетімді аудио құрылғылар жоқ', 'videoControls.searchSubtitles' => 'Субтитр іздеу', 'videoControls.language' => 'Тіл', 'videoControls.noSubtitlesFound' => 'Субтитр табылмады', @@ -3618,9 +3620,9 @@ extension on TranslationsKk { 'explore.badge.requested' => 'Сұралған', 'explore.badge.pendingApproval' => 'Растау күтілуде', 'explore.badge.processing' => 'Өңделуде', - 'explore.badge.declined' => 'Қабылданбады', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Қабылданбады', 'explore.badge.requestFailed' => 'Сұрау сәтсіз аяқталды', 'explore.badge.requested4k' => '4K сұралған', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} маусым', @@ -4132,9 +4134,9 @@ extension on TranslationsKk { 'performanceOverlay.dropped' => 'Өткізілген кадрлар', 'performanceOverlay.dvRpus' => 'DV RPU-лар', 'performanceOverlay.dvRpuAverage' => 'DV RPU Орт.', - 'performanceOverlay.dvSampleAverage' => 'DV Үлгі Орт.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV Үлгі Орт.', 'performanceOverlay.maxLuma' => 'Макс Luma', 'performanceOverlay.minLuma' => 'Мин Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 5cb76af2..0bb111ed 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -828,6 +828,7 @@ class _Translations$videoControls$ko extends Translations$videoControls$en { @override String get noChaptersAvailable => '사용 가능한 챕터가 없습니다'; @override String get queue => '재생 대기열'; @override String get noQueueItems => '대기열에 항목이 없습니다'; + @override String get noAudioDevicesAvailable => '사용 가능한 오디오 기기가 없습니다'; @override String get searchSubtitles => '자막 검색'; @override String get language => '언어'; @override String get noSubtitlesFound => '자막을 찾을 수 없습니다'; @@ -3224,6 +3225,7 @@ extension on TranslationsKo { 'videoControls.noChaptersAvailable' => '사용 가능한 챕터가 없습니다', 'videoControls.queue' => '재생 대기열', 'videoControls.noQueueItems' => '대기열에 항목이 없습니다', + 'videoControls.noAudioDevicesAvailable' => '사용 가능한 오디오 기기가 없습니다', 'videoControls.searchSubtitles' => '자막 검색', 'videoControls.language' => '언어', 'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다', @@ -3609,9 +3611,9 @@ extension on TranslationsKo { 'explore.badge.requested' => '요청됨', 'explore.badge.pendingApproval' => '승인 대기 중', 'explore.badge.processing' => '처리 중', - 'explore.badge.declined' => '거절됨', _ => null, } ?? switch (path) { + 'explore.badge.declined' => '거절됨', 'explore.badge.requestFailed' => '요청 실패', 'explore.badge.requested4k' => '4K 요청됨', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '시즌 ${available}/${total}', @@ -4123,9 +4125,9 @@ extension on TranslationsKo { 'performanceOverlay.dropped' => '드롭됨', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'DV RPU 평균', - 'performanceOverlay.dvSampleAverage' => 'DV 샘플 평균', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV 샘플 평균', 'performanceOverlay.maxLuma' => '최대 휘도', 'performanceOverlay.minLuma' => '최소 휘도', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index ac482694..56548403 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$nb extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Ingen kapitler tilgjengelig'; @override String get queue => 'Kø'; @override String get noQueueItems => 'Ingen elementer i kø'; + @override String get noAudioDevicesAvailable => 'Ingen lydenheter tilgjengelig'; @override String get searchSubtitles => 'Søk etter undertekster'; @override String get language => 'Språk'; @override String get noSubtitlesFound => 'Ingen undertekster funnet'; @@ -3233,6 +3234,7 @@ extension on TranslationsNb { 'videoControls.noChaptersAvailable' => 'Ingen kapitler tilgjengelig', 'videoControls.queue' => 'Kø', 'videoControls.noQueueItems' => 'Ingen elementer i kø', + 'videoControls.noAudioDevicesAvailable' => 'Ingen lydenheter tilgjengelig', 'videoControls.searchSubtitles' => 'Søk etter undertekster', 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet', @@ -3618,9 +3620,9 @@ extension on TranslationsNb { 'explore.badge.requested' => 'Forespurt', 'explore.badge.pendingApproval' => 'Venter på godkjenning', 'explore.badge.processing' => 'Behandler', - 'explore.badge.declined' => 'Avslått', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Avslått', 'explore.badge.requestFailed' => 'Forespørsel mislyktes', 'explore.badge.requested4k' => '4K forespurt', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} sesonger', @@ -4132,9 +4134,9 @@ extension on TranslationsNb { 'performanceOverlay.dropped' => 'Tapte', 'performanceOverlay.dvRpus' => 'DV RPU-er', 'performanceOverlay.dvRpuAverage' => 'DV RPU snitt', - 'performanceOverlay.dvSampleAverage' => 'DV-sample snitt', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-sample snitt', 'performanceOverlay.maxLuma' => 'Maks luma', 'performanceOverlay.minLuma' => 'Min luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 0650fbec..6089ce02 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$nl extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Geen hoofdstukken beschikbaar'; @override String get queue => 'Wachtrij'; @override String get noQueueItems => 'Geen items in de wachtrij'; + @override String get noAudioDevicesAvailable => 'Geen audioapparaten beschikbaar'; @override String get searchSubtitles => 'Ondertitels zoeken'; @override String get language => 'Taal'; @override String get noSubtitlesFound => 'Geen ondertitels gevonden'; @@ -3233,6 +3234,7 @@ extension on TranslationsNl { 'videoControls.noChaptersAvailable' => 'Geen hoofdstukken beschikbaar', 'videoControls.queue' => 'Wachtrij', 'videoControls.noQueueItems' => 'Geen items in de wachtrij', + 'videoControls.noAudioDevicesAvailable' => 'Geen audioapparaten beschikbaar', 'videoControls.searchSubtitles' => 'Ondertitels zoeken', 'videoControls.language' => 'Taal', 'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden', @@ -3618,9 +3620,9 @@ extension on TranslationsNl { 'explore.badge.requested' => 'Aangevraagd', 'explore.badge.pendingApproval' => 'In afwachting van goedkeuring', 'explore.badge.processing' => 'Wordt verwerkt', - 'explore.badge.declined' => 'Afgewezen', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Afgewezen', 'explore.badge.requestFailed' => 'Aanvraag mislukt', 'explore.badge.requested4k' => '4K aangevraagd', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} seizoenen', @@ -4132,9 +4134,9 @@ extension on TranslationsNl { 'performanceOverlay.dropped' => 'Gedropt', 'performanceOverlay.dvRpus' => 'DV RPU’s', 'performanceOverlay.dvRpuAverage' => 'DV RPU gem.', - 'performanceOverlay.dvSampleAverage' => 'DV-sample gem.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-sample gem.', 'performanceOverlay.maxLuma' => 'Max luma', 'performanceOverlay.minLuma' => 'Min luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 3e54981e..049388ab 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -837,6 +837,7 @@ class _Translations$videoControls$pl extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Brak dostępnych rozdziałów'; @override String get queue => 'Kolejka'; @override String get noQueueItems => 'Brak elementów w kolejce'; + @override String get noAudioDevicesAvailable => 'Brak dostępnych urządzeń audio'; @override String get searchSubtitles => 'Szukaj napisów'; @override String get language => 'Język'; @override String get noSubtitlesFound => 'Nie znaleziono napisów'; @@ -3251,6 +3252,7 @@ extension on TranslationsPl { 'videoControls.noChaptersAvailable' => 'Brak dostępnych rozdziałów', 'videoControls.queue' => 'Kolejka', 'videoControls.noQueueItems' => 'Brak elementów w kolejce', + 'videoControls.noAudioDevicesAvailable' => 'Brak dostępnych urządzeń audio', 'videoControls.searchSubtitles' => 'Szukaj napisów', 'videoControls.language' => 'Język', 'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów', @@ -3636,9 +3638,9 @@ extension on TranslationsPl { 'explore.badge.requested' => 'Zamówiono', 'explore.badge.pendingApproval' => 'Oczekuje na zatwierdzenie', 'explore.badge.processing' => 'Przetwarzanie', - 'explore.badge.declined' => 'Odrzucono', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Odrzucono', 'explore.badge.requestFailed' => 'Żądanie nie powiodło się', 'explore.badge.requested4k' => 'Zamówiono w 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} sezonów', @@ -4150,9 +4152,9 @@ extension on TranslationsPl { 'performanceOverlay.dropped' => 'Pominięte', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'Śr. DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Śr. próbki DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Śr. próbki DV', 'performanceOverlay.maxLuma' => 'Maks. luma', 'performanceOverlay.minLuma' => 'Min. luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 2e835b77..dc6cc283 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$pt extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Nenhum capítulo disponível'; @override String get queue => 'Fila'; @override String get noQueueItems => 'Nenhum item na fila'; + @override String get noAudioDevicesAvailable => 'Nenhum dispositivo de áudio disponível'; @override String get searchSubtitles => 'Pesquisar legendas'; @override String get language => 'Idioma'; @override String get noSubtitlesFound => 'Nenhuma legenda encontrada'; @@ -3233,6 +3234,7 @@ extension on TranslationsPt { 'videoControls.noChaptersAvailable' => 'Nenhum capítulo disponível', 'videoControls.queue' => 'Fila', 'videoControls.noQueueItems' => 'Nenhum item na fila', + 'videoControls.noAudioDevicesAvailable' => 'Nenhum dispositivo de áudio disponível', 'videoControls.searchSubtitles' => 'Pesquisar legendas', 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada', @@ -3618,9 +3620,9 @@ extension on TranslationsPt { 'explore.badge.requested' => 'Solicitado', 'explore.badge.pendingApproval' => 'Aguardando aprovação', 'explore.badge.processing' => 'Processando', - 'explore.badge.declined' => 'Recusado', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Recusado', 'explore.badge.requestFailed' => 'Falha na solicitação', 'explore.badge.requested4k' => '4K solicitado', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} temporadas', @@ -4132,9 +4134,9 @@ extension on TranslationsPt { 'performanceOverlay.dropped' => 'Descartados', 'performanceOverlay.dvRpus' => 'DV RPUs', 'performanceOverlay.dvRpuAverage' => 'Média DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Média amostra DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Média amostra DV', 'performanceOverlay.maxLuma' => 'Luma máx.', 'performanceOverlay.minLuma' => 'Luma mín.', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index e3f4c60e..047e35e2 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -837,6 +837,7 @@ class _Translations$videoControls$ru extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Главы недоступны'; @override String get queue => 'Очередь'; @override String get noQueueItems => 'В очереди нет элементов'; + @override String get noAudioDevicesAvailable => 'Нет доступных аудиоустройств'; @override String get searchSubtitles => 'Поиск субтитров'; @override String get language => 'Язык'; @override String get noSubtitlesFound => 'Субтитры не найдены'; @@ -3251,6 +3252,7 @@ extension on TranslationsRu { 'videoControls.noChaptersAvailable' => 'Главы недоступны', 'videoControls.queue' => 'Очередь', 'videoControls.noQueueItems' => 'В очереди нет элементов', + 'videoControls.noAudioDevicesAvailable' => 'Нет доступных аудиоустройств', 'videoControls.searchSubtitles' => 'Поиск субтитров', 'videoControls.language' => 'Язык', 'videoControls.noSubtitlesFound' => 'Субтитры не найдены', @@ -3636,9 +3638,9 @@ extension on TranslationsRu { 'explore.badge.requested' => 'Запрошено', 'explore.badge.pendingApproval' => 'Ожидает одобрения', 'explore.badge.processing' => 'В обработке', - 'explore.badge.declined' => 'Отклонено', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Отклонено', 'explore.badge.requestFailed' => 'Запрос не удался', 'explore.badge.requested4k' => 'Запрошено в 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} сезонов', @@ -4150,9 +4152,9 @@ extension on TranslationsRu { 'performanceOverlay.dropped' => 'Пропущено', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'Сред. DV RPU', - 'performanceOverlay.dvSampleAverage' => 'Сред. сэмпл DV', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'Сред. сэмпл DV', 'performanceOverlay.maxLuma' => 'Макс. яркость', 'performanceOverlay.minLuma' => 'Мин. яркость', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 2f63c8e7..f4cf48e3 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$sv extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Inga kapitel tillgängliga'; @override String get queue => 'Kö'; @override String get noQueueItems => 'Inga objekt i kön'; + @override String get noAudioDevicesAvailable => 'Inga ljudenheter tillgängliga'; @override String get searchSubtitles => 'Sök undertexter'; @override String get language => 'Språk'; @override String get noSubtitlesFound => 'Inga undertexter hittades'; @@ -3233,6 +3234,7 @@ extension on TranslationsSv { 'videoControls.noChaptersAvailable' => 'Inga kapitel tillgängliga', 'videoControls.queue' => 'Kö', 'videoControls.noQueueItems' => 'Inga objekt i kön', + 'videoControls.noAudioDevicesAvailable' => 'Inga ljudenheter tillgängliga', 'videoControls.searchSubtitles' => 'Sök undertexter', 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Inga undertexter hittades', @@ -3618,9 +3620,9 @@ extension on TranslationsSv { 'explore.badge.requested' => 'Begärd', 'explore.badge.pendingApproval' => 'Väntar på godkännande', 'explore.badge.processing' => 'Bearbetas', - 'explore.badge.declined' => 'Avvisad', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Avvisad', 'explore.badge.requestFailed' => 'Begäran misslyckades', 'explore.badge.requested4k' => '4K begärd', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} säsonger', @@ -4132,9 +4134,9 @@ extension on TranslationsSv { 'performanceOverlay.dropped' => 'Tappade bildrutor', 'performanceOverlay.dvRpus' => 'DV-RPU:er', 'performanceOverlay.dvRpuAverage' => 'DV-RPU, genomsnitt', - 'performanceOverlay.dvSampleAverage' => 'DV-sampling, genomsnitt', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV-sampling, genomsnitt', 'performanceOverlay.maxLuma' => 'Max luma', 'performanceOverlay.minLuma' => 'Min luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart index 5135e99c..49dbc4cf 100644 --- a/lib/i18n/strings_tr.g.dart +++ b/lib/i18n/strings_tr.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$tr extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Kısım bulunmuyor'; @override String get queue => 'Kuyruk'; @override String get noQueueItems => 'Kuyrukta öge yok'; + @override String get noAudioDevicesAvailable => 'Kullanılabilir ses cihazı yok'; @override String get searchSubtitles => 'Altyazı Ara'; @override String get language => 'Dil'; @override String get noSubtitlesFound => 'Altyazı bulunamadı'; @@ -3233,6 +3234,7 @@ extension on TranslationsTr { 'videoControls.noChaptersAvailable' => 'Kısım bulunmuyor', 'videoControls.queue' => 'Kuyruk', 'videoControls.noQueueItems' => 'Kuyrukta öge yok', + 'videoControls.noAudioDevicesAvailable' => 'Kullanılabilir ses cihazı yok', 'videoControls.searchSubtitles' => 'Altyazı Ara', 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı bulunamadı', @@ -3618,9 +3620,9 @@ extension on TranslationsTr { 'explore.badge.requested' => 'İstendi', 'explore.badge.pendingApproval' => 'Onay bekliyor', 'explore.badge.processing' => 'İşleniyor', - 'explore.badge.declined' => 'Reddedildi', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Reddedildi', 'explore.badge.requestFailed' => 'İstek başarısız oldu', 'explore.badge.requested4k' => '4K istendi', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} sezon', @@ -4132,9 +4134,9 @@ extension on TranslationsTr { 'performanceOverlay.dropped' => 'Kare Kaybı', 'performanceOverlay.dvRpus' => 'DV RPU\'ları', 'performanceOverlay.dvRpuAverage' => 'DV RPU Ort.', - 'performanceOverlay.dvSampleAverage' => 'DV Örnek Ort.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV Örnek Ort.', 'performanceOverlay.maxLuma' => 'Maks Luma', 'performanceOverlay.minLuma' => 'Min Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart index ede88ad6..de890034 100644 --- a/lib/i18n/strings_uz.g.dart +++ b/lib/i18n/strings_uz.g.dart @@ -831,6 +831,7 @@ class _Translations$videoControls$uz extends Translations$videoControls$en { @override String get noChaptersAvailable => 'Boʻlimlar mavjud emas'; @override String get queue => 'Navbat'; @override String get noQueueItems => 'Navbatda elementlar yoʻq'; + @override String get noAudioDevicesAvailable => 'Mavjud audio qurilmalar yoʻq'; @override String get searchSubtitles => 'Subtitr qidirish'; @override String get language => 'Til'; @override String get noSubtitlesFound => 'Subtitr topilmadi'; @@ -3233,6 +3234,7 @@ extension on TranslationsUz { 'videoControls.noChaptersAvailable' => 'Boʻlimlar mavjud emas', 'videoControls.queue' => 'Navbat', 'videoControls.noQueueItems' => 'Navbatda elementlar yoʻq', + 'videoControls.noAudioDevicesAvailable' => 'Mavjud audio qurilmalar yoʻq', 'videoControls.searchSubtitles' => 'Subtitr qidirish', 'videoControls.language' => 'Til', 'videoControls.noSubtitlesFound' => 'Subtitr topilmadi', @@ -3618,9 +3620,9 @@ extension on TranslationsUz { 'explore.badge.requested' => 'Soʻralgan', 'explore.badge.pendingApproval' => 'Tasdiq kutilmoqda', 'explore.badge.processing' => 'Ishlanmoqda', - 'explore.badge.declined' => 'Rad etilgan', _ => null, } ?? switch (path) { + 'explore.badge.declined' => 'Rad etilgan', 'explore.badge.requestFailed' => 'Soʻrov amalga oshmadi', 'explore.badge.requested4k' => '4K soʻralgan', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} mavsum', @@ -4132,9 +4134,9 @@ extension on TranslationsUz { 'performanceOverlay.dropped' => 'Tushirib qoldirilgan kadrlar', 'performanceOverlay.dvRpus' => 'DV RPU-lar', 'performanceOverlay.dvRpuAverage' => 'DV RPU Oʻrt.', - 'performanceOverlay.dvSampleAverage' => 'DV Namuna Oʻrt.', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV Namuna Oʻrt.', 'performanceOverlay.maxLuma' => 'Maks Luma', 'performanceOverlay.minLuma' => 'Min Luma', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index aaab90cb..fc64e301 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -828,6 +828,7 @@ class Translations$videoControls$zh extends Translations$videoControls$en { @override String get noChaptersAvailable => '没有可用的章节'; @override String get queue => '播放队列'; @override String get noQueueItems => '队列中没有项目'; + @override String get noAudioDevicesAvailable => '没有可用的音频设备'; @override String get searchSubtitles => '搜索字幕'; @override String get language => '语言'; @override String get noSubtitlesFound => '未找到字幕'; @@ -3224,6 +3225,7 @@ extension on TranslationsZh { 'videoControls.noChaptersAvailable' => '没有可用的章节', 'videoControls.queue' => '播放队列', 'videoControls.noQueueItems' => '队列中没有项目', + 'videoControls.noAudioDevicesAvailable' => '没有可用的音频设备', 'videoControls.searchSubtitles' => '搜索字幕', 'videoControls.language' => '语言', 'videoControls.noSubtitlesFound' => '未找到字幕', @@ -3609,9 +3611,9 @@ extension on TranslationsZh { 'explore.badge.requested' => '已请求', 'explore.badge.pendingApproval' => '待批准', 'explore.badge.processing' => '处理中', - 'explore.badge.declined' => '已拒绝', _ => null, } ?? switch (path) { + 'explore.badge.declined' => '已拒绝', 'explore.badge.requestFailed' => '请求失败', 'explore.badge.requested4k' => '已请求 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '已有 ${available}/${total} 季', @@ -4123,9 +4125,9 @@ extension on TranslationsZh { 'performanceOverlay.dropped' => '丢帧', 'performanceOverlay.dvRpus' => 'DV RPU', 'performanceOverlay.dvRpuAverage' => 'DV RPU 平均', - 'performanceOverlay.dvSampleAverage' => 'DV 采样平均', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV 采样平均', 'performanceOverlay.maxLuma' => '最大亮度', 'performanceOverlay.minLuma' => '最小亮度', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index eced9d64..24b1ab41 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -829,6 +829,7 @@ class _Translations$videoControls$zh_Hant extends Translations$videoControls$zh @override String get noChaptersAvailable => '沒有可用的章節'; @override String get queue => '播放佇列'; @override String get noQueueItems => '佇列中沒有項目'; + @override String get noAudioDevicesAvailable => '沒有可用的音訊裝置'; @override String get searchSubtitles => '搜尋字幕'; @override String get language => '語言'; @override String get noSubtitlesFound => '找不到字幕'; @@ -3225,6 +3226,7 @@ extension on TranslationsZhHant { 'videoControls.noChaptersAvailable' => '沒有可用的章節', 'videoControls.queue' => '播放佇列', 'videoControls.noQueueItems' => '佇列中沒有項目', + 'videoControls.noAudioDevicesAvailable' => '沒有可用的音訊裝置', 'videoControls.searchSubtitles' => '搜尋字幕', 'videoControls.language' => '語言', 'videoControls.noSubtitlesFound' => '找不到字幕', @@ -3610,9 +3612,9 @@ extension on TranslationsZhHant { 'explore.badge.requested' => '已提出請求', 'explore.badge.pendingApproval' => '等待核准', 'explore.badge.processing' => '處理中', - 'explore.badge.declined' => '已拒絕', _ => null, } ?? switch (path) { + 'explore.badge.declined' => '已拒絕', 'explore.badge.requestFailed' => '請求失敗', 'explore.badge.requested4k' => '已請求 4K', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} 季', @@ -4124,9 +4126,9 @@ extension on TranslationsZhHant { 'performanceOverlay.dropped' => '丟格數(Dropped)', 'performanceOverlay.dvRpus' => 'DV RPU 數', 'performanceOverlay.dvRpuAverage' => 'DV RPU 平均', - 'performanceOverlay.dvSampleAverage' => 'DV 取樣平均', _ => null, } ?? switch (path) { + 'performanceOverlay.dvSampleAverage' => 'DV 取樣平均', 'performanceOverlay.maxLuma' => '最大亮度', 'performanceOverlay.minLuma' => '最小亮度', 'performanceOverlay.maxCll' => 'MaxCLL', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 0afc7f98..6b520e2e 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Inga kapitel tillgängliga", "queue": "Kö", "noQueueItems": "Inga objekt i kön", + "noAudioDevicesAvailable": "Inga ljudenheter tillgängliga", "searchSubtitles": "Sök undertexter", "language": "Språk", "noSubtitlesFound": "Inga undertexter hittades", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index cfaa18cd..c8078e0f 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Kısım bulunmuyor", "queue": "Kuyruk", "noQueueItems": "Kuyrukta öge yok", + "noAudioDevicesAvailable": "Kullanılabilir ses cihazı yok", "searchSubtitles": "Altyazı Ara", "language": "Dil", "noSubtitlesFound": "Altyazı bulunamadı", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 41bf40cb..ee3bc8b4 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -681,6 +681,7 @@ "noChaptersAvailable": "Boʻlimlar mavjud emas", "queue": "Navbat", "noQueueItems": "Navbatda elementlar yoʻq", + "noAudioDevicesAvailable": "Mavjud audio qurilmalar yoʻq", "searchSubtitles": "Subtitr qidirish", "language": "Til", "noSubtitlesFound": "Subtitr topilmadi", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 45f52c6a..cb9e7e3c 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -678,6 +678,7 @@ "noChaptersAvailable": "沒有可用的章節", "queue": "播放佇列", "noQueueItems": "佇列中沒有項目", + "noAudioDevicesAvailable": "沒有可用的音訊裝置", "searchSubtitles": "搜尋字幕", "language": "語言", "noSubtitlesFound": "找不到字幕", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1678acf1..c01a5d6b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -678,6 +678,7 @@ "noChaptersAvailable": "没有可用的章节", "queue": "播放队列", "noQueueItems": "队列中没有项目", + "noAudioDevicesAvailable": "没有可用的音频设备", "searchSubtitles": "搜索字幕", "language": "语言", "noSubtitlesFound": "未找到字幕", diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 1919da5f..230021e0 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -51,6 +51,8 @@ class _FiltersBottomSheetState extends State { bool _isLoadingValues = false; String? _filterValuesError; int _filterValuesLoadGeneration = 0; + final _contentKey = GlobalKey(); + double? _transitionMinHeight; final Map _tempSelectedFilters = {}; static final Map _filterDisplayNames = {}; // Cache for display names static const int _maxCachedDisplayNames = 1000; @@ -116,6 +118,11 @@ class _FiltersBottomSheetState extends State { final libraryKey = widget.libraryKey; final cachedValues = widget.cachedValues; final loader = widget.loadFilterValues; + // Drilling in is a setState page swap inside one sheet, and sheets are + // bottom-anchored, so any height change during the load drags the header + // and its Back button. Hold the outgoing page's height for the transient + // spinner; the settled states below are free to hug again. + _transitionMinHeight = _contentHeight(); setState(() { _currentFilter = filter; _filterValues = []; @@ -235,26 +242,52 @@ class _FiltersBottomSheetState extends State { ), ) : null, - child: currentFilter != null ? _buildFilterValuesView(currentFilter) : _buildFiltersView(), + child: KeyedSubtree( + key: _contentKey, + child: currentFilter != null ? _buildFilterValuesView(currentFilter) : _buildFiltersView(), + ), ); } + /// Height the content area currently occupies, used to hold the sheet steady + /// across a page swap. Null before first layout. + double? _contentHeight() { + final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; + return box?.hasSize == true ? box!.size.height : null; + } + Widget _buildFilterValuesView(MediaFilter filter) { final error = _filterValuesError; if (error != null) { - return ErrorStateWidget( - message: error, - onRetry: () => _loadFilterValues(filter), - actionFocusNode: _initialFocusNode, - onActionBack: _goBack, - actionAutofocus: InputModeTracker.isKeyboardMode(context), - actionUseBackgroundFocus: true, + // The StateMessageWidget family is filling by design — 33 other sites + // render it in page bodies and SliverFillRemaining. The unbounded scroll + // axis here is what lets its inner Center shrink to content, so the sheet + // does not stretch to the full height cap for one line of text. + return SingleChildScrollView( + primary: false, + child: ErrorStateWidget( + message: error, + onRetry: () => _loadFilterValues(filter), + actionFocusNode: _initialFocusNode, + onActionBack: _goBack, + actionAutofocus: InputModeTracker.isKeyboardMode(context), + actionUseBackgroundFocus: true, + ), ); } if (_isLoadingValues) { + assert(_transitionMinHeight != null, '_transitionMinHeight must be set before entering the loading state'); + // Held at the outgoing page's height (see [_loadFilterValues]) so the + // transient spinner cannot move the header. Settled states below hug. return Focus( autofocus: InputModeTracker.isKeyboardMode(context), - child: const Center(child: CircularProgressIndicator()), + // Exactly the outgoing height, so the swap moves nothing. + // [_loadFilterValues] assigns it immediately before setting + // `_isLoadingValues`, so it is never null here. + child: SizedBox( + height: _transitionMinHeight, + child: const Center(child: CircularProgressIndicator()), + ), ); } @@ -262,6 +295,7 @@ class _FiltersBottomSheetState extends State { return ListView.builder( controller: _valuesScrollController, primary: false, + shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _filterValues.length + 1, itemBuilder: (context, index) { @@ -309,6 +343,7 @@ class _FiltersBottomSheetState extends State { final autofocusFirst = InputModeTracker.isKeyboardMode(context); return ListView.builder( primary: false, + shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _sortedFilters.length, itemBuilder: (context, index) { diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 94a69f88..0fb4e03b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -836,7 +836,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { ? Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Center( + // Hugs like every other empty state. Switching entries + // still moves the chooser chips above by the difference + // in row count, which is inherent to content sizing; + // filling this one branch instead made the mixed case + // (one entry with settings, one without) far worse, since + // the empty entry then inflated to the whole height cap. + heightFactor: 1, child: Text( _entry.airingsType ?? '', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index f1f0be53..afb9a6ee 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -104,13 +104,14 @@ class _ReorderFavoritesSheetState extends State mainAxisSize: .min, children: [ BottomSheetHeader(title: t.liveTv.reorderFavorites, icon: Symbols.swap_vert_rounded), - Expanded( + Flexible( child: Focus( focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: isKeyboardMode, onKeyEvent: handleReorderKeyEvent, child: ReorderableListView.builder( + shrinkWrap: true, scrollController: _scrollController, onReorderItem: _onReorder, itemCount: _tempFavorites.length, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index d70cd54a..b1e6aff6 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1561,12 +1561,10 @@ class _MainScreenState extends State final controller = OverlaySheetController.of(context); final groupByServer = SettingsService.instanceOrNull?.read(SettingsService.groupLibrariesByServer) ?? false; - final maxHeight = MediaQuery.sizeOf(context).height * 0.62; controller .show( showDragHandle: true, - constraints: BoxConstraints(maxHeight: maxHeight), builder: (sheetContext) { return Consumer2( builder: (context, librariesProvider, hiddenLibrariesProvider, _) { diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 51d62b03..b095ebfd 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -169,6 +169,7 @@ class _SessionMenuSheet extends StatelessWidget { ), Flexible( child: ListView( + shrinkWrap: true, padding: const EdgeInsets.all(16), children: [ Text( diff --git a/lib/widgets/bottom_sheet_page_scaffold.dart b/lib/widgets/bottom_sheet_page_scaffold.dart index 3110083f..49d756ca 100644 --- a/lib/widgets/bottom_sheet_page_scaffold.dart +++ b/lib/widgets/bottom_sheet_page_scaffold.dart @@ -5,6 +5,19 @@ import '../focus/key_event_utils.dart'; import 'bottom_sheet_header.dart'; /// Shared page layout for bottom sheets with a stable header and content area. +/// +/// The content area is a [Flexible], so the page is as tall as [child] wants to +/// be and no taller, while still being clamped by the sheet's own maximum +/// height. [child] should therefore shrink-wrap in the vertical axis: use a +/// `SingleChildScrollView`, or a list with `shrinkWrap: true`. A plain +/// scrollable sizes itself to the incoming maximum and reintroduces the empty +/// space this layout exists to avoid. +/// +/// A child may deliberately fill instead when a content-driven height would +/// move a control the user is operating — sheets are bottom-anchored, so a +/// shrinking body drags the top edge and anything above the scroll area with +/// it. `SubtitleSearchSheet` and its language picker opt out for that +/// reason; document any other. class BottomSheetPageScaffold extends StatelessWidget { final String title; final Widget child; @@ -19,7 +32,6 @@ class BottomSheetPageScaffold extends StatelessWidget { final bool showHeaderBorder; final bool showHeaderDivider; final FocusNode? closeFocusNode; - final bool shrinkWrap; const BottomSheetPageScaffold({ super.key, @@ -36,13 +48,12 @@ class BottomSheetPageScaffold extends StatelessWidget { this.showHeaderBorder = true, this.showHeaderDivider = false, this.closeFocusNode, - this.shrinkWrap = false, }); @override Widget build(BuildContext context) { Widget content = Column( - mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max, + mainAxisSize: MainAxisSize.min, children: [ BottomSheetHeader( title: title, @@ -58,7 +69,7 @@ class BottomSheetPageScaffold extends StatelessWidget { closeFocusNode: closeFocusNode, ), if (showHeaderDivider) Divider(color: Theme.of(context).dividerColor, height: 1), - if (shrinkWrap) child else Expanded(child: child), + Flexible(child: child), ], ); diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 802663dd..9ea09238 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -48,6 +48,7 @@ class _FileInfoBottomSheetState extends State { Widget build(BuildContext context) { final versions = widget.fileInfo.versions; return Column( + mainAxisSize: MainAxisSize.min, children: [ BottomSheetHeader( title: t.fileInfo.title, @@ -57,8 +58,9 @@ class _FileInfoBottomSheetState extends State { // keeps no rule under it. showBorder: false, ), - Expanded( + Flexible( child: ListView( + shrinkWrap: true, padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ if (widget.title.isNotEmpty) _ItemHeadline(title: widget.title, versions: versions), diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index c75e4833..2652f02d 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -395,7 +395,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), onKeyEvent: handleReorderKeyEvent, - child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys), + child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys, shrinkWrap: false), ), ), ), @@ -403,6 +403,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> } return Column( + mainAxisSize: .min, children: [ BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded), Flexible( @@ -411,7 +412,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), onKeyEvent: handleReorderKeyEvent, - child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys), + child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys, shrinkWrap: true), ), ), ], @@ -421,12 +422,17 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> /// Build flat library list with a server subtitle when multiple servers are /// connected. The TV dialog passes [_dialogScrollController] so focused rows /// can be scrolled into view; the bottom sheet passes its own controller. - Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { + Widget _buildFlatLibraryList( + ScrollController scrollController, + Set hiddenLibraryKeys, { + required bool shrinkWrap, + }) { final showServerNames = _hasMultipleServers(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); return ReorderableListView.builder( scrollController: scrollController, + shrinkWrap: shrinkWrap, onReorderItem: _reorderLibraries, itemCount: _tempLibraries.length, padding: const EdgeInsets.symmetric(vertical: 8), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 1a89b484..0b93a2c1 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -102,12 +103,29 @@ class OverlaySheetController { _state._autoFocus(clearSelectSuppression: false); } - /// Sizing applied when a caller supplies no explicit constraints: capped - /// width on desktop, three quarters of the screen height everywhere. + /// Absolute height ceiling for resizable desktop windows. Without it a 4K + /// window yields a 1620px sheet, which reads as a wall of list rather than a + /// sheet. + static const _windowedMaxHeight = 720.0; + + /// Sizing applied when a caller supplies no explicit constraints: three + /// quarters of the viewport height everywhere, the capped width on wide + /// viewports, and the absolute height ceiling on desktop windows only. + /// + /// Both caps require `width > 600`. The height ceiling additionally requires + /// a desktop OS and not TV, because it exists for a window the user can + /// resize arbitrarily tall: a portrait tablet and a 10-foot UI keep the full + /// 75%, and so does a desktop window narrower than 601px, which is + /// phone-shaped and where 75% is the norm. static BoxConstraints _defaultSheetConstraints(BuildContext context) { final size = MediaQuery.sizeOf(context); - final isDesktop = size.width > 600; - return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); + final isWideViewport = size.width > 600; + final isDesktopWindow = isWideViewport && PlatformDetector.isDesktopOS() && !PlatformDetector.isTV(); + final maxHeight = size.height * 0.75; + return BoxConstraints( + maxWidth: isWideViewport ? 700 : double.infinity, + maxHeight: isDesktopWindow ? math.min(maxHeight, _windowedMaxHeight) : maxHeight, + ); } /// Show a sheet using the overlay system if available, otherwise fall back @@ -288,6 +306,15 @@ class _OverlaySheetHostState extends State with SingleTickerPr Offset? _lastPointerPosition; double? _sheetHorizontalAnchor; + /// Bumped on every [_show]. Keys the resize animation so a freshly opened + /// sheet adopts its own height immediately instead of animating down from + /// the previous sheet's; nested pushes within one sheet still animate. + /// + /// Changing the key also remounts the sheet subtree, so a `show` that + /// replaces a live sheet of the same widget type starts with fresh [State] + /// rather than reconciling into the outgoing sheet's. + int _sheetSession = 0; + // Drag-to-dismiss state double _dragOffset = 0; bool _isDragging = false; @@ -356,6 +383,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr setState(() { _pageStack.add(entry); + _sheetSession++; _isOpen = true; _isClosing = false; _barrierDismissible = barrierDismissible; @@ -602,9 +630,22 @@ class _OverlaySheetHostState extends State with SingleTickerPr return renderBox?.size.height ?? 300; } + /// Minimum drag distance that dismisses a sheet, regardless of how short the + /// sheet is. Content-sized sheets can be ~150px tall, where a bare 25% of the + /// height is barely more than touch slop, so a slow nudge while scrolling or + /// reaching would close them. Fast flicks are already handled by the velocity + /// check in [_checkDismiss]. + static const _minDismissDrag = 96.0; + + /// Ceiling on that floor, as a fraction of the sheet. A one-row menu can be + /// shorter than [_minDismissDrag], and an unclamped floor would mean the only + /// way to dismiss it by distance is to drag it clean off the screen. + static const _maxDismissDragFraction = 0.6; + void _checkDismiss(double velocity) { final sheetHeight = _getSheetHeight(); - if (_dragOffset > sheetHeight * 0.25 || velocity > 500) { + final threshold = math.min(math.max(sheetHeight * 0.25, _minDismissDrag), sheetHeight * _maxDismissDragFraction); + if (_dragOffset > threshold || velocity > 500) { _close(); } else { setState(() { @@ -734,7 +775,19 @@ class _OverlaySheetHostState extends State with SingleTickerPr bottom: !isTop, left: false, right: false, - child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent), + // Content is sized by the sheet body, so pushing a nested + // page or resolving async content changes the sheet's + // height. Ease the box between those heights instead of + // snapping. The child is laid out at its final size and + // pinned to the anchored edge throughout, so it is revealed + // rather than stretched. + child: AnimatedSize( + key: ValueKey(_sheetSession), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: isTop ? Alignment.topCenter : Alignment.bottomCenter, + child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent), + ), ), ), ), diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index 31426d66..c632a852 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -56,7 +56,6 @@ class _RatingBottomSheetState extends State { final Map _autoSaveTimers = {}; final Map _trackerSourcesByKey = {}; final Set _pendingAutoSaves = {}; - final Set _hiddenTrackers = {}; final Set _loading = {}; final Map _statuses = {}; TrackerIdResolver? _resolver; @@ -86,17 +85,13 @@ class _RatingBottomSheetState extends State { @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74); - // Trakt's account provider is watched by [_trackerSources] via `context`. return Consumer( builder: (context, trackers, _) { - final allTrackerSources = _trackerSources(context); - final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList(); + final trackerSources = _trackerSources(context); _updateTrackerSourceMap(trackerSources); _resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected; - _queueTrackerScoreLoad(allTrackerSources); + _queueTrackerScoreLoad(trackerSources); final serverCaps = widget.serverClient?.capabilities; final showServerRow = serverCaps != null && (serverCaps.numericUserRating || serverCaps.userFavorites); @@ -106,47 +101,49 @@ class _RatingBottomSheetState extends State { ]; var focusIndex = 0; - return ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: Column( - mainAxisSize: .min, - children: [ - BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded), - Flexible( - child: ListView( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 12), - children: [ - if (showServerRow) - _buildServerRow( - widget.serverClient!, - _serverFocusNode, - autofocus: focusIndex == 0, - onNavigateUp: _navTo(focusNodes, focusIndex - 1), - onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), + // Hugs its content: a handful of rows in a 720px sheet was mostly empty + // space. The row set is therefore fixed from the first frame — see + // [_loadTrackerScores], which marks an unratable tracker `notAvailable` + // rather than removing its row. + return Column( + mainAxisSize: .min, + children: [ + BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(10, 4, 10, 12), + children: [ + if (showServerRow) + _buildServerRow( + widget.serverClient!, + _serverFocusNode, + autofocus: focusIndex == 0, + onNavigateUp: _navTo(focusNodes, focusIndex - 1), + onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), + ), + for (final source in trackerSources) + _buildTrackerRow( + source, + _trackerFocusNode(source.service), + autofocus: focusIndex == 0, + onNavigateUp: _navTo(focusNodes, focusIndex - 1), + onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), + ), + if (trackerSources.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Text( + t.rateSheet.noConnectedServices, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), - for (final source in trackerSources) - _buildTrackerRow( - source, - _trackerFocusNode(source.service), - autofocus: focusIndex == 0, - onNavigateUp: _navTo(focusNodes, focusIndex - 1), - onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), - ), - if (allTrackerSources.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), - child: Text( - t.rateSheet.noConnectedServices, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ], - ), + ), + ], ), - ], - ), + ), + ], ); }, ); @@ -279,6 +276,11 @@ class _RatingBottomSheetState extends State { }); } + /// Every tracker that cannot rate this item keeps its row and shows + /// `notAvailable`. Removing a row instead would shorten the sheet several + /// hundred ms after it opens, and because sheets are bottom-anchored that + /// slides the rows above it — which here are live rating controls — out from + /// under the user's finger. Future _loadTrackerScores(List<_TrackerRatingSource> sources) async { setState(() { for (final source in sources) { @@ -294,12 +296,8 @@ class _RatingBottomSheetState extends State { if (!mounted) return; setState(() { for (final source in sources) { - if (_hidesWhenUnavailable(source)) { - _hideTrackerSource(source); - } else { - _loading.remove(source.service.name); - _statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true); - } + _loading.remove(source.service.name); + _statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true); } }); return; @@ -318,12 +316,6 @@ class _RatingBottomSheetState extends State { } on TrackerRatingUnavailableException catch (e) { appLogger.d('Rating unavailable', error: e); if (!mounted) return; - if (_hidesWhenUnavailable(source)) { - setState(() { - _hideTrackerSource(source); - }); - return; - } setState(() { _statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true); }); @@ -344,21 +336,6 @@ class _RatingBottomSheetState extends State { ); } - bool _hidesWhenUnavailable(_TrackerRatingSource source) { - return source.service == TrackerService.mal || source.service == TrackerService.anilist; - } - - void _hideTrackerSource(_TrackerRatingSource source) { - final key = source.service.name; - _hiddenTrackers.add(source.service); - _loading.remove(key); - _statuses.remove(key); - _trackerScores.remove(source.service); - _autoSaveTimers.remove(key)?.cancel(); - _pendingAutoSaves.remove(key); - _trackerSourcesByKey.remove(key); - } - void _setServerStarUnits(int units) { final clamped = units.clamp(0, 10).toInt(); if ((_serverStars * 2).round() == clamped) return; diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 5c6f24a1..8f81aea4 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -100,15 +100,23 @@ class _ChapterSheetState extends State { final currentChapterIndex = chapterSnapshot.data; Widget content; if (!widget.chaptersLoaded) { - content = const Center(child: CircularProgressIndicator()); + content = const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(heightFactor: 1, child: CircularProgressIndicator()), + ); } else if (widget.chapters.isEmpty) { - content = Center( - child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)), + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + heightFactor: 1, + child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)), + ), ); } else { _initialScroll.maybeScrollTo(currentChapterIndex); content = ListView.builder( + shrinkWrap: true, controller: _initialScroll.controller, itemCount: widget.chapters.length, itemBuilder: (context, index) { diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 2b69f943..dac91454 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -53,14 +53,19 @@ class _QueueSheetState extends State { Widget content; if (items.isEmpty) { - content = Center( - child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)), + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + heightFactor: 1, + child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)), + ), ); } else { final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); _initialScroll.maybeScrollTo(currentIndex); content = ListView.builder( + shrinkWrap: true, controller: _initialScroll.controller, itemCount: items.length, itemBuilder: (context, index) { diff --git a/lib/widgets/video_controls/sheets/sheet_column_header.dart b/lib/widgets/video_controls/sheets/sheet_column_header.dart index d04be962..d747bfc9 100644 --- a/lib/widgets/video_controls/sheets/sheet_column_header.dart +++ b/lib/widgets/video_controls/sheets/sheet_column_header.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +/// Left-aligned label above a sheet selection column. +/// +/// The [Align] has no `heightFactor`, so it only shrink-wraps while it sits on +/// an unbounded main axis — i.e. as a non-flex child of a [Column]. Placing it +/// under a [Flexible] would make it fill the sheet's whole height cap. class SheetColumnHeader extends StatelessWidget { final String label; diff --git a/lib/widgets/video_controls/sheets/sheet_selection_column.dart b/lib/widgets/video_controls/sheets/sheet_selection_column.dart index 64edc8cf..cfa0600b 100644 --- a/lib/widgets/video_controls/sheets/sheet_selection_column.dart +++ b/lib/widgets/video_controls/sheets/sheet_selection_column.dart @@ -76,11 +76,15 @@ class _SheetSelectionColumnState extends State implements _initialScroll.maybeScrollTo(widget.initialIndex); return Column( + mainAxisSize: .min, children: [ if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( + // Reserve the bar's 2px unconditionally: growing the column mid-tap + // would nudge a content-sized sheet, and the host eases that as a twitch. + SizedBox(height: 2, child: _selectionPending ? const LinearProgressIndicator(minHeight: 2) : null), + Flexible( child: ListView.builder( + shrinkWrap: true, controller: _initialScroll.controller, itemCount: widget.itemCount, itemBuilder: (context, index) => widget.itemBuilder(context, index, this), diff --git a/lib/widgets/video_controls/sheets/sheet_split_columns.dart b/lib/widgets/video_controls/sheets/sheet_split_columns.dart new file mode 100644 index 00000000..d2130511 --- /dev/null +++ b/lib/widgets/video_controls/sheets/sheet_split_columns.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// Side-by-side pair of sheet columns separated by a hairline rule. +/// +/// The pair is as tall as its taller column, not as tall as the sheet allows. +/// That rules out a plain [VerticalDivider] between the columns: it has no +/// intrinsic height, so under loose constraints it expands to the incoming +/// maximum and drags the whole row to full height. Instead the row sizes +/// itself from the columns alone and the rule is painted over it, stretched to +/// the resolved height. +class SheetSplitColumns extends StatelessWidget { + final Widget start; + final Widget end; + + const SheetSplitColumns({super.key, required this.start, required this.end}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Row( + crossAxisAlignment: .start, + children: [ + Expanded(child: start), + const SizedBox(width: 1), + Expanded(child: end), + ], + ), + // Both halves have equal flex, so the reserved 1px gap above is exactly + // at the horizontal centre. VerticalDivider has no intrinsic height and + // fills whatever it is given — harmless here, because a positioned + // child cannot influence the Stack's size. + Positioned.fill( + child: Center(child: VerticalDivider(width: 1, color: Theme.of(context).dividerColor)), + ), + ], + ); + } +} diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index d193b145..ad5ebdf3 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -241,6 +241,11 @@ class _SubtitleSearchSheetState extends State with Controll title: t.videoControls.searchSubtitles, icon: Symbols.search_rounded, onBack: () => OverlaySheetController.of(context).pop(), + // Deliberately fills the sheet's height cap instead of hugging content. + // Overlay sheets are bottom-anchored, so a content-driven height would + // move the search field on every state transition — spinner, results, + // error, empty — while the user is still typing in it. A search surface + // needs a stable frame; the results list normally fills it anyway. child: Column( children: [ Padding( @@ -440,6 +445,9 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle title: t.videoControls.language, icon: Symbols.language_rounded, onBack: widget.onBack, + // Fills the height cap for the same reason as the search body: the filter + // field is autofocused and refilters on every keystroke, so a + // content-driven height would slide the field the user is typing in. child: Column( children: [ Padding( diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index b8ddea20..616538eb 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -11,6 +11,7 @@ import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; import 'sheet_selection_column.dart'; +import 'sheet_split_columns.dart'; import 'subtitle_search_sheet.dart'; import '../models/track_controls_state.dart'; import '../helpers/track_filter_helper.dart'; @@ -108,13 +109,9 @@ class TrackSheet extends StatelessWidget { } if (showAudio && showSubtitles) { - return Row( - crossAxisAlignment: .start, - children: [ - Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))), - VerticalDivider(width: 1, color: Theme.of(context).dividerColor), - Expanded(child: FocusTraversalGroup(child: subtitleColumnFor(selection, true))), - ], + return SheetSplitColumns( + start: FocusTraversalGroup(child: audioColumnFor(selection, true)), + end: FocusTraversalGroup(child: subtitleColumnFor(selection, true)), ); } diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index ef978e3d..cf137dd1 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -9,6 +9,7 @@ import '../../../utils/quality_preset_labels.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'sheet_selection_column.dart'; +import 'sheet_split_columns.dart'; String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) { return showQuality @@ -73,14 +74,7 @@ class VersionQualityPicker extends StatelessWidget { ); if (showVersions && showQuality) { - return Row( - crossAxisAlignment: .start, - children: [ - Expanded(child: versionColumn), - VerticalDivider(width: 1, color: Theme.of(context).dividerColor), - Expanded(child: qualityColumn), - ], - ); + return SheetSplitColumns(start: versionColumn, end: qualityColumn); } else if (showVersions) { return versionColumn; } else { diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index ed8f86e3..439d4e7d 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -529,6 +529,7 @@ class _VideoSettingsSheetState extends State { final isDesktop = PlatformDetector.isDesktop(context); return ListView( + shrinkWrap: true, children: [ // Playback Speed - hidden for live TV and when user cannot control playback if (_state.canControl && !_state.isLive) @@ -756,6 +757,7 @@ class _VideoSettingsSheetState extends State { final primary = Theme.of(context).colorScheme.primary; return ListView( + shrinkWrap: true, children: [ for (final mode in modes) FocusableListTile( @@ -796,6 +798,7 @@ class _VideoSettingsSheetState extends State { ]; return ListView.builder( + shrinkWrap: true, itemCount: speeds.length, itemBuilder: (context, index) { final speed = speeds[index]; @@ -826,6 +829,7 @@ class _VideoSettingsSheetState extends State { final primary = Theme.of(context).colorScheme.primary; return ListView( + shrinkWrap: true, children: [ FocusableListTile( leading: AppIcon(Symbols.restart_alt_rounded, fill: 1, color: tokens(context).textMuted), @@ -927,6 +931,7 @@ class _VideoSettingsSheetState extends State { } return ListView( + shrinkWrap: true, children: [ for (final d in ungrouped) _buildDeviceTile(d, currentDevice), for (final entry in groups.entries) ...[ @@ -948,7 +953,25 @@ class _VideoSettingsSheetState extends State { } Widget _buildFlatDeviceList(List devices, AudioDevice currentDevice) { + // The device list arrives asynchronously, so an empty list is the normal + // first frame. Without a placeholder the shrink-wrapped page would render + // as a bare header and then jump once devices land. + // + // A fixed placeholder rather than FiltersBottomSheet's hold-the-outgoing- + // height technique: this page is entered from the menu, whose height is + // unrelated to a device list, so holding it would be arbitrary. One small + // upward move when the devices land beats two. + if (devices.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + heightFactor: 1, + child: Text(t.videoControls.noAudioDevicesAvailable, style: TextStyle(color: tokens(context).textMuted)), + ), + ); + } return ListView.builder( + shrinkWrap: true, itemCount: devices.length, itemBuilder: (context, index) => _buildDeviceTile(devices[index], currentDevice), ); @@ -979,6 +1002,7 @@ class _VideoSettingsSheetState extends State { // +1 for the import button at the end return ListView.builder( + shrinkWrap: true, itemCount: presets.length + 1, itemBuilder: (context, index) { if (index == presets.length) { diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index 78c7b690..bcf9ffcf 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -31,6 +31,7 @@ class SleepTimerActiveStatus extends StatelessWidget { padding: const EdgeInsets.all(16), color: Colors.amber.withValues(alpha: 0.1), child: Column( + mainAxisSize: .min, children: [ Text( t.videoControls.timerActive, diff --git a/lib/widgets/video_controls/widgets/sleep_timer_content.dart b/lib/widgets/video_controls/widgets/sleep_timer_content.dart index 315deb00..23e9c5e9 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_content.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_content.dart @@ -10,13 +10,14 @@ import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import '../sheets/sheet_column_header.dart'; +import '../sheets/sheet_split_columns.dart'; import 'sleep_timer_active_status.dart'; /// Shared UI for sleep timer selection and active status. /// /// Layout mirrors the audio/subtitle [TrackSheet]: two side-by-side columns -/// inside a [Row], each in its own [FocusTraversalGroup] so D-pad navigation -/// stays inside the column the user is acting on. +/// inside a [SheetSplitColumns], each in its own [FocusTraversalGroup] so +/// D-pad navigation stays inside the column the user is acting on. /// /// * Left column ("Stop at") — event-based stop options. Today this is just /// "End of current video"; the column is intentionally open-ended for @@ -48,31 +49,24 @@ class SleepTimerContent extends StatelessWidget { final showActiveStatus = sleepTimer.isActive && (remainingTime != null || sleepTimer.isEndOfVideoMode); return Column( + mainAxisSize: .min, children: [ if (showActiveStatus) ...[ SleepTimerActiveStatus(sleepTimer: sleepTimer, remainingTime: remainingTime, onCancel: onCancel), Divider(color: Theme.of(context).dividerColor, height: 1), ], - Expanded( - child: Row( - crossAxisAlignment: .start, - children: [ - Expanded( - child: FocusTraversalGroup( - child: _SleepTimerEventColumn(player: player, sleepTimer: sleepTimer), - ), + Flexible( + child: SheetSplitColumns( + start: FocusTraversalGroup( + child: _SleepTimerEventColumn(player: player, sleepTimer: sleepTimer), + ), + end: FocusTraversalGroup( + child: _SleepTimerDurationColumn( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, ), - VerticalDivider(width: 1, color: Theme.of(context).dividerColor), - Expanded( - child: FocusTraversalGroup( - child: _SleepTimerDurationColumn( - player: player, - sleepTimer: sleepTimer, - defaultDuration: defaultDuration, - ), - ), - ), - ], + ), ), ), ], @@ -93,10 +87,12 @@ class _SleepTimerEventColumn extends StatelessWidget { final label = t.videoControls.sleepTimerEndOfVideo; return Column( + mainAxisSize: .min, children: [ SheetColumnHeader(label: t.videoControls.sleepTimerStopAtHeader), - Expanded( + Flexible( child: ListView( + shrinkWrap: true, children: [ FocusableListTile( leading: AppIcon( @@ -147,10 +143,12 @@ class _SleepTimerDurationColumn extends StatelessWidget { : null; return Column( + mainAxisSize: .min, children: [ SheetColumnHeader(label: t.videoControls.sleepTimerDurationHeader), - Expanded( + Flexible( child: ListView.builder( + shrinkWrap: true, itemCount: durations.length, itemBuilder: (context, index) { final minutes = durations[index]; diff --git a/test/screens/libraries/filters_bottom_sheet_test.dart b/test/screens/libraries/filters_bottom_sheet_test.dart index 249222c2..c6545478 100644 --- a/test/screens/libraries/filters_bottom_sheet_test.dart +++ b/test/screens/libraries/filters_bottom_sheet_test.dart @@ -6,6 +6,7 @@ import 'package:plezy/media/media_filter.dart'; import 'package:plezy/screens/libraries/filters_bottom_sheet.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/widgets/bottom_sheet_header.dart'; +import 'package:plezy/widgets/bottom_sheet_page_scaffold.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; final _filters = [ @@ -18,7 +19,7 @@ MediaFilterValue _value(String key, String title) => MediaFilterValue(key: key, void main() { testWidgets('filter switch rejects an obsolete success and its presentation effects', (tester) async { final requests = _FilterRequests(); - final harness = await _pumpSheet(tester, loader: requests.load); + await _pumpSheet(tester, loader: requests.load); await _openFilter(tester, 'Genre'); await _goBack(tester); @@ -34,12 +35,11 @@ void main() { expect(find.text('Current Studio'), findsOneWidget); expect(find.text('Obsolete Genre'), findsNothing); expect(tester.takeException(), isNull); - harness.dispose(); }); testWidgets('same-filter reopen rejects the first request completion', (tester) async { final requests = _FilterRequests(); - final harness = await _pumpSheet(tester, loader: requests.load); + await _pumpSheet(tester, loader: requests.load); await _openFilter(tester, 'Genre'); await _goBack(tester); @@ -52,12 +52,11 @@ void main() { expect(find.text('New Genre'), findsOneWidget); expect(find.text('Old Genre'), findsNothing); - harness.dispose(); }); testWidgets('stale failure cannot replace a newer successful value list', (tester) async { final requests = _FilterRequests(); - final harness = await _pumpSheet(tester, loader: requests.load); + await _pumpSheet(tester, loader: requests.load); await _openFilter(tester, 'Genre'); await _goBack(tester); @@ -70,7 +69,6 @@ void main() { expect(find.byType(ErrorStateWidget), findsNothing); expect(find.text('Current Studio'), findsOneWidget); - harness.dispose(); }); testWidgets('library replacement retires the old owner request', (tester) async { @@ -87,13 +85,12 @@ void main() { expect(find.text('Old Library Genre'), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.text('Filters'), findsOneWidget); - harness.dispose(); }); testWidgets('back then clear retires a loading request before closing', (tester) async { final requests = _FilterRequests(); final applied = >[]; - final harness = await _pumpSheet( + await _pumpSheet( tester, loader: requests.load, selectedFilters: const {'studio': 'selected'}, @@ -112,13 +109,12 @@ void main() { requests.request('genre').complete([_value('late', 'Late Genre')]); await tester.pump(); expect(tester.takeException(), isNull); - harness.dispose(); }); testWidgets('missing selected value is preserved until explicit user action', (tester) async { final requests = _FilterRequests(); final applied = >[]; - final harness = await _pumpSheet( + await _pumpSheet( tester, loader: requests.load, selectedFilters: const {'genre': 'missing'}, @@ -132,12 +128,11 @@ void main() { expect(find.text('Clear All'), findsOneWidget); expect(applied, isEmpty); - harness.dispose(); }); testWidgets('load failure has retry state while empty success remains selectable', (tester) async { final requests = _FilterRequests(); - final harness = await _pumpSheet(tester, loader: requests.load); + await _pumpSheet(tester, loader: requests.load); await _openFilter(tester, 'Genre'); requests.request('genre').completeError(StateError('temporary failure')); @@ -154,12 +149,49 @@ void main() { expect(find.byType(ErrorStateWidget), findsNothing); expect(find.text('All'), findsOneWidget); - harness.dispose(); + }); + + testWidgets('settled filter-values states hug while the transient one holds the height', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final requests = _FilterRequests(); + await _pumpSheet(tester, loader: requests.load); + + const cap = 800 * 0.75; + double sheetHeight() => tester.getSize(find.byType(BottomSheetPageScaffold)).height; + + // The root filters list must hug too — nothing else in the suite pins it. + // (`sheet == header + list` is a layout identity for a min-Column with no + // divider, so it holds even under a full fill; only the cap bound below + // actually discriminates.) + final filtersListHeight = sheetHeight(); + expect(filtersListHeight, lessThan(cap), reason: 'two filters must not fill the cap'); + + // Drilling in is a setState page swap inside one sheet, so the transient + // spinner must hold the outgoing height: a change here moves the header and + // its Back button, and moves them straight back when the values land. + await _openFilter(tester, 'Genre'); + expect(sheetHeight(), filtersListHeight, reason: 'the transient spinner must not move the sheet'); + + // Settled states hug — that is the empty space this change exists to remove. + requests.request('genre').completeError(StateError('temporary failure')); + await tester.pumpAndSettle(); + expect(find.byType(ErrorStateWidget), findsOneWidget); + expect(sheetHeight(), lessThan(cap), reason: 'error state must not fill the cap'); + + await tester.tap(find.text('Retry')); + await tester.pump(); + requests.request('genre', 1).complete([_value('action', 'Action')]); + await tester.pumpAndSettle(); + expect(find.text('Action'), findsOneWidget); + expect(sheetHeight(), lessThan(cap), reason: 'short value list must not fill the cap'); }); testWidgets('cached values bypass the lazy loader', (tester) async { var loadCount = 0; - final harness = await _pumpSheet( + await _pumpSheet( tester, loader: (_) async { loadCount++; @@ -174,7 +206,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Cached Genre'), findsOneWidget); expect(loadCount, 0); - harness.dispose(); }); } @@ -226,19 +257,31 @@ Future<_SheetHarness> _pumpSheet( await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - return _SheetHarness(config); + final harness = _SheetHarness(config); + addTearDown(harness.dispose); + return harness; } Future _openFilter(WidgetTester tester, String title) async { await tester.tap(find.text(title)); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); + await _settleSheetResize(tester); } Future _goBack(WidgetTester tester) async { final headerRect = tester.getRect(find.byType(BottomSheetHeader)); await tester.tapAt(headerRect.centerLeft + const Offset(20, 0)); await tester.pump(); + await _settleSheetResize(tester); +} + +/// Advances past the host's 180ms resize tween. A page swap changes the sheet's +/// height, and mid-tween the content is laid out at its final size but clipped +/// by the still-animating box — so header geometry is not tappable until this +/// completes. `pumpAndSettle` cannot be used: the spinner never settles. +Future _settleSheetResize(WidgetTester tester) async { + await tester.pump(const Duration(milliseconds: 200)); } class _FilterRequests { diff --git a/test/widgets/overlay_sheet_test.dart b/test/widgets/overlay_sheet_test.dart index a52cdb5e..8f4d19f8 100644 --- a/test/widgets/overlay_sheet_test.dart +++ b/test/widgets/overlay_sheet_test.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/bottom_sheet_header.dart'; +import 'package:plezy/widgets/bottom_sheet_page_scaffold.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/video_controls/sheets/sheet_split_columns.dart'; void main() { testWidgets('scrollable sheet does not attach to parent primary controller', (tester) async { @@ -95,6 +100,379 @@ void main() { expect(sheetSize.width, 700); }); + testWidgets('a tall desktop window clamps the sheet to the absolute ceiling, a tall phone does not', (tester) async { + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + // Pin both gates the ceiling reads, not just the OS one. + PlatformDetector.debugSetIsDesktopOSOverride(true); + TvDetectionService.debugSetAppleTVOverride(false); + addTearDown(() { + PlatformDetector.debugSetIsDesktopOSOverride(null); + TvDetectionService.debugSetAppleTVOverride(null); + }); + + // 1440p desktop: 75% would be 1080px, which reads as a wall of list. + tester.view.physicalSize = const Size(2560, 1440); + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 720); + + controller.close(); + await tester.pumpAndSettle(); + + // Narrow viewports keep the plain 75% rule: the ceiling is windows-only. + tester.view.physicalSize = const Size(400, 1200); + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 1200 * 0.75); + }); + + testWidgets('a portrait tablet keeps the full 75% height — the ceiling is for resizable windows', (tester) async { + // Wide enough for the 700px width cap, but not a window the user can drag + // taller, so the absolute ceiling must not apply. + PlatformDetector.debugSetIsDesktopOSOverride(false); + TvDetectionService.debugSetAppleTVOverride(false); + addTearDown(() { + PlatformDetector.debugSetIsDesktopOSOverride(null); + TvDetectionService.debugSetAppleTVOverride(null); + }); + tester.view.physicalSize = const Size(1024, 1366); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ); + await tester.pumpAndSettle(); + + final size = tester.getSize(find.byType(BottomSheetPageScaffold)); + expect(size.height, 1366 * 0.75); + expect(size.width, 700, reason: 'the width cap still applies on a wide viewport'); + }); + + testWidgets('TV keeps the full 75% height — the ceiling is for resizable windows', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + PlatformDetector.debugSetIsDesktopOSOverride(true); + addTearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + }); + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ); + await tester.pumpAndSettle(); + + // A 10-foot UI wants every row it can get: 810, not the 720 window ceiling. + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 1080 * 0.75); + }); + + testWidgets('the hostless modal fallback inherits the same sizing rules', (tester) async { + PlatformDetector.debugSetIsDesktopOSOverride(true); + TvDetectionService.debugSetAppleTVOverride(false); + addTearDown(() { + PlatformDetector.debugSetIsDesktopOSOverride(null); + TvDetectionService.debugSetAppleTVOverride(null); + }); + tester.view.physicalSize = const Size(2560, 1440); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + body: Builder( + // No OverlaySheetHost anywhere above: showAdaptive must fall back + // to showModalBottomSheet and still honour the default constraints. + builder: (context) => ElevatedButton( + onPressed: () => unawaited( + OverlaySheetController.showAdaptive( + context, + isScrollControlled: true, + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final size = tester.getSize(find.byType(BottomSheetPageScaffold)); + expect(size.height, 720); + expect(size.width, 700); + }); + + for (final anchor in [Alignment.bottomCenter, Alignment.topCenter]) { + final isTop = anchor.y < 0; + final label = isTop ? 'top' : 'bottom'; + + testWidgets('a $label-anchored sheet keeps its anchored edge still while it grows', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + alignment: anchor, + builder: (_) => const BottomSheetPageScaffold(title: 'Small', child: _FixedRowList(rowCount: 2)), + ), + ); + await tester.pumpAndSettle(); + final animated = find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize)); + final smallHeight = tester.getSize(animated).height; + // The header is the content row adjacent to the anchored edge for a + // top-anchored sheet, and the farthest from it for a bottom-anchored one. + final header = find.byType(BottomSheetHeader); + final headerTopBefore = tester.getTopLeft(header).dy; + + unawaited( + controller.push( + builder: (_) => const BottomSheetPageScaffold(title: 'Big', child: _FixedRowList(rowCount: 6)), + ), + ); + + // Mid-tween: this is the only point where AnimatedSize's `alignment` + // is observable. The child is already laid out at its final size, so a + // wrong alignment shows up as the content sliding against its anchor. + // The first pump starts the tween; the second advances it. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final growing = tester.getSize(animated).height; + expect(growing, greaterThan(smallHeight)); + expect(growing, lessThan(smallHeight + 160)); + if (isTop) { + expect(tester.getTopLeft(header).dy, headerTopBefore, reason: 'header is pinned to the top anchor'); + } else { + // Bottom-anchored: the child is laid out at its final height and + // bottom-pinned, so the header sits at its FINAL y from the first tween + // frame and holds there. Under a wrong (top) alignment it would instead + // track the animating height. Only the exact value distinguishes them. + expect(tester.getTopLeft(header).dy, 800 - (smallHeight + 160)); + expect(tester.getBottomLeft(animated).dy, 800, reason: 'bottom edge stays pinned to the viewport'); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(animated).height, smallHeight + 160); + }); + } + + testWidgets('popping back to a shorter page eases the sheet down', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Small', child: _FixedRowList(rowCount: 2)), + ), + ); + await tester.pumpAndSettle(); + final animated = find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize)); + final smallHeight = tester.getSize(animated).height; + + unawaited( + controller.push( + builder: (_) => const BottomSheetPageScaffold(title: 'Big', child: _FixedRowList(rowCount: 6)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(animated).height, smallHeight + 160); + + controller.pop(); + await tester.pump(); + expect(tester.getSize(animated).height, smallHeight + 160, reason: 'shrink must ease, not jump'); + await tester.pump(const Duration(milliseconds: 90)); + final shrinking = tester.getSize(animated).height; + expect(shrinking, lessThan(smallHeight + 160)); + expect(shrinking, greaterThan(smallHeight)); + await tester.pumpAndSettle(); + expect(tester.getSize(animated).height, smallHeight); + }); + + testWidgets('a short content-sized sheet still needs a deliberate drag to dismiss', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + showDragHandle: true, + builder: (_) => const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2)), + ), + ); + await tester.pumpAndSettle(); + + // ~145px of content plus a 20px drag handle, so a bare 25%-of-height + // threshold would be ~41px — barely more than touch slop. The absolute + // floor is what keeps a stray flick from closing it. + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, lessThan(200)); + + // 90px of gesture is ~70px of drag once the recogniser's slop is consumed: + // past 25% of the sheet, but well short of the floor. + await _slowDragDown(tester, 90); + expect(find.byType(BottomSheetPageScaffold), findsOneWidget, reason: '70px of drag must not dismiss'); + + await _slowDragDown(tester, 140); + expect(find.byType(BottomSheetPageScaffold), findsNothing, reason: '140px clears the floor'); + }); + + testWidgets('a sheet shorter than the dismiss floor stays dismissible by drag', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + unawaited( + controller.show( + showDragHandle: true, + builder: (_) => const BottomSheetPageScaffold(title: 'One', child: _FixedRowList(rowCount: 1)), + ), + ); + await tester.pumpAndSettle(); + + // A one-row menu is shorter than the 96px floor. Left unclamped, the floor + // would demand a drag longer than the sheet itself, so the only way to + // dismiss by distance would be to pull it clean off the screen. + final sheetHeight = tester + .getSize(find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize))) + .height; + expect(sheetHeight, lessThan(96 / 0.6), reason: 'the clamp must actually be the binding rule here'); + + // Just past 60% of the sheet, still short of the raw 96px floor. + await _slowDragDown(tester, sheetHeight * 0.6 + 8 + _dragSlop); + expect(find.byType(BottomSheetPageScaffold), findsNothing, reason: 'clamped floor keeps a short sheet dismissible'); + }); + + testWidgets('sheet page shrinks to short content instead of filling the height cap', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await _pumpHostedSheet(tester, const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2))); + + final sheetHeight = tester.getSize(find.byType(BottomSheetPageScaffold)).height; + final headerHeight = tester.getSize(find.byType(BottomSheetHeader)).height; + + // Exactly header + two 40px rows: no filler between the last row and the + // bottom of the sheet. + expect(sheetHeight, headerHeight + 80); + expect(sheetHeight, lessThan(800 * 0.75)); + }); + + testWidgets('sheet page clamps to the height cap once content overflows', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await _pumpHostedSheet(tester, const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200))); + + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75); + // Still scrollable rather than overflowing. + expect(tester.takeException(), isNull); + await tester.drag(find.byType(ListView), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(find.text('row 0'), findsNothing); + expect( + tester.getSize(find.byType(BottomSheetPageScaffold)).height, + 800 * 0.75, + reason: 'scrolling must not resize the sheet', + ); + }); + + testWidgets('uneven row heights do not make the clamped sheet wobble while scrolling', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // A shrink-wrapping sliver reports an *estimated* max extent extrapolated + // from the rows laid out so far. That estimate can only move the sheet while + // it is near the clamp, so this list is deliberately sized to just overflow + // the 600px cap (11 rows of 40/64/88 = 616px) rather than to swamp it. + await _pumpHostedSheet( + tester, + const BottomSheetPageScaffold(title: 'Uneven', child: _FixedRowList(rowCount: 11, varyHeights: true)), + ); + + for (var step = 0; step < 4; step++) { + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75, reason: 'step $step'); + await tester.drag(find.byType(ListView), const Offset(0, -40)); + await tester.pumpAndSettle(); + } + expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75); + }); + + testWidgets('split columns size to the taller column and stretch the rule to match', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await _pumpHostedSheet( + tester, + const SheetSplitColumns(start: _FixedRowList(rowCount: 2), end: _FixedRowList(rowCount: 3)), + ); + + // Taller column wins; the hairline rule must not drag the row to the cap. + expect(tester.getSize(find.byType(SheetSplitColumns)).height, 120); + final rule = tester.getSize( + find.descendant(of: find.byType(SheetSplitColumns), matching: find.byType(VerticalDivider)), + ); + expect(rule, const Size(1, 120)); + }); + + testWidgets('replacing an open sheet adopts the new height instead of easing down from the old one', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final controller = await _pumpIdleHost(tester); + + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(AnimatedSize)).height, 800 * 0.75); + + // Replacing without closing keeps the sheet mounted, so only the per-sheet + // animation key stops the new page from easing down from 600px. + unawaited( + controller.show( + builder: (_) => const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2)), + ), + ); + await tester.pump(); + + final headerHeight = tester.getSize(find.byType(BottomSheetHeader)).height; + expect(tester.getSize(find.byType(AnimatedSize)).height, headerHeight + 80); + }); + testWidgets('pointer-opened sheet claims focus and handles Back before the screen', (tester) async { final screenFocusNode = FocusNode(debugLabel: 'Screen'); addTearDown(screenFocusNode.dispose); @@ -428,3 +806,82 @@ void main() { }); }); } + +/// Shrink-wrapping list for the sizing tests: 40px rows by default, or a +/// 40/64/88 cycle when [varyHeights] is set so the sliver's estimated extent +/// keeps changing as rows are laid out. +class _FixedRowList extends StatelessWidget { + final int rowCount; + final bool varyHeights; + + const _FixedRowList({required this.rowCount, this.varyHeights = false}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + shrinkWrap: true, + itemCount: rowCount, + itemBuilder: (_, index) => SizedBox(height: varyHeights ? 40 + (index % 3) * 24 : 40, child: Text('row $index')), + ); + } +} + +Future _pumpHostedSheet(WidgetTester tester, Widget content) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: OverlaySheetHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => OverlaySheetController.of(context).show(builder: (_) => content), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +/// Distance the vertical drag recogniser swallows before it starts reporting +/// updates, in 10px steps: two steps to clear `kTouchSlop`. +const _dragSlop = 20.0; + +/// Drags the sheet down by [distance] in steps, so only the distance threshold +/// can decide. A single-move `tester.drag` would never emit `onUpdate` past the +/// recogniser's slop, which is why this steps by hand; the fling-velocity +/// escape hatch cannot fire either way, because `TestGesture.moveBy` stamps +/// every pointer event with `Duration.zero`. +Future _slowDragDown(WidgetTester tester, double distance) async { + final gesture = await tester.startGesture(tester.getCenter(find.byType(BottomSheetHeader))); + for (var moved = 0.0; moved < distance; moved += 10) { + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(const Duration(milliseconds: 40)); + } + await gesture.up(); + await tester.pumpAndSettle(); +} + +Future _pumpIdleHost(WidgetTester tester) async { + late OverlaySheetController controller; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: OverlaySheetHost( + child: Builder( + builder: (context) { + controller = OverlaySheetController.of(context); + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + return controller; +} diff --git a/test/widgets/rating_bottom_sheet_test.dart b/test/widgets/rating_bottom_sheet_test.dart new file mode 100644 index 00000000..b123340d --- /dev/null +++ b/test/widgets/rating_bottom_sheet_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/providers/trackers_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/mdblist/mdblist_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/tracker_account_store.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/rating_bottom_sheet.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/io_fakes.dart'; +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +/// Sizing suite for [RatingBottomSheet]. The sheet no longer carries its own +/// `ConstrainedBox(maxHeight: height * 0.64/0.74)`: it is a +/// `Column(mainAxisSize: .min)` + `Flexible` + `ListView(shrinkWrap: true)` +/// whose only ceiling is the [OverlaySheetHost] cap (`viewportHeight * 0.75`, +/// itself capped at 720 on a desktop OS). Every test drives the real host so +/// the cap under test is the production one. +void main() { + setUp(() { + resetSharedPreferencesForTest(); + LocaleSettings.setLocaleSync(AppLocale.en); + _resetTrackerBindings(); + }); + tearDown(_resetTrackerBindings); + + testWidgets('an unratable tracker keeps its row so the sheet cannot resize under the user', (tester) async { + // The score load resolves asynchronously and finds every tracker unratable + // for this item. Removing those rows would shorten the sheet hundreds of ms + // after it opens, and because sheets are bottom-anchored that slides the + // rows above them — live rating controls — out from under the user. + await _seedAllTrackerSessions(_profileUuid); + await _pumpRatingSheet( + tester, + viewport: const Size(1280, 800), + serverClient: _StubServerClient(ServerCapabilities.plex), + profileUuid: _profileUuid, + item: testMediaItem(id: 'ep-1', kind: MediaKind.episode, serverId: 'server-1', serverName: 'Living Room'), + ); + + // Post-frame resolve has already run and reported every tracker unavailable. + expect(find.text(t.rateSheet.notAvailable), findsNWidgets(5)); + expect(_listContentExtent(tester), _tallListExtent, reason: 'no row may be dropped'); + + final heightAfterResolve = tester.getSize(_sheetFinder).height; + final serverRowTop = tester.getRect(find.text(t.rateSheet.server)).top; + + // Pump well past any further async settling: nothing may move. + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(tester.getSize(_sheetFinder).height, heightAfterResolve); + expect(tester.getRect(find.text(t.rateSheet.server)).top, serverRowTop); + }); +} + +/// The sheet body itself — the direct child of the host's capping +/// `ConstrainedBox`, so its height *is* the clamped sheet height. +final Finder _sheetFinder = find.byType(RatingBottomSheet); + +/// Six 54px rows (48px min-height row + 6px gap) plus the list's 4/12 padding. +const double _tallListExtent = 6 * 54 + 16; + +double _listContentExtent(WidgetTester tester) { + final position = tester + .state(find.descendant(of: _sheetFinder, matching: find.byType(Scrollable))) + .position; + return position.maxScrollExtent + position.viewportDimension; +} + +const _profileUuid = 'profile-1'; + +Future _pumpRatingSheet( + WidgetTester tester, { + required Size viewport, + MediaServerClient? serverClient, + String? profileUuid, + MediaItem? item, +}) async { + tester.view.physicalSize = viewport; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // Pin the two platform gates the host's cap consults, so the 0.75/720 math + // is the same on every machine running the suite. + PlatformDetector.debugSetIsDesktopOSOverride(true); + addTearDown(() => PlatformDetector.debugSetIsDesktopOSOverride(null)); + TvDetectionService.debugSetAppleTVOverride(false); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final trackers = TrackersProvider(httpClientFactory: () => FakeHttpClient(200, const [])); + addTearDown(trackers.dispose); + if (profileUuid != null) { + await trackers.onActiveProfileChanged(profileUuid); + } + + final resolvedItem = item ?? testMediaItem(id: 'item-1', serverId: 'server-1', serverName: 'Living Room'); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: trackers, + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]), + home: OverlaySheetHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => OverlaySheetController.of(context).show( + builder: (_) => RatingBottomSheet(item: resolvedItem, serverClient: serverClient), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +Future _seedAllTrackerSessions(String uuid) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (final service in TrackerService.values) { + await trackerAccountStore(service).save( + uuid, + TrackerSession( + accessToken: '${service.name}-at', + refreshToken: '${service.name}-rt', + expiresAt: now + 3600, + createdAt: now, + username: 'tester', + ), + ); + } + BaseSharedPreferencesService.resetForTesting(); +} + +/// The tracker singletons outlive a test; unbind the seeded sessions so the +/// next case starts disconnected. +void _resetTrackerBindings() { + MalTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); +} + +/// Narrow stand-in for the media server: while laying out, the sheet reads only +/// [capabilities] (which decides whether the server row renders at all), +/// [backend], [serverName], and [serverId]. +class _StubServerClient implements MediaServerClient { + _StubServerClient(this.capabilities); + + @override + final ServerCapabilities capabilities; + + @override + ServerId get serverId => ServerId('server-1'); + + @override + String? get serverName => 'Living Room'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/subtitle_search_sheet_test.dart b/test/widgets/subtitle_search_sheet_test.dart index 8a532f58..99f576b7 100644 --- a/test/widgets/subtitle_search_sheet_test.dart +++ b/test/widgets/subtitle_search_sheet_test.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/sheets/subtitle_search_sheet.dart'; +import '../test_helpers/theme.dart'; + void main() { group('resolveSubtitleSearchLanguageCode', () { test('prefers saved language over system language', () { @@ -21,4 +26,72 @@ void main() { expect(resolveSubtitleSearchLanguageCode(savedLanguageCode: 'zz', systemLocale: const Locale('xx')), 'en'); }); }); + + group('sheet geometry', () { + setUp(() { + LocaleSettings.setLocaleSync(AppLocale.en); + // The 720 assertion below is the desktop-window ceiling, so pin both + // gates it reads rather than inheriting the host OS. + PlatformDetector.debugSetIsDesktopOSOverride(true); + TvDetectionService.debugSetAppleTVOverride(false); + }); + + tearDown(() { + PlatformDetector.debugSetIsDesktopOSOverride(null); + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('search body keeps a stable height so the focused field cannot slide', (tester) async { + tester.view.physicalSize = const Size(1280, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await _pumpSearchSheet(tester); + + // No MultiServerProvider, so the search resolves to no client and the + // results area is empty. A shrink-wrapping body would collapse here. + final emptyHeight = _sheetHeight(tester); + final fieldTop = tester.getTopLeft(find.byType(TextField)).dy; + expect(emptyHeight, 720, reason: 'search surface fills the windowed height ceiling'); + + // Switching to the language picker and filtering it down to a couple of + // matches must not move the geometry either. + await tester.tap(find.text('English')); + await tester.pumpAndSettle(); + expect(_sheetHeight(tester), emptyHeight); + + await tester.enterText(find.byType(TextField), 'zulu'); + await tester.pumpAndSettle(); + expect(find.text('Zulu'), findsOneWidget); + expect(_sheetHeight(tester), emptyHeight, reason: 'per-keystroke match count must not resize the sheet'); + expect(tester.getTopLeft(find.byType(TextField)).dy, fieldTop); + }); + }); +} + +/// Height of the sheet box the host lays out, i.e. what the user sees. +double _sheetHeight(WidgetTester tester) { + return tester.getSize(find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize))).height; +} + +Future _pumpSearchSheet(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: OverlaySheetHost( + child: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => OverlaySheetController.of(context).show( + builder: (_) => const SubtitleSearchSheet(ratingKey: '1', serverId: 'server'), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); }