From 0fd050655175c6294274bd75fee217119e3e2dc9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:18:32 +0200 Subject: [PATCH] feat(media): lazily page detail episodes close #1245 --- lib/i18n/bg.i18n.json | 2 + lib/i18n/da.i18n.json | 2 + lib/i18n/de.i18n.json | 2 + lib/i18n/en.i18n.json | 2 + lib/i18n/es.i18n.json | 2 + lib/i18n/fr.i18n.json | 2 + lib/i18n/it.i18n.json | 2 + lib/i18n/ja.i18n.json | 2 + lib/i18n/ko.i18n.json | 2 + lib/i18n/nb.i18n.json | 2 + lib/i18n/nl.i18n.json | 2 + lib/i18n/pl.i18n.json | 2 + lib/i18n/pt.i18n.json | 2 + lib/i18n/ru.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 12 +- lib/i18n/strings_da.g.dart | 12 +- lib/i18n/strings_de.g.dart | 12 +- lib/i18n/strings_en.g.dart | 16 +- lib/i18n/strings_es.g.dart | 12 +- lib/i18n/strings_fr.g.dart | 12 +- lib/i18n/strings_it.g.dart | 12 +- lib/i18n/strings_ja.g.dart | 12 +- lib/i18n/strings_ko.g.dart | 12 +- lib/i18n/strings_nb.g.dart | 12 +- lib/i18n/strings_nl.g.dart | 12 +- lib/i18n/strings_pl.g.dart | 12 +- lib/i18n/strings_pt.g.dart | 12 +- lib/i18n/strings_ru.g.dart | 12 +- lib/i18n/strings_sv.g.dart | 12 +- lib/i18n/strings_zh.g.dart | 12 +- lib/i18n/sv.i18n.json | 2 + lib/i18n/zh.i18n.json | 2 + lib/media/episode_collection.dart | 172 ++++ lib/media/media_server_client.dart | 12 +- lib/media/paged_media_list_state.dart | 115 +++ lib/providers/download_provider.dart | 2 +- lib/screens/media_detail_screen.dart | 915 ++++++++++++------ .../jellyfin_client/parts/browse.dart | 76 ++ lib/services/plex_client.dart | 86 +- lib/services/sync_rule_executor.dart | 2 +- .../trackers/tracker_coordinator.dart | 2 +- lib/services/trakt/trakt_sync_service.dart | 2 +- lib/utils/download_version_utils.dart | 12 +- lib/utils/episode_collection.dart | 78 -- lib/widgets/tv_browse_rail.dart | 225 ++++- 46 files changed, 1428 insertions(+), 501 deletions(-) create mode 100644 lib/media/episode_collection.dart create mode 100644 lib/media/paged_media_list_state.dart delete mode 100644 lib/utils/episode_collection.dart diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 6bd2cbf0..e9c06286 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Неуспешно опресняване на метаданни: ${error}", "logoutConfirm": "Сигурни ли сте, че искате да излезете?", "noSeasonsFound": "Не са намерени сезони", + "seasonsLoadFailed": "Неуспешно зареждане на сезони", "noEpisodesFound": "Не са намерени епизоди в първия сезон", "noEpisodesFoundGeneral": "Не са намерени епизоди", + "episodesLoadFailed": "Неуспешно зареждане на епизоди", "noResultsFound": "Няма намерени резултати", "sleepTimerSet": "Таймерът за заспиване е зададен за ${label}", "noItemsAvailable": "Няма налични елементи", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 954b0c46..26179794 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Kunne ikke opdatere metadata: ${error}", "logoutConfirm": "Er du sikker på, at du vil logge ud?", "noSeasonsFound": "Ingen sæsoner fundet", + "seasonsLoadFailed": "Kunne ikke indlæse sæsoner", "noEpisodesFound": "Ingen episoder fundet i første sæson", "noEpisodesFoundGeneral": "Ingen episoder fundet", + "episodesLoadFailed": "Kunne ikke indlæse episoder", "noResultsFound": "Ingen resultater fundet", "sleepTimerSet": "Sove-timer indstillet til ${label}", "noItemsAvailable": "Ingen elementer tilgængelige", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 60d18f3a..79d71994 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", "logoutConfirm": "Abmeldung wirklich durchführen?", "noSeasonsFound": "Keine Staffeln gefunden", + "seasonsLoadFailed": "Staffeln konnten nicht geladen werden", "noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden", "noEpisodesFoundGeneral": "Keine Episoden gefunden", + "episodesLoadFailed": "Episoden konnten nicht geladen werden", "noResultsFound": "Keine Ergebnisse gefunden", "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", "noItemsAvailable": "Keine Elemente verfügbar", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 09517fde..a5e490df 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Failed to refresh metadata: ${error}", "logoutConfirm": "Are you sure you want to logout?", "noSeasonsFound": "No seasons found", + "seasonsLoadFailed": "Couldn't load seasons", "noEpisodesFound": "No episodes found in first season", "noEpisodesFoundGeneral": "No episodes found", + "episodesLoadFailed": "Couldn't load episodes", "noResultsFound": "No results found", "sleepTimerSet": "Sleep timer set for ${label}", "noItemsAvailable": "No items available", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 16c20ef4..4664de99 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Error al actualizar metadatos: ${error}", "logoutConfirm": "¿Estás seguro de que quieres cerrar sesión?", "noSeasonsFound": "No se encontraron temporadas", + "seasonsLoadFailed": "No se pudieron cargar las temporadas", "noEpisodesFound": "No se encontraron episodios en la primera temporada", "noEpisodesFoundGeneral": "No se encontraron episodios", + "episodesLoadFailed": "No se pudieron cargar los episodios", "noResultsFound": "No se encontraron resultados", "sleepTimerSet": "Temporizador establecido en ${label}", "noItemsAvailable": "No hay elementos disponibles", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 29d699dd..8fa6d1e6 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Échec de l'actualisation des métadonnées: ${error}", "logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ?", "noSeasonsFound": "Aucune saison trouvée", + "seasonsLoadFailed": "Impossible de charger les saisons", "noEpisodesFound": "Aucun épisode trouvé dans la première saison", "noEpisodesFoundGeneral": "Aucun épisode trouvé", + "episodesLoadFailed": "Impossible de charger les épisodes", "noResultsFound": "Aucun résultat trouvé", "sleepTimerSet": "Minuterie de mise en veille réglée sur ${label}", "noItemsAvailable": "Aucun élément disponible", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index ba64b253..4cf9f618 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Errore aggiornamento metadati: ${error}", "logoutConfirm": "Sei sicuro di volerti disconnettere?", "noSeasonsFound": "Nessuna stagione trovata", + "seasonsLoadFailed": "Impossibile caricare le stagioni", "noEpisodesFound": "Nessun episodio trovato nella prima stagione", "noEpisodesFoundGeneral": "Nessun episodio trovato", + "episodesLoadFailed": "Impossibile caricare gli episodi", "noResultsFound": "Nessun risultato", "sleepTimerSet": "Imposta timer spegnimento per ${label}", "noItemsAvailable": "Nessun elemento disponibile", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 9ab96475..f7e3c5eb 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "メタデータの更新に失敗しました: ${error}", "logoutConfirm": "ログアウトしてもよろしいですか?", "noSeasonsFound": "シーズンが見つかりません", + "seasonsLoadFailed": "シーズンを読み込めませんでした", "noEpisodesFound": "最初のシーズンにエピソードが見つかりません", "noEpisodesFoundGeneral": "エピソードが見つかりません", + "episodesLoadFailed": "エピソードを読み込めませんでした", "noResultsFound": "結果が見つかりません", "sleepTimerSet": "スリープタイマーを${label}に設定しました", "noItemsAvailable": "アイテムがありません", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f1fe3085..092758b8 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "메타데이터 새로고침 실패: ${error}", "logoutConfirm": "로그아웃 하시겠습니까?", "noSeasonsFound": "시즌을 찾을 수 없음", + "seasonsLoadFailed": "시즌을 불러오지 못했습니다", "noEpisodesFound": "시즌 1에서 에피소드를 찾을 수 없습니다", "noEpisodesFoundGeneral": "에피소드를 찾을 수 없습니다", + "episodesLoadFailed": "에피소드를 불러오지 못했습니다", "noResultsFound": "결과를 찾을 수 없습니다", "sleepTimerSet": "수면 타이머가 ${label}로 설정 되었습니다", "noItemsAvailable": "사용 가능한 항목이 없습니다", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 67688bdf..8248de25 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Kunne ikke oppdatere metadata: ${error}", "logoutConfirm": "Er du sikker på at du vil logge ut?", "noSeasonsFound": "Ingen sesonger funnet", + "seasonsLoadFailed": "Kunne ikke laste sesonger", "noEpisodesFound": "Ingen episoder funnet i første sesong", "noEpisodesFoundGeneral": "Ingen episoder funnet", + "episodesLoadFailed": "Kunne ikke laste episoder", "noResultsFound": "Ingen resultater funnet", "sleepTimerSet": "Søvntimer satt til ${label}", "noItemsAvailable": "Ingen elementer tilgjengelig", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c820d77a..bf542b11 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Kon metadata niet vernieuwen: ${error}", "logoutConfirm": "Weet je zeker dat je wilt uitloggen?", "noSeasonsFound": "Geen seizoenen gevonden", + "seasonsLoadFailed": "Kan seizoenen niet laden", "noEpisodesFound": "Geen afleveringen gevonden in eerste seizoen", "noEpisodesFoundGeneral": "Geen afleveringen gevonden", + "episodesLoadFailed": "Kan afleveringen niet laden", "noResultsFound": "Geen resultaten gevonden", "sleepTimerSet": "Slaap timer ingesteld voor ${label}", "noItemsAvailable": "Geen items beschikbaar", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 071fe56c..4066da2a 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Nie udało się odświeżyć metadanych: ${error}", "logoutConfirm": "Czy na pewno chcesz się wylogować?", "noSeasonsFound": "Nie znaleziono sezonów", + "seasonsLoadFailed": "Nie udało się załadować sezonów", "noEpisodesFound": "Nie znaleziono odcinków w pierwszym sezonie", "noEpisodesFoundGeneral": "Nie znaleziono odcinków", + "episodesLoadFailed": "Nie udało się załadować odcinków", "noResultsFound": "Nie znaleziono wyników", "sleepTimerSet": "Wyłącznik czasowy ustawiony na ${label}", "noItemsAvailable": "Brak dostępnych elementów", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index bc4003d8..706611a0 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Falha ao atualizar metadados: ${error}", "logoutConfirm": "Tem certeza que deseja sair?", "noSeasonsFound": "Nenhuma temporada encontrada", + "seasonsLoadFailed": "Não foi possível carregar as temporadas", "noEpisodesFound": "Nenhum episódio encontrado na primeira temporada", "noEpisodesFoundGeneral": "Nenhum episódio encontrado", + "episodesLoadFailed": "Não foi possível carregar os episódios", "noResultsFound": "Nenhum resultado encontrado", "sleepTimerSet": "Timer de sono definido para ${label}", "noItemsAvailable": "Nenhum item disponível", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 8d7acc44..97138fb4 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Не удалось обновить метаданные: ${error}", "logoutConfirm": "Вы уверены, что хотите выйти?", "noSeasonsFound": "Сезоны не найдены", + "seasonsLoadFailed": "Не удалось загрузить сезоны", "noEpisodesFound": "Эпизоды в первом сезоне не найдены", "noEpisodesFoundGeneral": "Эпизоды не найдены", + "episodesLoadFailed": "Не удалось загрузить эпизоды", "noResultsFound": "Результаты не найдены", "sleepTimerSet": "Таймер сна установлен на ${label}", "noItemsAvailable": "Нет доступных элементов", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index da6626e3..623b51ef 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 19072 (1192 per locale) +/// Strings: 19104 (1194 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 623211ed..69a3591a 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesBg extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Неуспешно опресняване на метаданни: ${error}'; @override String get logoutConfirm => 'Сигурни ли сте, че искате да излезете?'; @override String get noSeasonsFound => 'Не са намерени сезони'; + @override String get seasonsLoadFailed => 'Неуспешно зареждане на сезони'; @override String get noEpisodesFound => 'Не са намерени епизоди в първия сезон'; @override String get noEpisodesFoundGeneral => 'Не са намерени епизоди'; + @override String get episodesLoadFailed => 'Неуспешно зареждане на епизоди'; @override String get noResultsFound => 'Няма намерени резултати'; @override String sleepTimerSet({required Object label}) => 'Таймерът за заспиване е зададен за ${label}'; @override String get noItemsAvailable => 'Няма налични елементи'; @@ -2295,8 +2297,10 @@ extension on TranslationsBg { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Неуспешно опресняване на метаданни: ${error}', 'messages.logoutConfirm' => 'Сигурни ли сте, че искате да излезете?', 'messages.noSeasonsFound' => 'Не са намерени сезони', + 'messages.seasonsLoadFailed' => 'Неуспешно зареждане на сезони', 'messages.noEpisodesFound' => 'Не са намерени епизоди в първия сезон', 'messages.noEpisodesFoundGeneral' => 'Не са намерени епизоди', + 'messages.episodesLoadFailed' => 'Неуспешно зареждане на епизоди', 'messages.noResultsFound' => 'Няма намерени резултати', 'messages.sleepTimerSet' => ({required Object label}) => 'Таймерът за заспиване е зададен за ${label}', 'messages.noItemsAvailable' => 'Няма налични елементи', @@ -2331,10 +2335,10 @@ extension on TranslationsBg { 'mpvConfig.loadPreset' => 'Зареди', 'mpvConfig.deletePreset' => 'Изтрий', 'mpvConfig.presetSaved' => 'Пресетът е запазен', - 'mpvConfig.presetLoaded' => 'Пресетът е зареден', - 'mpvConfig.presetDeleted' => 'Пресетът е изтрит', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Пресетът е зареден', + 'mpvConfig.presetDeleted' => 'Пресетът е изтрит', 'mpvConfig.confirmDeletePreset' => 'Сигурни ли сте, че искате да изтриете този пресет?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Потвърждение на действие', @@ -2845,10 +2849,10 @@ extension on TranslationsBg { 'externalPlayer.playerPackage' => 'Име на пакет', 'externalPlayer.playerUrlScheme' => 'URL схема', 'externalPlayer.off' => 'Изключено', - 'externalPlayer.launchFailed' => 'Неуспешно отваряне на външен плеър', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} не е инсталиран', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Неуспешно отваряне на външен плеър', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} не е инсталиран', 'externalPlayer.playInExternalPlayer' => 'Пусни във външен плеър', 'metadataEdit.editMetadata' => 'Редактирай...', 'metadataEdit.screenTitle' => 'Редактиране на метаданни', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 86735287..7dada554 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesDa extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Kunne ikke opdatere metadata: ${error}'; @override String get logoutConfirm => 'Er du sikker på, at du vil logge ud?'; @override String get noSeasonsFound => 'Ingen sæsoner fundet'; + @override String get seasonsLoadFailed => 'Kunne ikke indlæse sæsoner'; @override String get noEpisodesFound => 'Ingen episoder fundet i første sæson'; @override String get noEpisodesFoundGeneral => 'Ingen episoder fundet'; + @override String get episodesLoadFailed => 'Kunne ikke indlæse episoder'; @override String get noResultsFound => 'Ingen resultater fundet'; @override String sleepTimerSet({required Object label}) => 'Sove-timer indstillet til ${label}'; @override String get noItemsAvailable => 'Ingen elementer tilgængelige'; @@ -2295,8 +2297,10 @@ extension on TranslationsDa { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Kunne ikke opdatere metadata: ${error}', 'messages.logoutConfirm' => 'Er du sikker på, at du vil logge ud?', 'messages.noSeasonsFound' => 'Ingen sæsoner fundet', + 'messages.seasonsLoadFailed' => 'Kunne ikke indlæse sæsoner', 'messages.noEpisodesFound' => 'Ingen episoder fundet i første sæson', 'messages.noEpisodesFoundGeneral' => 'Ingen episoder fundet', + 'messages.episodesLoadFailed' => 'Kunne ikke indlæse episoder', 'messages.noResultsFound' => 'Ingen resultater fundet', 'messages.sleepTimerSet' => ({required Object label}) => 'Sove-timer indstillet til ${label}', 'messages.noItemsAvailable' => 'Ingen elementer tilgængelige', @@ -2331,10 +2335,10 @@ extension on TranslationsDa { 'mpvConfig.loadPreset' => 'Indlæs', 'mpvConfig.deletePreset' => 'Slet', 'mpvConfig.presetSaved' => 'Forudindstilling gemt', - 'mpvConfig.presetLoaded' => 'Forudindstilling indlæst', - 'mpvConfig.presetDeleted' => 'Forudindstilling slettet', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Forudindstilling indlæst', + 'mpvConfig.presetDeleted' => 'Forudindstilling slettet', 'mpvConfig.confirmDeletePreset' => 'Er du sikker på, at du vil slette denne forudindstilling?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bekræft handling', @@ -2845,10 +2849,10 @@ extension on TranslationsDa { 'externalPlayer.playerPackage' => 'Pakkenavn', 'externalPlayer.playerUrlScheme' => 'URL-skema', 'externalPlayer.off' => 'Fra', - 'externalPlayer.launchFailed' => 'Kunne ikke åbne ekstern afspiller', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} er ikke installeret', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Kunne ikke åbne ekstern afspiller', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} er ikke installeret', 'externalPlayer.playInExternalPlayer' => 'Afspil i ekstern afspiller', 'metadataEdit.editMetadata' => 'Redigér...', 'metadataEdit.screenTitle' => 'Redigér metadata', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 53c69b06..5c24d1ab 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesDe extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; @override String get logoutConfirm => 'Abmeldung wirklich durchführen?'; @override String get noSeasonsFound => 'Keine Staffeln gefunden'; + @override String get seasonsLoadFailed => 'Staffeln konnten nicht geladen werden'; @override String get noEpisodesFound => 'Keine Episoden in der ersten Staffel gefunden'; @override String get noEpisodesFoundGeneral => 'Keine Episoden gefunden'; + @override String get episodesLoadFailed => 'Episoden konnten nicht geladen werden'; @override String get noResultsFound => 'Keine Ergebnisse gefunden'; @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; @override String get noItemsAvailable => 'Keine Elemente verfügbar'; @@ -2295,8 +2297,10 @@ extension on TranslationsDe { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}', 'messages.logoutConfirm' => 'Abmeldung wirklich durchführen?', 'messages.noSeasonsFound' => 'Keine Staffeln gefunden', + 'messages.seasonsLoadFailed' => 'Staffeln konnten nicht geladen werden', 'messages.noEpisodesFound' => 'Keine Episoden in der ersten Staffel gefunden', 'messages.noEpisodesFoundGeneral' => 'Keine Episoden gefunden', + 'messages.episodesLoadFailed' => 'Episoden konnten nicht geladen werden', 'messages.noResultsFound' => 'Keine Ergebnisse gefunden', 'messages.sleepTimerSet' => ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}', 'messages.noItemsAvailable' => 'Keine Elemente verfügbar', @@ -2331,10 +2335,10 @@ extension on TranslationsDe { 'mpvConfig.loadPreset' => 'Laden', 'mpvConfig.deletePreset' => 'Löschen', 'mpvConfig.presetSaved' => 'Voreinstellung gespeichert', - 'mpvConfig.presetLoaded' => 'Voreinstellung geladen', - 'mpvConfig.presetDeleted' => 'Voreinstellung gelöscht', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Voreinstellung geladen', + 'mpvConfig.presetDeleted' => 'Voreinstellung gelöscht', 'mpvConfig.confirmDeletePreset' => 'Möchten Sie diese Voreinstellung wirklich löschen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Aktion bestätigen', @@ -2845,10 +2849,10 @@ extension on TranslationsDe { 'externalPlayer.playerPackage' => 'Paketname', 'externalPlayer.playerUrlScheme' => 'URL-Schema', 'externalPlayer.off' => 'Aus', - 'externalPlayer.launchFailed' => 'Externer Player konnte nicht geöffnet werden', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} ist nicht installiert', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Externer Player konnte nicht geöffnet werden', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} ist nicht installiert', 'externalPlayer.playInExternalPlayer' => 'In externem Player abspielen', 'metadataEdit.editMetadata' => 'Bearbeiten...', 'metadataEdit.screenTitle' => 'Metadaten bearbeiten', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 78e95d4a..85a2b0a1 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1560,12 +1560,18 @@ class TranslationsMessagesEn { /// en: 'No seasons found' String get noSeasonsFound => 'No seasons found'; + /// en: 'Couldn't load seasons' + String get seasonsLoadFailed => 'Couldn\'t load seasons'; + /// en: 'No episodes found in first season' String get noEpisodesFound => 'No episodes found in first season'; /// en: 'No episodes found' String get noEpisodesFoundGeneral => 'No episodes found'; + /// en: 'Couldn't load episodes' + String get episodesLoadFailed => 'Couldn\'t load episodes'; + /// en: 'No results found' String get noResultsFound => 'No results found'; @@ -4685,8 +4691,10 @@ extension on Translations { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Failed to refresh metadata: ${error}', 'messages.logoutConfirm' => 'Are you sure you want to logout?', 'messages.noSeasonsFound' => 'No seasons found', + 'messages.seasonsLoadFailed' => 'Couldn\'t load seasons', 'messages.noEpisodesFound' => 'No episodes found in first season', 'messages.noEpisodesFoundGeneral' => 'No episodes found', + 'messages.episodesLoadFailed' => 'Couldn\'t load episodes', 'messages.noResultsFound' => 'No results found', 'messages.sleepTimerSet' => ({required Object label}) => 'Sleep timer set for ${label}', 'messages.noItemsAvailable' => 'No items available', @@ -4721,10 +4729,10 @@ extension on Translations { 'mpvConfig.loadPreset' => 'Load', 'mpvConfig.deletePreset' => 'Delete', 'mpvConfig.presetSaved' => 'Preset saved', - 'mpvConfig.presetLoaded' => 'Preset loaded', - 'mpvConfig.presetDeleted' => 'Preset deleted', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Preset loaded', + 'mpvConfig.presetDeleted' => 'Preset deleted', 'mpvConfig.confirmDeletePreset' => 'Are you sure you want to delete this preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirm Action', @@ -5235,10 +5243,10 @@ extension on Translations { 'externalPlayer.playerPackage' => 'Package Name', 'externalPlayer.playerUrlScheme' => 'URL Scheme', 'externalPlayer.off' => 'Off', - 'externalPlayer.launchFailed' => 'Failed to open external player', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Failed to open external player', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', 'externalPlayer.playInExternalPlayer' => 'Play in External Player', 'metadataEdit.editMetadata' => 'Edit...', 'metadataEdit.screenTitle' => 'Edit Metadata', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ec3a03eb..3d518cb4 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesEs extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Error al actualizar metadatos: ${error}'; @override String get logoutConfirm => '¿Estás seguro de que quieres cerrar sesión?'; @override String get noSeasonsFound => 'No se encontraron temporadas'; + @override String get seasonsLoadFailed => 'No se pudieron cargar las temporadas'; @override String get noEpisodesFound => 'No se encontraron episodios en la primera temporada'; @override String get noEpisodesFoundGeneral => 'No se encontraron episodios'; + @override String get episodesLoadFailed => 'No se pudieron cargar los episodios'; @override String get noResultsFound => 'No se encontraron resultados'; @override String sleepTimerSet({required Object label}) => 'Temporizador establecido en ${label}'; @override String get noItemsAvailable => 'No hay elementos disponibles'; @@ -2295,8 +2297,10 @@ extension on TranslationsEs { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Error al actualizar metadatos: ${error}', 'messages.logoutConfirm' => '¿Estás seguro de que quieres cerrar sesión?', 'messages.noSeasonsFound' => 'No se encontraron temporadas', + 'messages.seasonsLoadFailed' => 'No se pudieron cargar las temporadas', 'messages.noEpisodesFound' => 'No se encontraron episodios en la primera temporada', 'messages.noEpisodesFoundGeneral' => 'No se encontraron episodios', + 'messages.episodesLoadFailed' => 'No se pudieron cargar los episodios', 'messages.noResultsFound' => 'No se encontraron resultados', 'messages.sleepTimerSet' => ({required Object label}) => 'Temporizador establecido en ${label}', 'messages.noItemsAvailable' => 'No hay elementos disponibles', @@ -2331,10 +2335,10 @@ extension on TranslationsEs { 'mpvConfig.loadPreset' => 'Cargar', 'mpvConfig.deletePreset' => 'Eliminar', 'mpvConfig.presetSaved' => 'Ajuste guardado', - 'mpvConfig.presetLoaded' => 'Ajuste cargado', - 'mpvConfig.presetDeleted' => 'Ajuste eliminado', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Ajuste cargado', + 'mpvConfig.presetDeleted' => 'Ajuste eliminado', 'mpvConfig.confirmDeletePreset' => '¿Estás seguro de que quieres eliminar este ajuste?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmar Acción', @@ -2845,10 +2849,10 @@ extension on TranslationsEs { 'externalPlayer.playerPackage' => 'Nombre del paquete', 'externalPlayer.playerUrlScheme' => 'Esquema URL', 'externalPlayer.off' => 'Desactivado', - 'externalPlayer.launchFailed' => 'No se pudo abrir el reproductor externo', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} no está instalado', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'No se pudo abrir el reproductor externo', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} no está instalado', 'externalPlayer.playInExternalPlayer' => 'Reproducir en reproductor externo', 'metadataEdit.editMetadata' => 'Editar...', 'metadataEdit.screenTitle' => 'Editar metadatos', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 1fb0872b..102298a4 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesFr extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Échec de l\'actualisation des métadonnées: ${error}'; @override String get logoutConfirm => 'Êtes-vous sûr de vouloir vous déconnecter ?'; @override String get noSeasonsFound => 'Aucune saison trouvée'; + @override String get seasonsLoadFailed => 'Impossible de charger les saisons'; @override String get noEpisodesFound => 'Aucun épisode trouvé dans la première saison'; @override String get noEpisodesFoundGeneral => 'Aucun épisode trouvé'; + @override String get episodesLoadFailed => 'Impossible de charger les épisodes'; @override String get noResultsFound => 'Aucun résultat trouvé'; @override String sleepTimerSet({required Object label}) => 'Minuterie de mise en veille réglée sur ${label}'; @override String get noItemsAvailable => 'Aucun élément disponible'; @@ -2295,8 +2297,10 @@ extension on TranslationsFr { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Échec de l\'actualisation des métadonnées: ${error}', 'messages.logoutConfirm' => 'Êtes-vous sûr de vouloir vous déconnecter ?', 'messages.noSeasonsFound' => 'Aucune saison trouvée', + 'messages.seasonsLoadFailed' => 'Impossible de charger les saisons', 'messages.noEpisodesFound' => 'Aucun épisode trouvé dans la première saison', 'messages.noEpisodesFoundGeneral' => 'Aucun épisode trouvé', + 'messages.episodesLoadFailed' => 'Impossible de charger les épisodes', 'messages.noResultsFound' => 'Aucun résultat trouvé', 'messages.sleepTimerSet' => ({required Object label}) => 'Minuterie de mise en veille réglée sur ${label}', 'messages.noItemsAvailable' => 'Aucun élément disponible', @@ -2331,10 +2335,10 @@ extension on TranslationsFr { 'mpvConfig.loadPreset' => 'Charger', 'mpvConfig.deletePreset' => 'Supprimer', 'mpvConfig.presetSaved' => 'Préréglage enregistré', - 'mpvConfig.presetLoaded' => 'Préréglage chargé', - 'mpvConfig.presetDeleted' => 'Préréglage supprimé', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Préréglage chargé', + 'mpvConfig.presetDeleted' => 'Préréglage supprimé', 'mpvConfig.confirmDeletePreset' => 'Êtes-vous sûr de vouloir supprimer ce préréglage ?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmer l\'action', @@ -2845,10 +2849,10 @@ extension on TranslationsFr { 'externalPlayer.playerPackage' => 'Nom du paquet', 'externalPlayer.playerUrlScheme' => 'Schéma URL', 'externalPlayer.off' => 'Désactivé', - 'externalPlayer.launchFailed' => 'Impossible d\'ouvrir le lecteur externe', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} n\'est pas installé', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Impossible d\'ouvrir le lecteur externe', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} n\'est pas installé', 'externalPlayer.playInExternalPlayer' => 'Lire dans un lecteur externe', 'metadataEdit.editMetadata' => 'Modifier...', 'metadataEdit.screenTitle' => 'Modifier les métadonnées', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index ab976f09..dc81b977 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesIt extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Errore aggiornamento metadati: ${error}'; @override String get logoutConfirm => 'Sei sicuro di volerti disconnettere?'; @override String get noSeasonsFound => 'Nessuna stagione trovata'; + @override String get seasonsLoadFailed => 'Impossibile caricare le stagioni'; @override String get noEpisodesFound => 'Nessun episodio trovato nella prima stagione'; @override String get noEpisodesFoundGeneral => 'Nessun episodio trovato'; + @override String get episodesLoadFailed => 'Impossibile caricare gli episodi'; @override String get noResultsFound => 'Nessun risultato'; @override String sleepTimerSet({required Object label}) => 'Imposta timer spegnimento per ${label}'; @override String get noItemsAvailable => 'Nessun elemento disponibile'; @@ -2295,8 +2297,10 @@ extension on TranslationsIt { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Errore aggiornamento metadati: ${error}', 'messages.logoutConfirm' => 'Sei sicuro di volerti disconnettere?', 'messages.noSeasonsFound' => 'Nessuna stagione trovata', + 'messages.seasonsLoadFailed' => 'Impossibile caricare le stagioni', 'messages.noEpisodesFound' => 'Nessun episodio trovato nella prima stagione', 'messages.noEpisodesFoundGeneral' => 'Nessun episodio trovato', + 'messages.episodesLoadFailed' => 'Impossibile caricare gli episodi', 'messages.noResultsFound' => 'Nessun risultato', 'messages.sleepTimerSet' => ({required Object label}) => 'Imposta timer spegnimento per ${label}', 'messages.noItemsAvailable' => 'Nessun elemento disponibile', @@ -2331,10 +2335,10 @@ extension on TranslationsIt { 'mpvConfig.loadPreset' => 'Carica', 'mpvConfig.deletePreset' => 'Elimina', 'mpvConfig.presetSaved' => 'Preset salvato', - 'mpvConfig.presetLoaded' => 'Preset caricato', - 'mpvConfig.presetDeleted' => 'Preset eliminato', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Preset caricato', + 'mpvConfig.presetDeleted' => 'Preset eliminato', 'mpvConfig.confirmDeletePreset' => 'Sei sicuro di voler eliminare questo preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Conferma azione', @@ -2845,10 +2849,10 @@ extension on TranslationsIt { 'externalPlayer.playerPackage' => 'Nome pacchetto', 'externalPlayer.playerUrlScheme' => 'Schema URL', 'externalPlayer.off' => 'Disattivato', - 'externalPlayer.launchFailed' => 'Impossibile aprire il lettore esterno', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} non è installato', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Impossibile aprire il lettore esterno', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} non è installato', 'externalPlayer.playInExternalPlayer' => 'Riproduci in lettore esterno', 'metadataEdit.editMetadata' => 'Modifica...', 'metadataEdit.screenTitle' => 'Modifica metadati', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index edecfefb..5f23f028 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesJa extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'メタデータの更新に失敗しました: ${error}'; @override String get logoutConfirm => 'ログアウトしてもよろしいですか?'; @override String get noSeasonsFound => 'シーズンが見つかりません'; + @override String get seasonsLoadFailed => 'シーズンを読み込めませんでした'; @override String get noEpisodesFound => '最初のシーズンにエピソードが見つかりません'; @override String get noEpisodesFoundGeneral => 'エピソードが見つかりません'; + @override String get episodesLoadFailed => 'エピソードを読み込めませんでした'; @override String get noResultsFound => '結果が見つかりません'; @override String sleepTimerSet({required Object label}) => 'スリープタイマーを${label}に設定しました'; @override String get noItemsAvailable => 'アイテムがありません'; @@ -2295,8 +2297,10 @@ extension on TranslationsJa { 'messages.metadataRefreshFailed' => ({required Object error}) => 'メタデータの更新に失敗しました: ${error}', 'messages.logoutConfirm' => 'ログアウトしてもよろしいですか?', 'messages.noSeasonsFound' => 'シーズンが見つかりません', + 'messages.seasonsLoadFailed' => 'シーズンを読み込めませんでした', 'messages.noEpisodesFound' => '最初のシーズンにエピソードが見つかりません', 'messages.noEpisodesFoundGeneral' => 'エピソードが見つかりません', + 'messages.episodesLoadFailed' => 'エピソードを読み込めませんでした', 'messages.noResultsFound' => '結果が見つかりません', 'messages.sleepTimerSet' => ({required Object label}) => 'スリープタイマーを${label}に設定しました', 'messages.noItemsAvailable' => 'アイテムがありません', @@ -2331,10 +2335,10 @@ extension on TranslationsJa { 'mpvConfig.loadPreset' => '読み込み', 'mpvConfig.deletePreset' => '削除', 'mpvConfig.presetSaved' => 'プリセットを保存しました', - 'mpvConfig.presetLoaded' => 'プリセットを読み込みました', - 'mpvConfig.presetDeleted' => 'プリセットを削除しました', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'プリセットを読み込みました', + 'mpvConfig.presetDeleted' => 'プリセットを削除しました', 'mpvConfig.confirmDeletePreset' => 'このプリセットを削除してもよろしいですか?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '操作の確認', @@ -2845,10 +2849,10 @@ extension on TranslationsJa { 'externalPlayer.playerPackage' => 'パッケージ名', 'externalPlayer.playerUrlScheme' => 'URLスキーム', 'externalPlayer.off' => 'オフ', - 'externalPlayer.launchFailed' => '外部プレーヤーの起動に失敗しました', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name}がインストールされていません', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => '外部プレーヤーの起動に失敗しました', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name}がインストールされていません', 'externalPlayer.playInExternalPlayer' => '外部プレーヤーで再生', 'metadataEdit.editMetadata' => '編集...', 'metadataEdit.screenTitle' => 'メタデータを編集', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 4652d08e..a73e926b 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesKo extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => '메타데이터 새로고침 실패: ${error}'; @override String get logoutConfirm => '로그아웃 하시겠습니까?'; @override String get noSeasonsFound => '시즌을 찾을 수 없음'; + @override String get seasonsLoadFailed => '시즌을 불러오지 못했습니다'; @override String get noEpisodesFound => '시즌 1에서 에피소드를 찾을 수 없습니다'; @override String get noEpisodesFoundGeneral => '에피소드를 찾을 수 없습니다'; + @override String get episodesLoadFailed => '에피소드를 불러오지 못했습니다'; @override String get noResultsFound => '결과를 찾을 수 없습니다'; @override String sleepTimerSet({required Object label}) => '수면 타이머가 ${label}로 설정 되었습니다'; @override String get noItemsAvailable => '사용 가능한 항목이 없습니다'; @@ -2295,8 +2297,10 @@ extension on TranslationsKo { 'messages.metadataRefreshFailed' => ({required Object error}) => '메타데이터 새로고침 실패: ${error}', 'messages.logoutConfirm' => '로그아웃 하시겠습니까?', 'messages.noSeasonsFound' => '시즌을 찾을 수 없음', + 'messages.seasonsLoadFailed' => '시즌을 불러오지 못했습니다', 'messages.noEpisodesFound' => '시즌 1에서 에피소드를 찾을 수 없습니다', 'messages.noEpisodesFoundGeneral' => '에피소드를 찾을 수 없습니다', + 'messages.episodesLoadFailed' => '에피소드를 불러오지 못했습니다', 'messages.noResultsFound' => '결과를 찾을 수 없습니다', 'messages.sleepTimerSet' => ({required Object label}) => '수면 타이머가 ${label}로 설정 되었습니다', 'messages.noItemsAvailable' => '사용 가능한 항목이 없습니다', @@ -2331,10 +2335,10 @@ extension on TranslationsKo { 'mpvConfig.loadPreset' => '로드', 'mpvConfig.deletePreset' => '삭제', 'mpvConfig.presetSaved' => '프리셋이 저장 되었습니다', - 'mpvConfig.presetLoaded' => '프리셋이 로드 되었습니다', - 'mpvConfig.presetDeleted' => '프리셋이 삭제 되었습니다', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => '프리셋이 로드 되었습니다', + 'mpvConfig.presetDeleted' => '프리셋이 삭제 되었습니다', 'mpvConfig.confirmDeletePreset' => '이 프리셋을 삭제 하시겠습니까?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '확인', @@ -2845,10 +2849,10 @@ extension on TranslationsKo { 'externalPlayer.playerPackage' => '패키지 이름', 'externalPlayer.playerUrlScheme' => 'URL 스킴', 'externalPlayer.off' => '꺼짐', - 'externalPlayer.launchFailed' => '외부 플레이어를 열 수 없습니다', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name}이(가) 설치되어 있지 않습니다', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => '외부 플레이어를 열 수 없습니다', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name}이(가) 설치되어 있지 않습니다', 'externalPlayer.playInExternalPlayer' => '외부 플레이어에서 재생', 'metadataEdit.editMetadata' => '편집...', 'metadataEdit.screenTitle' => '메타데이터 편집', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 832b25c8..6e816c47 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesNb extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Kunne ikke oppdatere metadata: ${error}'; @override String get logoutConfirm => 'Er du sikker på at du vil logge ut?'; @override String get noSeasonsFound => 'Ingen sesonger funnet'; + @override String get seasonsLoadFailed => 'Kunne ikke laste sesonger'; @override String get noEpisodesFound => 'Ingen episoder funnet i første sesong'; @override String get noEpisodesFoundGeneral => 'Ingen episoder funnet'; + @override String get episodesLoadFailed => 'Kunne ikke laste episoder'; @override String get noResultsFound => 'Ingen resultater funnet'; @override String sleepTimerSet({required Object label}) => 'Søvntimer satt til ${label}'; @override String get noItemsAvailable => 'Ingen elementer tilgjengelig'; @@ -2295,8 +2297,10 @@ extension on TranslationsNb { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Kunne ikke oppdatere metadata: ${error}', 'messages.logoutConfirm' => 'Er du sikker på at du vil logge ut?', 'messages.noSeasonsFound' => 'Ingen sesonger funnet', + 'messages.seasonsLoadFailed' => 'Kunne ikke laste sesonger', 'messages.noEpisodesFound' => 'Ingen episoder funnet i første sesong', 'messages.noEpisodesFoundGeneral' => 'Ingen episoder funnet', + 'messages.episodesLoadFailed' => 'Kunne ikke laste episoder', 'messages.noResultsFound' => 'Ingen resultater funnet', 'messages.sleepTimerSet' => ({required Object label}) => 'Søvntimer satt til ${label}', 'messages.noItemsAvailable' => 'Ingen elementer tilgjengelig', @@ -2331,10 +2335,10 @@ extension on TranslationsNb { 'mpvConfig.loadPreset' => 'Last inn', 'mpvConfig.deletePreset' => 'Slett', 'mpvConfig.presetSaved' => 'Forhåndsinnstilling lagret', - 'mpvConfig.presetLoaded' => 'Forhåndsinnstilling lastet inn', - 'mpvConfig.presetDeleted' => 'Forhåndsinnstilling slettet', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Forhåndsinnstilling lastet inn', + 'mpvConfig.presetDeleted' => 'Forhåndsinnstilling slettet', 'mpvConfig.confirmDeletePreset' => 'Er du sikker på at du vil slette denne forhåndsinnstillingen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# kommentar', 'dialog.confirmAction' => 'Bekreft handling', @@ -2845,10 +2849,10 @@ extension on TranslationsNb { 'externalPlayer.playerPackage' => 'Pakkenavn', 'externalPlayer.playerUrlScheme' => 'URL-skjema', 'externalPlayer.off' => 'Av', - 'externalPlayer.launchFailed' => 'Kunne ikke åpne ekstern spiller', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} er ikke installert', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Kunne ikke åpne ekstern spiller', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} er ikke installert', 'externalPlayer.playInExternalPlayer' => 'Spill av i ekstern spiller', 'metadataEdit.editMetadata' => 'Rediger...', 'metadataEdit.screenTitle' => 'Rediger metadata', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 5c282dcd..1b9ef0da 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesNl extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Kon metadata niet vernieuwen: ${error}'; @override String get logoutConfirm => 'Weet je zeker dat je wilt uitloggen?'; @override String get noSeasonsFound => 'Geen seizoenen gevonden'; + @override String get seasonsLoadFailed => 'Kan seizoenen niet laden'; @override String get noEpisodesFound => 'Geen afleveringen gevonden in eerste seizoen'; @override String get noEpisodesFoundGeneral => 'Geen afleveringen gevonden'; + @override String get episodesLoadFailed => 'Kan afleveringen niet laden'; @override String get noResultsFound => 'Geen resultaten gevonden'; @override String sleepTimerSet({required Object label}) => 'Slaap timer ingesteld voor ${label}'; @override String get noItemsAvailable => 'Geen items beschikbaar'; @@ -2295,8 +2297,10 @@ extension on TranslationsNl { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Kon metadata niet vernieuwen: ${error}', 'messages.logoutConfirm' => 'Weet je zeker dat je wilt uitloggen?', 'messages.noSeasonsFound' => 'Geen seizoenen gevonden', + 'messages.seasonsLoadFailed' => 'Kan seizoenen niet laden', 'messages.noEpisodesFound' => 'Geen afleveringen gevonden in eerste seizoen', 'messages.noEpisodesFoundGeneral' => 'Geen afleveringen gevonden', + 'messages.episodesLoadFailed' => 'Kan afleveringen niet laden', 'messages.noResultsFound' => 'Geen resultaten gevonden', 'messages.sleepTimerSet' => ({required Object label}) => 'Slaap timer ingesteld voor ${label}', 'messages.noItemsAvailable' => 'Geen items beschikbaar', @@ -2331,10 +2335,10 @@ extension on TranslationsNl { 'mpvConfig.loadPreset' => 'Laden', 'mpvConfig.deletePreset' => 'Verwijderen', 'mpvConfig.presetSaved' => 'Voorinstelling opgeslagen', - 'mpvConfig.presetLoaded' => 'Voorinstelling geladen', - 'mpvConfig.presetDeleted' => 'Voorinstelling verwijderd', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Voorinstelling geladen', + 'mpvConfig.presetDeleted' => 'Voorinstelling verwijderd', 'mpvConfig.confirmDeletePreset' => 'Weet je zeker dat je deze voorinstelling wilt verwijderen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bevestig actie', @@ -2845,10 +2849,10 @@ extension on TranslationsNl { 'externalPlayer.playerPackage' => 'Pakketnaam', 'externalPlayer.playerUrlScheme' => 'URL-schema', 'externalPlayer.off' => 'Uit', - 'externalPlayer.launchFailed' => 'Kan externe speler niet openen', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is niet geïnstalleerd', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Kan externe speler niet openen', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is niet geïnstalleerd', 'externalPlayer.playInExternalPlayer' => 'Afspelen in externe speler', 'metadataEdit.editMetadata' => 'Bewerken...', 'metadataEdit.screenTitle' => 'Metadata bewerken', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 9009a0a4..d0c1b6ed 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesPl extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Nie udało się odświeżyć metadanych: ${error}'; @override String get logoutConfirm => 'Czy na pewno chcesz się wylogować?'; @override String get noSeasonsFound => 'Nie znaleziono sezonów'; + @override String get seasonsLoadFailed => 'Nie udało się załadować sezonów'; @override String get noEpisodesFound => 'Nie znaleziono odcinków w pierwszym sezonie'; @override String get noEpisodesFoundGeneral => 'Nie znaleziono odcinków'; + @override String get episodesLoadFailed => 'Nie udało się załadować odcinków'; @override String get noResultsFound => 'Nie znaleziono wyników'; @override String sleepTimerSet({required Object label}) => 'Wyłącznik czasowy ustawiony na ${label}'; @override String get noItemsAvailable => 'Brak dostępnych elementów'; @@ -2295,8 +2297,10 @@ extension on TranslationsPl { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Nie udało się odświeżyć metadanych: ${error}', 'messages.logoutConfirm' => 'Czy na pewno chcesz się wylogować?', 'messages.noSeasonsFound' => 'Nie znaleziono sezonów', + 'messages.seasonsLoadFailed' => 'Nie udało się załadować sezonów', 'messages.noEpisodesFound' => 'Nie znaleziono odcinków w pierwszym sezonie', 'messages.noEpisodesFoundGeneral' => 'Nie znaleziono odcinków', + 'messages.episodesLoadFailed' => 'Nie udało się załadować odcinków', 'messages.noResultsFound' => 'Nie znaleziono wyników', 'messages.sleepTimerSet' => ({required Object label}) => 'Wyłącznik czasowy ustawiony na ${label}', 'messages.noItemsAvailable' => 'Brak dostępnych elementów', @@ -2331,10 +2335,10 @@ extension on TranslationsPl { 'mpvConfig.loadPreset' => 'Załaduj', 'mpvConfig.deletePreset' => 'Usuń', 'mpvConfig.presetSaved' => 'Preset zapisany', - 'mpvConfig.presetLoaded' => 'Preset załadowany', - 'mpvConfig.presetDeleted' => 'Preset usunięty', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Preset załadowany', + 'mpvConfig.presetDeleted' => 'Preset usunięty', 'mpvConfig.confirmDeletePreset' => 'Czy na pewno chcesz usunąć ten preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Potwierdź działanie', @@ -2845,10 +2849,10 @@ extension on TranslationsPl { 'externalPlayer.playerPackage' => 'Nazwa pakietu', 'externalPlayer.playerUrlScheme' => 'Schemat URL', 'externalPlayer.off' => 'Wyłączony', - 'externalPlayer.launchFailed' => 'Nie udało się otworzyć zewnętrznego odtwarzacza', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} nie jest zainstalowany', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Nie udało się otworzyć zewnętrznego odtwarzacza', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} nie jest zainstalowany', 'externalPlayer.playInExternalPlayer' => 'Odtwórz w zewnętrznym odtwarzaczu', 'metadataEdit.editMetadata' => 'Edytuj...', 'metadataEdit.screenTitle' => 'Edytuj metadane', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 68b57670..4fd7e4af 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesPt extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Falha ao atualizar metadados: ${error}'; @override String get logoutConfirm => 'Tem certeza que deseja sair?'; @override String get noSeasonsFound => 'Nenhuma temporada encontrada'; + @override String get seasonsLoadFailed => 'Não foi possível carregar as temporadas'; @override String get noEpisodesFound => 'Nenhum episódio encontrado na primeira temporada'; @override String get noEpisodesFoundGeneral => 'Nenhum episódio encontrado'; + @override String get episodesLoadFailed => 'Não foi possível carregar os episódios'; @override String get noResultsFound => 'Nenhum resultado encontrado'; @override String sleepTimerSet({required Object label}) => 'Timer de sono definido para ${label}'; @override String get noItemsAvailable => 'Nenhum item disponível'; @@ -2295,8 +2297,10 @@ extension on TranslationsPt { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Falha ao atualizar metadados: ${error}', 'messages.logoutConfirm' => 'Tem certeza que deseja sair?', 'messages.noSeasonsFound' => 'Nenhuma temporada encontrada', + 'messages.seasonsLoadFailed' => 'Não foi possível carregar as temporadas', 'messages.noEpisodesFound' => 'Nenhum episódio encontrado na primeira temporada', 'messages.noEpisodesFoundGeneral' => 'Nenhum episódio encontrado', + 'messages.episodesLoadFailed' => 'Não foi possível carregar os episódios', 'messages.noResultsFound' => 'Nenhum resultado encontrado', 'messages.sleepTimerSet' => ({required Object label}) => 'Timer de sono definido para ${label}', 'messages.noItemsAvailable' => 'Nenhum item disponível', @@ -2331,10 +2335,10 @@ extension on TranslationsPt { 'mpvConfig.loadPreset' => 'Carregar', 'mpvConfig.deletePreset' => 'Excluir', 'mpvConfig.presetSaved' => 'Predefinição salva', - 'mpvConfig.presetLoaded' => 'Predefinição carregada', - 'mpvConfig.presetDeleted' => 'Predefinição excluída', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Predefinição carregada', + 'mpvConfig.presetDeleted' => 'Predefinição excluída', 'mpvConfig.confirmDeletePreset' => 'Tem certeza que deseja excluir esta predefinição?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmar Ação', @@ -2845,10 +2849,10 @@ extension on TranslationsPt { 'externalPlayer.playerPackage' => 'Nome do Pacote', 'externalPlayer.playerUrlScheme' => 'Esquema de URL', 'externalPlayer.off' => 'Desativado', - 'externalPlayer.launchFailed' => 'Falha ao abrir player externo', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} não está instalado', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Falha ao abrir player externo', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} não está instalado', 'externalPlayer.playInExternalPlayer' => 'Reproduzir no Player Externo', 'metadataEdit.editMetadata' => 'Editar...', 'metadataEdit.screenTitle' => 'Editar Metadados', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index cb1bcf79..d4b4f32e 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesRu extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Не удалось обновить метаданные: ${error}'; @override String get logoutConfirm => 'Вы уверены, что хотите выйти?'; @override String get noSeasonsFound => 'Сезоны не найдены'; + @override String get seasonsLoadFailed => 'Не удалось загрузить сезоны'; @override String get noEpisodesFound => 'Эпизоды в первом сезоне не найдены'; @override String get noEpisodesFoundGeneral => 'Эпизоды не найдены'; + @override String get episodesLoadFailed => 'Не удалось загрузить эпизоды'; @override String get noResultsFound => 'Результаты не найдены'; @override String sleepTimerSet({required Object label}) => 'Таймер сна установлен на ${label}'; @override String get noItemsAvailable => 'Нет доступных элементов'; @@ -2295,8 +2297,10 @@ extension on TranslationsRu { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Не удалось обновить метаданные: ${error}', 'messages.logoutConfirm' => 'Вы уверены, что хотите выйти?', 'messages.noSeasonsFound' => 'Сезоны не найдены', + 'messages.seasonsLoadFailed' => 'Не удалось загрузить сезоны', 'messages.noEpisodesFound' => 'Эпизоды в первом сезоне не найдены', 'messages.noEpisodesFoundGeneral' => 'Эпизоды не найдены', + 'messages.episodesLoadFailed' => 'Не удалось загрузить эпизоды', 'messages.noResultsFound' => 'Результаты не найдены', 'messages.sleepTimerSet' => ({required Object label}) => 'Таймер сна установлен на ${label}', 'messages.noItemsAvailable' => 'Нет доступных элементов', @@ -2331,10 +2335,10 @@ extension on TranslationsRu { 'mpvConfig.loadPreset' => 'Загрузить', 'mpvConfig.deletePreset' => 'Удалить', 'mpvConfig.presetSaved' => 'Пресет сохранён', - 'mpvConfig.presetLoaded' => 'Пресет загружен', - 'mpvConfig.presetDeleted' => 'Пресет удалён', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Пресет загружен', + 'mpvConfig.presetDeleted' => 'Пресет удалён', 'mpvConfig.confirmDeletePreset' => 'Вы уверены, что хотите удалить этот пресет?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Подтвердить действие', @@ -2845,10 +2849,10 @@ extension on TranslationsRu { 'externalPlayer.playerPackage' => 'Имя пакета', 'externalPlayer.playerUrlScheme' => 'URL-схема', 'externalPlayer.off' => 'Выкл.', - 'externalPlayer.launchFailed' => 'Не удалось открыть внешний плеер', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} не установлен', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Не удалось открыть внешний плеер', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} не установлен', 'externalPlayer.playInExternalPlayer' => 'Воспроизвести во внешнем плеере', 'metadataEdit.editMetadata' => 'Редактировать...', 'metadataEdit.screenTitle' => 'Редактировать метаданные', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index e949150b..35401ae4 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesSv extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => 'Misslyckades att uppdatera metadata: ${error}'; @override String get logoutConfirm => 'Är du säker på att du vill logga ut?'; @override String get noSeasonsFound => 'Inga säsonger hittades'; + @override String get seasonsLoadFailed => 'Det gick inte att läsa in säsonger'; @override String get noEpisodesFound => 'Inga avsnitt hittades i första säsongen'; @override String get noEpisodesFoundGeneral => 'Inga avsnitt hittades'; + @override String get episodesLoadFailed => 'Det gick inte att läsa in avsnitt'; @override String get noResultsFound => 'Inga resultat hittades'; @override String sleepTimerSet({required Object label}) => 'Sovtimer inställd för ${label}'; @override String get noItemsAvailable => 'Inga objekt tillgängliga'; @@ -2295,8 +2297,10 @@ extension on TranslationsSv { 'messages.metadataRefreshFailed' => ({required Object error}) => 'Misslyckades att uppdatera metadata: ${error}', 'messages.logoutConfirm' => 'Är du säker på att du vill logga ut?', 'messages.noSeasonsFound' => 'Inga säsonger hittades', + 'messages.seasonsLoadFailed' => 'Det gick inte att läsa in säsonger', 'messages.noEpisodesFound' => 'Inga avsnitt hittades i första säsongen', 'messages.noEpisodesFoundGeneral' => 'Inga avsnitt hittades', + 'messages.episodesLoadFailed' => 'Det gick inte att läsa in avsnitt', 'messages.noResultsFound' => 'Inga resultat hittades', 'messages.sleepTimerSet' => ({required Object label}) => 'Sovtimer inställd för ${label}', 'messages.noItemsAvailable' => 'Inga objekt tillgängliga', @@ -2331,10 +2335,10 @@ extension on TranslationsSv { 'mpvConfig.loadPreset' => 'Ladda', 'mpvConfig.deletePreset' => 'Ta bort', 'mpvConfig.presetSaved' => 'Förval sparat', - 'mpvConfig.presetLoaded' => 'Förval laddat', - 'mpvConfig.presetDeleted' => 'Förval borttaget', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => 'Förval laddat', + 'mpvConfig.presetDeleted' => 'Förval borttaget', 'mpvConfig.confirmDeletePreset' => 'Är du säker på att du vill ta bort detta förval?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bekräfta åtgärd', @@ -2845,10 +2849,10 @@ extension on TranslationsSv { 'externalPlayer.playerPackage' => 'Paketnamn', 'externalPlayer.playerUrlScheme' => 'URL-schema', 'externalPlayer.off' => 'Av', - 'externalPlayer.launchFailed' => 'Kunde inte öppna extern spelare', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} är inte installerad', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => 'Kunde inte öppna extern spelare', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} är inte installerad', 'externalPlayer.playInExternalPlayer' => 'Spela i extern spelare', 'metadataEdit.editMetadata' => 'Redigera...', 'metadataEdit.screenTitle' => 'Redigera metadata', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 59af2f6c..ce33d905 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -672,8 +672,10 @@ class _TranslationsMessagesZh extends TranslationsMessagesEn { @override String metadataRefreshFailed({required Object error}) => '无法刷新元数据: ${error}'; @override String get logoutConfirm => '你确定要登出吗?'; @override String get noSeasonsFound => '未找到季'; + @override String get seasonsLoadFailed => '无法加载季'; @override String get noEpisodesFound => '在第一季中未找到剧集'; @override String get noEpisodesFoundGeneral => '未找到剧集'; + @override String get episodesLoadFailed => '无法加载剧集'; @override String get noResultsFound => '未找到结果'; @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; @override String get noItemsAvailable => '没有可用的项目'; @@ -2295,8 +2297,10 @@ extension on TranslationsZh { 'messages.metadataRefreshFailed' => ({required Object error}) => '无法刷新元数据: ${error}', 'messages.logoutConfirm' => '你确定要登出吗?', 'messages.noSeasonsFound' => '未找到季', + 'messages.seasonsLoadFailed' => '无法加载季', 'messages.noEpisodesFound' => '在第一季中未找到剧集', 'messages.noEpisodesFoundGeneral' => '未找到剧集', + 'messages.episodesLoadFailed' => '无法加载剧集', 'messages.noResultsFound' => '未找到结果', 'messages.sleepTimerSet' => ({required Object label}) => '睡眠定时器已设置为 ${label}', 'messages.noItemsAvailable' => '没有可用的项目', @@ -2331,10 +2335,10 @@ extension on TranslationsZh { 'mpvConfig.loadPreset' => '加载', 'mpvConfig.deletePreset' => '删除', 'mpvConfig.presetSaved' => '预设已保存', - 'mpvConfig.presetLoaded' => '预设已加载', - 'mpvConfig.presetDeleted' => '预设已删除', _ => null, } ?? switch (path) { + 'mpvConfig.presetLoaded' => '预设已加载', + 'mpvConfig.presetDeleted' => '预设已删除', 'mpvConfig.confirmDeletePreset' => '确定要删除此预设吗?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '确认操作', @@ -2845,10 +2849,10 @@ extension on TranslationsZh { 'externalPlayer.playerPackage' => '包名', 'externalPlayer.playerUrlScheme' => 'URL 方案', 'externalPlayer.off' => '关闭', - 'externalPlayer.launchFailed' => '无法打开外部播放器', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} 未安装', _ => null, } ?? switch (path) { + 'externalPlayer.launchFailed' => '无法打开外部播放器', + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} 未安装', 'externalPlayer.playInExternalPlayer' => '在外部播放器中播放', 'metadataEdit.editMetadata' => '编辑...', 'metadataEdit.screenTitle' => '编辑元数据', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 94946f3c..1407ac2b 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "Misslyckades att uppdatera metadata: ${error}", "logoutConfirm": "Är du säker på att du vill logga ut?", "noSeasonsFound": "Inga säsonger hittades", + "seasonsLoadFailed": "Det gick inte att läsa in säsonger", "noEpisodesFound": "Inga avsnitt hittades i första säsongen", "noEpisodesFoundGeneral": "Inga avsnitt hittades", + "episodesLoadFailed": "Det gick inte att läsa in avsnitt", "noResultsFound": "Inga resultat hittades", "sleepTimerSet": "Sovtimer inställd för ${label}", "noItemsAvailable": "Inga objekt tillgängliga", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index fdd5690d..397b523f 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -508,8 +508,10 @@ "metadataRefreshFailed": "无法刷新元数据: ${error}", "logoutConfirm": "你确定要登出吗?", "noSeasonsFound": "未找到季", + "seasonsLoadFailed": "无法加载季", "noEpisodesFound": "在第一季中未找到剧集", "noEpisodesFoundGeneral": "未找到剧集", + "episodesLoadFailed": "无法加载剧集", "noResultsFound": "未找到结果", "sleepTimerSet": "睡眠定时器已设置为 ${label}", "noItemsAvailable": "没有可用的项目", diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart new file mode 100644 index 00000000..f5aace88 --- /dev/null +++ b/lib/media/episode_collection.dart @@ -0,0 +1,172 @@ +import 'library_query.dart'; +import 'media_item.dart'; +import 'media_kind.dart'; +import 'media_server_client.dart'; + +/// Collect every episode of a show into [out] using the backend's one-shot +/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] — +/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's +/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking +/// show → seasons → episodes client-side, so large series come back in one +/// trip and aren't capped by any per-page Limit. +/// +/// A failure of the underlying call propagates to the caller — both +/// `DownloadProvider.queueDownload` and the sync rule executor wrap their +/// invocations so the user-facing error surfaces / the rule run is rolled +/// back. +Future collectEpisodesForShow( + MediaServerClient client, + String showRatingKey, { + required bool unwatchedOnly, + required List out, + MediaItem? fallback, +}) { + return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); +} + +/// Collect every episode of a single season into [out] via the same +/// one-shot endpoint. On a season the leaves *are* the episodes, so the +/// shape matches the show case. +Future collectEpisodesForSeason( + MediaServerClient client, + String seasonRatingKey, { + required bool unwatchedOnly, + required List out, + MediaItem? fallback, +}) { + return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); +} + +/// Fetch just the first episode of a season without walking the entire season. +/// Use this for representative lookups and immediate "play first" actions. +Future fetchFirstEpisodeForSeason(MediaServerClient client, String seasonRatingKey) async { + final page = await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1); + for (final item in page.items) { + if (item.kind == MediaKind.episode) return item; + } + return null; +} + +/// Prefer the first regular season over specials, falling back to the first +/// season row when a show only has specials or lacks season indexes. +int defaultPlaybackSeasonIndex(List seasons) { + if (seasons.isEmpty) return 0; + final regularSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season && (season.index ?? 0) > 0); + if (regularSeasonIndex != -1) return regularSeasonIndex; + final firstSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season); + return firstSeasonIndex == -1 ? 0 : firstSeasonIndex; +} + +MediaItem? defaultPlaybackSeason(List seasons) { + if (seasons.isEmpty) return null; + final index = defaultPlaybackSeasonIndex(seasons); + if (index < 0 || index >= seasons.length) return null; + final season = seasons[index]; + return season.kind == MediaKind.season ? season : null; +} + +/// Find the season index matching an explicit navigation target or on-deck +/// episode, then fall back to [defaultPlaybackSeasonIndex]. +int preferredSeasonIndex(List seasons, {int? initialSeasonIndex, MediaItem? onDeckEpisode}) { + if (seasons.isEmpty) return 0; + if (initialSeasonIndex != null) { + final idx = seasons.indexWhere((season) => season.kind == MediaKind.season && season.index == initialSeasonIndex); + if (idx != -1) return idx; + } + + if (onDeckEpisode != null) { + final parentId = onDeckEpisode.parentId; + if (parentId != null) { + final idx = seasons.indexWhere((season) => season.kind == MediaKind.season && season.id == parentId); + if (idx != -1) return idx; + } + + final parentIndex = onDeckEpisode.parentIndex; + if (parentIndex != null) { + final idx = seasons.indexWhere((season) => season.kind == MediaKind.season && season.index == parentIndex); + if (idx != -1) return idx; + } + } + + return defaultPlaybackSeasonIndex(seasons); +} + +/// Fetch a page of direct season children and normalize the episode identity +/// fields detail rows depend on. Local/session progress stays layered in UI. +Future> fetchSeasonEpisodePage( + MediaServerClient client, { + required MediaItem show, + required MediaItem season, + required int start, + required int size, +}) async { + final page = await client.fetchChildrenPage(season.id, start: start, size: size); + return LibraryPage( + items: normalizeSeasonEpisodes(page.items, show: show, season: season), + totalCount: page.totalCount, + offset: page.offset, + ); +} + +List normalizeSeasonEpisodes( + List episodes, { + required MediaItem show, + required MediaItem season, +}) { + return episodes + .map( + (episode) => _withFallbackLibrary( + episode.copyWith( + serverId: show.serverId ?? episode.serverId, + serverName: show.serverName ?? episode.serverName, + grandparentId: show.id, + grandparentTitle: show.title ?? episode.grandparentTitle, + parentId: episode.parentId ?? season.id, + parentIndex: episode.parentIndex ?? season.index, + ), + season.libraryId != null ? season : show, + ), + ) + .toList(); +} + +Future _collectPlayable( + MediaServerClient client, + String parentId, { + required bool unwatchedOnly, + required List out, + MediaItem? fallback, +}) async { + final leaves = await client.fetchPlayableDescendants(parentId); + for (final ep in leaves) { + if (ep.kind != MediaKind.episode) continue; + if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; + out.add(_withFallbackLibrary(ep, fallback)); + } +} + +MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) { + if (fallback == null) return item; + final fallbackIsSeason = fallback.kind == MediaKind.season; + final fallbackIsShow = fallback.kind == MediaKind.show; + return item.copyWith( + serverId: item.serverId ?? fallback.serverId, + serverName: item.serverName ?? fallback.serverName, + libraryId: item.libraryId ?? fallback.libraryId, + libraryTitle: item.libraryTitle ?? fallback.libraryTitle, + parentId: item.parentId ?? (fallbackIsSeason ? fallback.id : null), + parentTitle: item.parentTitle ?? (fallbackIsSeason ? fallback.title : null), + grandparentId: item.grandparentId ?? _fallbackGrandparentId(fallback, isShow: fallbackIsShow), + grandparentTitle: item.grandparentTitle ?? _fallbackGrandparentTitle(fallback, isShow: fallbackIsShow), + ); +} + +String? _fallbackGrandparentId(MediaItem fallback, {required bool isShow}) { + if (isShow) return fallback.id; + return fallback.grandparentId ?? fallback.parentId; +} + +String? _fallbackGrandparentTitle(MediaItem fallback, {required bool isShow}) { + if (isShow) return fallback.title; + return fallback.grandparentTitle ?? fallback.parentTitle; +} diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 2ed22953..f45c1dad 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -169,9 +169,15 @@ abstract class MediaServerClient { /// show, tracks of an album, items of a collection. Future> fetchChildren(String parentId); - /// Page through playable descendants of [parentId]. Used by large show / - /// season detail views so episodes can render before the full list has - /// loaded. [fetchPlayableDescendants] remains the complete-list helper for + /// Page through direct children of [parentId]. Unlike + /// [fetchPlayableDescendantsPage], this preserves container shape and is used + /// for large season episode lists where the children endpoint is the correct + /// backend primitive. + Future> fetchChildrenPage(String parentId, {int? start, int? size, AbortController? abort}); + + /// Page through playable descendants of [parentId]. Used by flattened show / + /// season detail views so episodes can render before the full list has loaded. + /// [fetchPlayableDescendants] remains the complete-list helper for /// playback/download/sync paths. Future> fetchPlayableDescendantsPage( String parentId, { diff --git a/lib/media/paged_media_list_state.dart b/lib/media/paged_media_list_state.dart new file mode 100644 index 00000000..8500583c --- /dev/null +++ b/lib/media/paged_media_list_state.dart @@ -0,0 +1,115 @@ +/// Immutable state for a list loaded in pages. +class PagedMediaListState { + const PagedMediaListState({ + this.items = const [], + this.totalCount = 0, + this.isInitialLoading = false, + this.isLoadingMore = false, + this.initialLoadFailed = false, + this.pageLoadFailed = false, + }); + + final List items; + final int totalCount; + final bool isInitialLoading; + final bool isLoadingMore; + final bool initialLoadFailed; + final bool pageLoadFailed; + + bool get hasMore => items.length < totalCount; + bool get hasItems => items.isNotEmpty; + + PagedMediaListState copyWith({ + List? items, + int? totalCount, + bool? isInitialLoading, + bool? isLoadingMore, + bool? initialLoadFailed, + bool? pageLoadFailed, + }) { + return PagedMediaListState( + items: items ?? this.items, + totalCount: totalCount ?? this.totalCount, + isInitialLoading: isInitialLoading ?? this.isInitialLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + initialLoadFailed: initialLoadFailed ?? this.initialLoadFailed, + pageLoadFailed: pageLoadFailed ?? this.pageLoadFailed, + ); + } + + PagedMediaListState startInitialLoad() { + return copyWith(isInitialLoading: true, isLoadingMore: false, initialLoadFailed: false, pageLoadFailed: false); + } + + PagedMediaListState completeInitialLoad(List pageItems, int total) { + return copyWith( + items: List.of(pageItems), + totalCount: total, + isInitialLoading: false, + isLoadingMore: false, + initialLoadFailed: false, + pageLoadFailed: false, + ); + } + + PagedMediaListState failInitialLoad() { + return copyWith(isInitialLoading: false, isLoadingMore: false, initialLoadFailed: true); + } + + PagedMediaListState startLoadMore() { + return copyWith(isLoadingMore: true, pageLoadFailed: false); + } + + PagedMediaListState completeLoadMore({ + required int expectedOffset, + required List pageItems, + required int total, + }) { + if (items.length != expectedOffset) { + return copyWith(isLoadingMore: false); + } + final nextTotal = pageItems.isEmpty ? expectedOffset : total; + return copyWith( + items: [...items, ...pageItems], + totalCount: nextTotal, + isLoadingMore: false, + pageLoadFailed: false, + ); + } + + PagedMediaListState failLoadMore() { + return copyWith(isLoadingMore: false, pageLoadFailed: true); + } + + PagedMediaListState replaceItems(List nextItems) { + final nextTotal = totalCount < nextItems.length ? nextItems.length : totalCount; + return copyWith(items: List.of(nextItems), totalCount: nextTotal); + } + + PagedMediaListState removeWhere(bool Function(T item) test) { + var removed = 0; + final nextItems = []; + for (final item in items) { + if (test(item)) { + removed++; + } else { + nextItems.add(item); + } + } + if (removed == 0) return this; + final decrementedTotal = totalCount - removed; + final nextTotal = decrementedTotal < nextItems.length ? nextItems.length : decrementedTotal; + return copyWith(items: nextItems, totalCount: nextTotal); + } + + PagedMediaListState mapItems(R Function(T item) map) { + return PagedMediaListState( + items: items.map(map).toList(), + totalCount: totalCount, + isInitialLoading: isInitialLoading, + isLoadingMore: isLoadingMore, + initialLoadFailed: initialLoadFailed, + pageLoadFailed: pageLoadFailed, + ); + } +} diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index ad21c3fb..3a05a43e 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -22,7 +22,7 @@ import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; import '../utils/deletion_notifier.dart'; -import '../utils/episode_collection.dart'; +import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 6a071dec..49832c6c 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -26,9 +26,11 @@ import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; import '../utils/plex_season_display.dart'; import '../media/media_item.dart'; +import '../media/episode_collection.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; import '../media/media_role.dart'; +import '../media/paged_media_list_state.dart'; import '../widgets/media_card.dart'; import '../i18n/strings.g.dart'; import '../widgets/optimized_media_image.dart'; @@ -61,6 +63,7 @@ import '../widgets/app_bar_back_button.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_context_menu.dart'; +import 'libraries/state_messages.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/watch_state_aware.dart'; @@ -86,6 +89,7 @@ const double _tvDetailTallPosterScale = TvBrowseRailLayout.compactTallPosterScal const double _tvDetailEpisodeThumbnailScale = TvBrowseRailLayout.compactEpisodeThumbnailScale; const double _tvDetailActionSize = 46; const double _tvDetailActionRailGap = 4; +const String _tvDetailSeasonsErrorHubId = 'detail_seasons_error'; const String _tvDetailSeasonHubIdPrefix = 'detail_season_'; const String _tvDetailExtrasHubId = 'detail_extras'; const String _tvDetailActorsHubId = 'detail_actors'; @@ -93,6 +97,107 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; enum _SyncRuleAction { edit, remove, delete } +class _SeasonEpisodePager { + final Map> _states = {}; + final Set _firstPageLoadsInFlight = {}; + final Set _moreLoadsInFlight = {}; + + PagedMediaListState stateFor(String seasonId) { + return _states[seasonId] ?? const PagedMediaListState(); + } + + bool hasState(String seasonId) => _states.containsKey(seasonId); + + bool beginFirstPageLoad(String seasonId) => _firstPageLoadsInFlight.add(seasonId); + void endFirstPageLoad(String seasonId) => _firstPageLoadsInFlight.remove(seasonId); + + bool beginMoreLoad(String seasonId) => _moreLoadsInFlight.add(seasonId); + void endMoreLoad(String seasonId) => _moreLoadsInFlight.remove(seasonId); + + void markFirstPageLoading(String seasonId) { + _states[seasonId] = stateFor(seasonId).startInitialLoad(); + } + + void completeFirstPage(String seasonId, List episodes, int total) { + _states[seasonId] = stateFor(seasonId).completeInitialLoad(episodes, total); + } + + void failFirstPage(String seasonId) { + _states[seasonId] = stateFor(seasonId).failInitialLoad(); + } + + void markMoreLoading(String seasonId) { + _states[seasonId] = stateFor(seasonId).startLoadMore(); + } + + void completeMoreLoad( + String seasonId, { + required int expectedOffset, + required List episodes, + required int total, + }) { + _states[seasonId] = stateFor( + seasonId, + ).completeLoadMore(expectedOffset: expectedOffset, pageItems: episodes, total: total); + } + + void failMoreLoad(String seasonId) { + _states[seasonId] = stateFor(seasonId).failLoadMore(); + } + + void resetSeason(String seasonId) { + _states.remove(seasonId); + _firstPageLoadsInFlight.remove(seasonId); + _moreLoadsInFlight.remove(seasonId); + } + + void removeEpisode(String episodeId) { + for (final entry in _states.entries.toList()) { + _states[entry.key] = entry.value.removeWhere((episode) => episode.id == episodeId); + } + } + + void updateEpisode(String seasonId, int index, MediaItem updated) { + final state = _states[seasonId]; + if (state == null || index < 0 || index >= state.items.length) return; + final next = List.of(state.items); + next[index] = updated; + _states[seasonId] = state.replaceItems(next); + } + + void patchEpisode(String episodeId, MediaItem Function(MediaItem existing) patch) { + for (final entry in _states.entries.toList()) { + var changed = false; + final next = []; + for (final episode in entry.value.items) { + if (episode.id == episodeId) { + changed = true; + next.add(patch(episode)); + } else { + next.add(episode); + } + } + if (changed) _states[entry.key] = entry.value.replaceItems(next); + } + } + + void patchWhere(bool Function(String seasonId, MediaItem episode) test, MediaItem Function(MediaItem episode) patch) { + for (final entry in _states.entries.toList()) { + var changed = false; + final next = []; + for (final episode in entry.value.items) { + if (test(entry.key, episode)) { + changed = true; + next.add(patch(episode)); + } else { + next.add(episode); + } + } + if (changed) _states[entry.key] = entry.value.replaceItems(next); + } + } +} + class MediaDetailScreen extends StatefulWidget { final MediaItem metadata; final bool isOffline; @@ -132,12 +237,11 @@ class _MediaDetailScreenState extends State MediaItem get _metadata => _fullMetadata ?? widget.metadata; List _seasons = []; bool _isLoadingSeasons = false; + bool _seasonsLoadFailed = false; // the seasons fetch threw (vs. a genuinely empty show) Completer? _seasonsCompleter; List _episodes = []; - bool _isLoadingEpisodes = false; - bool _isLoadingAllEpisodes = false; + PagedMediaListState _allEpisodes = const PagedMediaListState(); int _episodesLoadGeneration = 0; - int _tvSeasonEpisodeCacheWarmGeneration = 0; bool _showEpisodesDirectly = false; MediaItem? _fullMetadata; MediaItem? _onDeckEpisode; @@ -166,11 +270,25 @@ class _MediaDetailScreenState extends State // Inline season tabs int _selectedSeasonIndex = 0; - final Map> _episodeCache = {}; - final Set _seasonEpisodeLoadsInFlight = {}; - bool _isLoadingSeasonEpisodes = false; + final _seasonEpisodePager = _SeasonEpisodePager(); List _seasonTabFocusNodes = []; + PagedMediaListState get _selectedSeasonEpisodeState { + if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) { + return const PagedMediaListState(); + } + return _seasonEpisodePager.stateFor(_seasons[_selectedSeasonIndex].id); + } + + bool get _isLoadingEpisodes => _allEpisodes.isInitialLoading; + bool get _isLoadingAllEpisodes => _allEpisodes.isLoadingMore; + int get _allEpisodesTotal => _allEpisodes.totalCount; + bool get _allEpisodesPageError => _allEpisodes.initialLoadFailed || _allEpisodes.pageLoadFailed; + bool get _isLoadingSeasonEpisodes => _selectedSeasonEpisodeState.isInitialLoading; + bool get _isLoadingMoreSeasonEpisodes => _selectedSeasonEpisodeState.isLoadingMore; + bool get _seasonEpisodesPageError => _selectedSeasonEpisodeState.pageLoadFailed; + bool get _seasonEpisodesFirstPageError => _selectedSeasonEpisodeState.initialLoadFailed; + MediaItem _withFallbackLibrary(MediaItem item, MediaItem fallback) { return item.copyWith( libraryId: item.libraryId ?? fallback.libraryId, @@ -182,7 +300,12 @@ class _MediaDetailScreenState extends State final ScrollController _seasonTabsScrollController = ScrollController(); final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode'); final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode'); + String? _lastEpisodeFocusPinnedKey; + bool _suppressNextLastEpisodeFocusLoad = false; static const int _episodesPageSize = 200; + // Sentinel at the end of the inline episode list; its viewport position drives + // lazy paging (the list is shrink-wrapped, so it can't self-detect near-end). + final GlobalKey _episodeTailKey = GlobalKey(); late final FocusNode _playButtonFocusNode; late final FocusNode _ratingChipFocusNode; @@ -317,14 +440,10 @@ class _MediaDetailScreenState extends State isWatched, clearWatchedProgress: clearWatchedProgress, ); - for (final cached in _episodeCache.values) { - _patchWatchedInListWhere( - cached, - (item) => item.id == event.itemId, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - } + _seasonEpisodePager.patchWhere( + (_, item) => item.id == event.itemId, + (item) => _withWatchedPatch(item, isWatched, clearWatchedProgress: clearWatchedProgress), + ); final extras = _extras; if (extras != null) { _patchWatchedInListWhere( @@ -346,6 +465,7 @@ class _MediaDetailScreenState extends State } else if (episodeDelta != 0) { _adjustParentWatchCounts(event.parentChain, episodeDelta); } + _syncFlattenEpisodeState(); }); } @@ -407,14 +527,10 @@ class _MediaDetailScreenState extends State bool isDescendant(MediaItem item) => item.parentChain.contains(parentId); _patchWatchedInListWhere(_seasons, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); _patchWatchedInListWhere(_episodes, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); - for (final entry in _episodeCache.entries) { - _patchWatchedInListWhere( - entry.value, - (item) => entry.key == parentId || isDescendant(item), - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - } + _seasonEpisodePager.patchWhere( + (seasonId, item) => seasonId == parentId || isDescendant(item), + (item) => _withWatchedPatch(item, isWatched, clearWatchedProgress: clearWatchedProgress), + ); } void _adjustParentWatchCounts(List parentIds, int delta) { @@ -491,9 +607,8 @@ class _MediaDetailScreenState extends State _patchItemInList(_seasons, item); _patchItemInList(_episodes, item); - for (final cached in _episodeCache.values) { - _patchItemInList(cached, item); - } + _syncFlattenEpisodeState(); + _seasonEpisodePager.patchEpisode(item.id, (existing) => _normalizeRefreshedItem(item, existing)); final extras = _extras; if (extras != null) { _patchItemInList(extras, item); @@ -584,12 +699,15 @@ class _MediaDetailScreenState extends State final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); if (epIndex != -1) { setState(() { - _episodes.removeAt(epIndex); + if (_isFlattenEpisodeList) { + _allEpisodes = _allEpisodes.removeWhere((episode) => episode.id == event.itemId); + _episodes = _allEpisodes.items; + } else { + _episodes.removeAt(epIndex); + } }); } - for (final cached in _episodeCache.values) { - cached.removeWhere((e) => e.id == event.itemId); - } + _seasonEpisodePager.removeEpisode(event.itemId); if (epIndex != -1 && _showEpisodesDirectly) { if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) { @@ -695,6 +813,7 @@ class _MediaDetailScreenState extends State super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); + _lastEpisodeFocusNode.addListener(_onLastEpisodeFocusChanged); _extrasFocusNode = FocusNode(debugLabel: 'extras_row'); _extrasFocusNode.addListener(_handleExtrasFocusChange); _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); @@ -709,6 +828,7 @@ class _MediaDetailScreenState extends State final positions = _scrollController.positions; if (positions.length != 1) return; _scrollOffset.value = positions.first.pixels; + _maybeTriggerEpisodePaging(); } @override @@ -759,7 +879,8 @@ class _MediaDetailScreenState extends State if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes; if (_seasons.isEmpty) return true; if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false; - return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(_seasons[_selectedSeasonIndex].id); + if (_seasonEpisodesFirstPageError) return true; + return !_isLoadingSeasonEpisodes && _seasonEpisodePager.hasState(_seasons[_selectedSeasonIndex].id); } if (metadata.isSeason) { @@ -840,6 +961,7 @@ class _MediaDetailScreenState extends State } _seasonTabsScrollController.dispose(); _firstEpisodeFocusNode.dispose(); + _lastEpisodeFocusNode.removeListener(_onLastEpisodeFocusChanged); _lastEpisodeFocusNode.dispose(); super.dispose(); } @@ -1410,6 +1532,7 @@ class _MediaDetailScreenState extends State _seasonsCompleter = Completer(); setStateIfMounted(() { _isLoadingSeasons = true; + _seasonsLoadFailed = false; _hasLoadedSeasons = false; }); @@ -1432,8 +1555,10 @@ class _MediaDetailScreenState extends State // form (or null on Jellyfin items). final sectionId = (_fullMetadata ?? _metadata).libraryId; final seasonsFuture = client.fetchChildren(_metadata.id); + // Prefs are a per-library nicety (Plex "flatten seasons"); a failure here + // must never take down the seasons list, so degrade to defaults. final prefsFuture = (client is PlexClient && sectionId != null) - ? client.getLibrarySectionPrefs(sectionId) + ? client.getLibrarySectionPrefs(sectionId).catchError((_) => {}) : Future.value({}); final results = await Future.wait([seasonsFuture, prefsFuture]); @@ -1480,11 +1605,11 @@ class _MediaDetailScreenState extends State if (shouldShowEpisodesDirectly) { await _fetchAllEpisodes(); } else if (seasonsWithServerId.isNotEmpty) { + // Load only the on-deck season's first page; other seasons load lazily + // when focused (strict on-demand — no whole-show pre-warm). if (PlatformDetector.isTV()) { await _fetchSeasonEpisodes(onDeckSeasonIndex); - unawaited(_warmTvSeasonEpisodeCaches(onDeckSeasonIndex)); } else { - // Fetch episodes for the auto-selected season unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); } } @@ -1492,6 +1617,7 @@ class _MediaDetailScreenState extends State appLogger.w('Seasons load failed', error: e, stackTrace: st); setStateIfMounted(() { _isLoadingSeasons = false; + _seasonsLoadFailed = true; _hasLoadedSeasons = true; }); } finally { @@ -1556,7 +1682,9 @@ class _MediaDetailScreenState extends State _updateSeasonTabFocusNodes(seasons.length); for (final entry in seasonMap.entries) { final seasonRatingKey = entry.value.first.parentId ?? ''; - _episodeCache[seasonRatingKey] = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + final sortedEpisodes = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + final episodesWithProgress = sortedEpisodes.map(_applyLocalProgress).toList(); + _seasonEpisodePager.completeFirstPage(seasonRatingKey, episodesWithProgress, episodesWithProgress.length); } final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasons); @@ -1586,8 +1714,9 @@ class _MediaDetailScreenState extends State ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); setState(() { - _episodes = seasonEpisodes.map(_applyLocalProgress).toList(); - _isLoadingEpisodes = false; + final episodes = seasonEpisodes.map(_applyLocalProgress).toList(); + _allEpisodes = _allEpisodes.completeInitialLoad(episodes, episodes.length); + _episodes = _allEpisodes.items; _hasLoadedEpisodes = true; }); } @@ -1606,114 +1735,219 @@ class _MediaDetailScreenState extends State /// Find the season index matching the initial selection or on-deck episode, /// then fall back to the same season the Play button would use. int _findOnDeckSeasonIndex(List seasons) { - // Prefer explicit initial season (from navigation) - if (widget.initialSeasonIndex != null && seasons.isNotEmpty) { - final idx = seasons.indexWhere((s) => s.index == widget.initialSeasonIndex); - if (idx != -1) return idx; - } - // Fall back to on-deck episode's season - final onDeckEpisode = _onDeckEpisode; - if (onDeckEpisode != null && seasons.isNotEmpty) { - final onDeckParentId = onDeckEpisode.parentId; - if (onDeckParentId != null) { - final idx = seasons.indexWhere((s) => s.id == onDeckParentId); - if (idx != -1) return idx; - } - - final onDeckParentIndex = onDeckEpisode.parentIndex; - if (onDeckParentIndex != null) { - final idx = seasons.indexWhere((s) => s.index == onDeckParentIndex); - if (idx != -1) return idx; - } - } - return _defaultPlaybackSeasonIndex(seasons); - } - - int _defaultPlaybackSeasonIndex(List seasons) { - if (seasons.isEmpty) return 0; - final regularSeasonIndex = seasons.indexWhere((season) => (season.index ?? 0) > 0); - return regularSeasonIndex == -1 ? 0 : regularSeasonIndex; - } - - MediaItem? _defaultPlaybackSeason(List seasons) { - if (seasons.isEmpty) return null; - return seasons[_defaultPlaybackSeasonIndex(seasons)]; + return preferredSeasonIndex(seasons, initialSeasonIndex: widget.initialSeasonIndex, onDeckEpisode: _onDeckEpisode); } /// Fetch episodes for a specific season by index, using cache when available + /// Load (or restore) the FIRST page of a season's episodes. The rest loads + /// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward + /// the end, so a 1000+ episode season never blocks on one giant request. Future _fetchSeasonEpisodes(int seasonIndex) async { if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; final seasonId = season.id; - // Check cache first - final cached = _episodeCache[seasonId]; - if (cached != null) { + // Restore from cache (which may hold only a partial page — more loads on + // demand) and clear any prior per-season loading/error state. + final cached = _seasonEpisodePager.stateFor(seasonId); + if (_seasonEpisodePager.hasState(seasonId) && !cached.isInitialLoading && !cached.initialLoadFailed) { + final episodes = cached.items.map(_applyLocalProgress).toList(); + _seasonEpisodePager.completeFirstPage(seasonId, episodes, cached.totalCount); setStateIfMounted(() { if (_isSelectedSeason(seasonIndex, seasonId)) { - _episodes = cached.map(_applyLocalProgress).toList(); - _isLoadingSeasonEpisodes = false; + _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); + } + }); + unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); + return; + } + + if (!_seasonEpisodePager.beginFirstPageLoad(seasonId)) { + setStateIfMounted(() { + if (_isSelectedSeason(seasonIndex, seasonId)) { + _seasonEpisodePager.markFirstPageLoading(seasonId); } }); return; } - if (!_seasonEpisodeLoadsInFlight.add(seasonId)) { - setStateIfMounted(() { - if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true; - }); - return; - } - + final generation = ++_episodesLoadGeneration; setStateIfMounted(() { - if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true; + if (_isSelectedSeason(seasonIndex, seasonId)) { + _seasonEpisodePager.markFirstPageLoading(seasonId); + } }); try { if (widget.isOffline) { - // Offline: load from downloads + // Offline: load from downloads (already the complete set). final downloadProvider = context.read(); final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + final episodes = seasonEpisodes.map(_applyLocalProgress).toList(); _completeSeasonEpisodesLoad( seasonIndex: seasonIndex, seasonId: seasonId, - episodes: seasonEpisodes.map(_applyLocalProgress).toList(), + episodes: episodes, + total: episodes.length, + generation: generation, ); } else { // Resolve the right backend client so Jellyfin (where the typed // PlexClient helper returns null) loads episodes too. - final serverId = _metadata.serverId; - final mediaClient = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); - if (serverId == null || mediaClient == null) { - _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); + final mediaClient = _getMediaClientForMetadata(context); + if (mediaClient == null) { + _completeSeasonEpisodesLoad( + seasonIndex: seasonIndex, + seasonId: seasonId, + episodes: const [], + total: 0, + generation: generation, + ); return; } - final episodes = await mediaClient.fetchChildren(season.id); - final episodesWithServerId = episodes - .map( - (e) => _withFallbackLibrary( - e.copyWith( - serverId: _metadata.serverId ?? e.serverId, - serverName: _metadata.serverName ?? e.serverName, - grandparentId: _metadata.id, - grandparentTitle: _metadata.title ?? e.grandparentTitle, - ), - season.libraryId != null ? season : _metadata, - ), - ) - .map(_applyLocalProgress) - .toList(); - _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: episodesWithServerId); + final page = await fetchSeasonEpisodePage( + mediaClient, + show: _metadata, + season: season, + start: 0, + size: _episodesPageSize, + ); + if (!mounted || generation != _episodesLoadGeneration) return; + _completeSeasonEpisodesLoad( + seasonIndex: seasonIndex, + seasonId: seasonId, + episodes: page.items.map(_applyLocalProgress).toList(), + total: page.totalCount, + generation: generation, + ); + } + } catch (e, st) { + appLogger.w('Season episodes load failed', error: e, stackTrace: st); + if (mounted && generation == _episodesLoadGeneration && _isSelectedSeason(seasonIndex, seasonId)) { + setStateIfMounted(() { + _seasonEpisodePager.failFirstPage(seasonId); + }); } - } catch (e) { - _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); } finally { - _seasonEpisodeLoadsInFlight.remove(seasonId); + _seasonEpisodePager.endFirstPageLoad(seasonId); } } + /// TV-only quality-of-life prefetch: warm just the first page of the adjacent + /// seasons so horizontal season rail changes feel instant without reverting to + /// whole-show or whole-season eager loading. + Future _prefetchAdjacentSeasonEpisodePages(int centerIndex) async { + if (!PlatformDetector.isTV() || widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return; + for (final index in [centerIndex - 1, centerIndex + 1]) { + if (index < 0 || index >= _seasons.length) continue; + unawaited(_prefetchSeasonEpisodeFirstPage(index)); + } + } + + Future _prefetchSeasonEpisodeFirstPage(int seasonIndex) async { + if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; + final season = _seasons[seasonIndex]; + final seasonId = season.id; + if (_seasonEpisodePager.hasState(seasonId)) return; + if (!_seasonEpisodePager.beginFirstPageLoad(seasonId)) return; + + try { + final mediaClient = _getMediaClientForMetadata(context); + if (mediaClient == null) return; + final page = await fetchSeasonEpisodePage( + mediaClient, + show: _metadata, + season: season, + start: 0, + size: _episodesPageSize, + ); + if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) { + return; + } + final episodes = page.items.map(_applyLocalProgress).toList(); + setStateIfMounted(() { + final current = _seasonEpisodePager.stateFor(seasonId); + if (_seasonEpisodePager.hasState(seasonId) && !(current.isInitialLoading && !current.hasItems)) return; + _seasonEpisodePager.completeFirstPage(seasonId, episodes, page.totalCount); + if (_isSelectedSeason(seasonIndex, seasonId)) { + _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); + } + }); + if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); + } catch (e, st) { + appLogger.d('TV adjacent season episode prefetch failed', error: e, stackTrace: st); + if (mounted && _isSelectedSeason(seasonIndex, seasonId)) { + setStateIfMounted(() { + _seasonEpisodePager.failFirstPage(seasonId); + }); + } + } finally { + _seasonEpisodePager.endFirstPageLoad(seasonId); + } + } + + /// Load the next page of the selected season's episodes and append it. + /// No-op when nothing more remains, offline, or a page is already in flight. + Future _loadMoreSeasonEpisodes() async { + final seasonIndex = _selectedSeasonIndex; + if (widget.isOffline || seasonIndex < 0 || seasonIndex >= _seasons.length) return; + final season = _seasons[seasonIndex]; + final seasonId = season.id; + final state = _seasonEpisodePager.stateFor(seasonId); + final loaded = state.items.length; + if (!state.hasMore) return; + if (!_seasonEpisodePager.beginMoreLoad(seasonId)) return; + final generation = _episodesLoadGeneration; + + setStateIfMounted(() { + if (_isSelectedSeason(seasonIndex, seasonId)) { + _seasonEpisodePager.markMoreLoading(seasonId); + } + }); + + try { + final mediaClient = _getMediaClientForMetadata(context); + if (mediaClient == null) { + setStateIfMounted(() { + if (_isSelectedSeason(seasonIndex, seasonId)) { + _seasonEpisodePager.completeMoreLoad(seasonId, expectedOffset: loaded, episodes: const [], total: loaded); + } + }); + return; + } + final page = await fetchSeasonEpisodePage( + mediaClient, + show: _metadata, + season: season, + start: loaded, + size: _episodesPageSize, + ); + if (!mounted || generation != _episodesLoadGeneration) return; + final more = page.items.map(_applyLocalProgress).toList(); + setStateIfMounted(() { + _seasonEpisodePager.completeMoreLoad(seasonId, expectedOffset: loaded, episodes: more, total: page.totalCount); + if (_isSelectedSeason(seasonIndex, seasonId)) _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); + }); + } catch (e, st) { + appLogger.w('Season episodes page load failed', error: e, stackTrace: st); + if (mounted && generation == _episodesLoadGeneration && _isSelectedSeason(seasonIndex, seasonId)) { + setStateIfMounted(() { + _seasonEpisodePager.failMoreLoad(seasonId); + }); + } + } finally { + _seasonEpisodePager.endMoreLoad(seasonId); + } + } + + /// Whether the selected season has more episodes to page in. + bool get _selectedSeasonHasMore { + if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false; + return _selectedSeasonEpisodeState.hasMore; + } + bool _isSelectedSeason(int seasonIndex, String seasonId) { return _selectedSeasonIndex == seasonIndex && seasonIndex >= 0 && @@ -1721,137 +1955,21 @@ class _MediaDetailScreenState extends State _seasons[seasonIndex].id == seasonId; } - Future _warmTvSeasonEpisodeCaches(int selectedSeasonIndex) async { - if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return; - final generation = ++_tvSeasonEpisodeCacheWarmGeneration; - final seasons = List.of(_seasons); - final seasonIdsToWarm = { - for (var i = 0; i < seasons.length; i++) - if (i != selectedSeasonIndex && !_episodeCache.containsKey(seasons[i].id)) seasons[i].id, - }; - if (seasonIdsToWarm.isEmpty) return; - - final serverId = _metadata.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); - if (serverId == null || client == null) return; - - final seasonsById = {for (final season in seasons) season.id: season}; - final seasonsByIndex = { - for (final season in seasons) - if (season.index != null) season.index!: season, - }; - final episodesBySeasonId = { - for (final season in seasons) - if (seasonIdsToWarm.contains(season.id)) season.id: [], - }; - - try { - var offset = 0; - var total = 0; - do { - final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); - if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; - if (page.items.isEmpty) break; - - final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); - for (final episode in enriched) { - final seasonId = _seasonIdForEpisode(episode, seasonsById: seasonsById, seasonsByIndex: seasonsByIndex); - if (seasonId == null || !seasonIdsToWarm.contains(seasonId)) continue; - episodesBySeasonId[seasonId]?.add(episode); - } - - offset += page.items.length; - total = page.totalCount; - } while (offset < total && total > 0); - - _completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation); - } catch (e, st) { - appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st); - await _warmTvSeasonEpisodeCachesBySeason(seasons, seasonIdsToWarm, client, ServerId(serverId), generation); - } - } - - Future _warmTvSeasonEpisodeCachesBySeason( - List seasons, - Set seasonIdsToWarm, - MediaServerClient client, - ServerId serverId, - int generation, - ) async { - final episodesBySeasonId = >{}; - - for (final season in seasons) { - if (!seasonIdsToWarm.contains(season.id) || _episodeCache.containsKey(season.id)) continue; - if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; - try { - final episodes = await client.fetchChildren(season.id); - if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; - episodesBySeasonId[season.id] = episodes - .map( - (episode) => _withFallbackLibrary( - episode.copyWith( - serverId: serverId, - serverName: _metadata.serverName ?? episode.serverName, - grandparentId: _metadata.id, - grandparentTitle: _metadata.title ?? episode.grandparentTitle, - parentId: season.id, - parentIndex: season.index ?? episode.parentIndex, - ), - season.libraryId != null ? season : _metadata, - ), - ) - .map(_applyLocalProgress) - .toList(); - } catch (e, st) { - appLogger.w('Failed to load TV season episodes for ${season.id}', error: e, stackTrace: st); - episodesBySeasonId[season.id] = const []; - } - } - - _completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation); - } - - String? _seasonIdForEpisode( - MediaItem episode, { - required Map seasonsById, - required Map seasonsByIndex, - }) { - final parentId = episode.parentId; - if (parentId != null && seasonsById.containsKey(parentId)) return parentId; - - final parentIndex = episode.parentIndex; - if (parentIndex != null) return seasonsByIndex[parentIndex]?.id; - - return null; - } - - void _completeWarmedTvSeasonEpisodeCaches( - List seasons, - Map> episodesBySeasonId, - int generation, - ) { - if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; - final currentSeasonIds = _seasons.map((season) => season.id).toSet(); - setStateIfMounted(() { - for (final season in seasons) { - if (!currentSeasonIds.contains(season.id) || _episodeCache.containsKey(season.id)) continue; - _episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const []); - } - }); - } - void _completeSeasonEpisodesLoad({ required int seasonIndex, required String seasonId, required List episodes, + required int total, + required int generation, }) { + if (generation != _episodesLoadGeneration) return; setStateIfMounted(() { - _episodeCache[seasonId] = episodes; + _seasonEpisodePager.completeFirstPage(seasonId, episodes, total); if (_isSelectedSeason(seasonIndex, seasonId)) { - _episodes = List.of(episodes); - _isLoadingSeasonEpisodes = false; + _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); } }); + if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); } /// Load extras (trailers, featurettes, behind-the-scenes, etc.). @@ -2398,7 +2516,9 @@ class _MediaDetailScreenState extends State if (key.isUpKey) { // If episodes are visible, focus the last episode (cast is right below episodes) if (_episodes.isNotEmpty) { - final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode; + final useLastEpisode = _episodes.length > 1; + if (useLastEpisode) _suppressNextLastEpisodeFocusLoad = true; + final target = useLastEpisode ? _lastEpisodeFocusNode : _firstEpisodeFocusNode; target.requestFocus(); } else { _focusSectionAboveCast(); @@ -2493,20 +2613,35 @@ class _MediaDetailScreenState extends State ); } + /// Retryable error for a section whose fetch threw (vs. [_sectionEmpty], which + /// means a successful-but-empty result). Reuses the app-wide [ErrorStateWidget] + /// so the Retry button is dpad-focusable. + Widget _sectionError(String message, VoidCallback onRetry) { + return Padding( + padding: const EdgeInsets.all(16), + child: ErrorStateWidget( + message: message, + icon: Symbols.error_outline_rounded, + onRetry: onRetry, + retryLabel: t.common.retry, + ), + ); + } + /// Build episode list directly when the library hides seasons for single-season shows Widget _buildEpisodesList() { final client = _getMediaClientForMetadata(context); + final pinnedLastEpisodeKey = _lastEpisodeFocusPinnedKey; + final hasPinnedLastEpisode = + pinnedLastEpisodeKey != null && _episodes.any((episode) => episode.globalKey == pinnedLastEpisodeKey); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: .zero, - itemCount: _episodes.length + (_isLoadingAllEpisodes ? 1 : 0), + itemCount: _episodes.length + (_episodeListHasMore || _episodeListLoadingMore || _episodeListPageError ? 1 : 0), itemBuilder: (context, index) { if (index == _episodes.length) { - return const Padding( - padding: .all(24), - child: Center(child: CircularProgressIndicator()), - ); + return _buildEpisodeListTail(); } final episode = _episodes[index]; String? localPosterPath; @@ -2521,7 +2656,11 @@ class _MediaDetailScreenState extends State autofocus: false, focusNode: index == 0 ? _firstEpisodeFocusNode - : index == _episodes.length - 1 && _episodes.length > 1 + : _shouldUseLastEpisodeFocusNode( + episode: episode, + index: index, + hasPinnedLastEpisode: hasPinnedLastEpisode, + ) ? _lastEpisodeFocusNode : null, onNavigateUp: index == 0 @@ -2562,15 +2701,53 @@ class _MediaDetailScreenState extends State ); } + bool _shouldUseLastEpisodeFocusNode({ + required MediaItem episode, + required int index, + required bool hasPinnedLastEpisode, + }) { + if (_episodes.length <= 1) return false; + if (hasPinnedLastEpisode) return episode.globalKey == _lastEpisodeFocusPinnedKey; + return index == _episodes.length - 1; + } + + /// Tail of the inline episode list: a retry tile when a page failed, otherwise + /// a spinner. Carries [_episodeTailKey] so the scroll listener can detect when + /// it nears the viewport and page the next batch in. + Widget _buildEpisodeListTail() { + if (_episodeListPageError) { + return Padding( + key: _episodeTailKey, + padding: const EdgeInsets.all(16), + child: ErrorStateWidget( + message: t.messages.episodesLoadFailed, + icon: Symbols.error_outline_rounded, + onRetry: () => unawaited(_loadMoreEpisodeList()), + retryLabel: t.common.retry, + ), + ); + } + return Padding( + key: _episodeTailKey, + padding: const EdgeInsets.all(24), + child: const Center(child: CircularProgressIndicator()), + ); + } + /// Sync an updated episode back into the episode cache void _syncEpisodeToCache(int episodeIndex, MediaItem updated) { - if (_showEpisodesDirectly || _seasons.isEmpty) return; + if (_isFlattenEpisodeList) { + _allEpisodes = _allEpisodes.replaceItems(_episodes); + return; + } + if (_seasons.isEmpty) return; if (_selectedSeasonIndex >= _seasons.length) return; final season = _seasons[_selectedSeasonIndex]; - final cached = _episodeCache[season.id]; - if (cached != null && episodeIndex < cached.length) { - cached[episodeIndex] = updated; - } + _seasonEpisodePager.updateEpisode(season.id, episodeIndex, updated); + } + + void _syncFlattenEpisodeState() { + if (_isFlattenEpisodeList) _allEpisodes = _allEpisodes.replaceItems(_episodes); } /// Refresh episodes for the current context (inline season or all flattened) @@ -2580,7 +2757,7 @@ class _MediaDetailScreenState extends State } else if (_seasons.isNotEmpty) { // Clear cache for current season and re-fetch final season = _seasons[_selectedSeasonIndex]; - _episodeCache.remove(season.id); + _seasonEpisodePager.resetSeason(season.id); await _fetchSeasonEpisodes(_selectedSeasonIndex); } } @@ -2589,8 +2766,8 @@ class _MediaDetailScreenState extends State final generation = ++_episodesLoadGeneration; if (_seasons.isEmpty) { setStateIfMounted(() { - _isLoadingEpisodes = false; - _isLoadingAllEpisodes = false; + _allEpisodes = const PagedMediaListState(); + _episodes = const []; _hasLoadedEpisodes = true; }); return; @@ -2598,8 +2775,8 @@ class _MediaDetailScreenState extends State final serverId = _metadata.serverId; if (serverId == null) { setStateIfMounted(() { - _isLoadingEpisodes = false; - _isLoadingAllEpisodes = false; + _allEpisodes = const PagedMediaListState(); + _episodes = const []; _hasLoadedEpisodes = true; }); return; @@ -2607,15 +2784,15 @@ class _MediaDetailScreenState extends State final client = context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) { setStateIfMounted(() { - _isLoadingEpisodes = false; - _isLoadingAllEpisodes = false; + _allEpisodes = const PagedMediaListState(); + _episodes = const []; _hasLoadedEpisodes = true; }); return; } setStateIfMounted(() { - _isLoadingEpisodes = true; - _isLoadingAllEpisodes = false; + _allEpisodes = const PagedMediaListState().startInitialLoad(); + _episodes = const []; _hasLoadedEpisodes = false; }); try { @@ -2623,21 +2800,14 @@ class _MediaDetailScreenState extends State if (!mounted || generation != _episodesLoadGeneration) return; final enriched = _enrichPlayableEpisodes(firstPage.items, ServerId(serverId)); setStateIfMounted(() { - _episodes = enriched; - _isLoadingEpisodes = false; - _isLoadingAllEpisodes = firstPage.items.length < firstPage.totalCount; + _allEpisodes = _allEpisodes.completeInitialLoad(enriched, firstPage.totalCount); + _episodes = _allEpisodes.items; _hasLoadedEpisodes = true; }); - if (firstPage.items.length < firstPage.totalCount) { - unawaited( - _fetchRemainingEpisodes(client, ServerId(serverId), generation, firstPage.items.length, firstPage.totalCount), - ); - } } catch (e, st) { appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st); setStateIfMounted(() { - _isLoadingEpisodes = false; - _isLoadingAllEpisodes = false; + _allEpisodes = _allEpisodes.failInitialLoad(); _hasLoadedEpisodes = true; }); } @@ -2667,38 +2837,84 @@ class _MediaDetailScreenState extends State .toList(); } - Future _fetchRemainingEpisodes( - MediaServerClient client, - ServerId serverId, - int generation, - int startOffset, - int totalCount, - ) async { - var offset = startOffset; - var total = totalCount; + /// Load the next page of the flatten/all-episodes list (single-season show or + /// season detail) on demand and append it. + Future _loadMoreAllEpisodes() async { + if (widget.isOffline || !_allEpisodes.hasMore || _allEpisodes.isLoadingMore) return; + final serverId = _metadata.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); + if (serverId == null || client == null) return; + final generation = _episodesLoadGeneration; + final offset = _allEpisodes.items.length; + setStateIfMounted(() { + _allEpisodes = _allEpisodes.startLoadMore(); + }); try { - while (offset < total) { - final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); - if (!mounted || generation != _episodesLoadGeneration) return; - if (page.items.isEmpty) break; - final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); - setStateIfMounted(() { - _episodes.addAll(enriched); - }); - offset += page.items.length; - total = page.totalCount; - } + final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); + if (!mounted || generation != _episodesLoadGeneration) return; + final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); + setStateIfMounted(() { + _allEpisodes = _allEpisodes.completeLoadMore( + expectedOffset: offset, + pageItems: enriched, + total: page.totalCount, + ); + _episodes = _allEpisodes.items; + }); } catch (e, st) { - appLogger.w('Failed to finish loading all episodes', error: e, stackTrace: st); - } finally { + appLogger.w('Failed to load more episodes', error: e, stackTrace: st); if (mounted && generation == _episodesLoadGeneration) { setStateIfMounted(() { - _isLoadingAllEpisodes = false; + _allEpisodes = _allEpisodes.failLoadMore(); }); } } } + /// The inline episode list renders in two modes: the flatten/all-episodes list + /// (single-season show or season detail) and the per-season list. These + /// getters unify lazy-paging state across both so [_buildEpisodesList] and the + /// scroll/focus triggers don't have to branch on the mode. + bool get _isFlattenEpisodeList => _showEpisodesDirectly || _metadata.isSeason; + bool get _episodeListHasMore => _isFlattenEpisodeList ? _allEpisodes.hasMore : _selectedSeasonHasMore; + bool get _episodeListLoadingMore => _isFlattenEpisodeList ? _isLoadingAllEpisodes : _isLoadingMoreSeasonEpisodes; + bool get _episodeListPageError => _isFlattenEpisodeList ? _allEpisodesPageError : _seasonEpisodesPageError; + Future _loadMoreEpisodeList() => _isFlattenEpisodeList ? _loadMoreAllEpisodes() : _loadMoreSeasonEpisodes(); + + /// Trigger lazy paging when the tail sentinel nears the viewport. Read-only — + /// must never scroll (it runs inside the scroll listener). + void _maybeTriggerEpisodePaging() { + if (!_episodeListHasMore || _episodeListLoadingMore || _episodeListPageError) return; + final tailContext = _episodeTailKey.currentContext; + if (tailContext == null) return; + final render = tailContext.findRenderObject(); + if (render is! RenderBox || !render.hasSize) return; + final tailTop = render.localToGlobal(Offset.zero).dy; + final viewportHeight = MediaQuery.of(context).size.height; + // Prefetch once the tail is within ~one viewport of the visible bottom. + if (tailTop <= viewportHeight * 2) unawaited(_loadMoreEpisodeList()); + } + + void _onLastEpisodeFocusChanged() { + if (!_lastEpisodeFocusNode.hasFocus) { + _suppressNextLastEpisodeFocusLoad = false; + if (_lastEpisodeFocusPinnedKey != null) { + setStateIfMounted(() => _lastEpisodeFocusPinnedKey = null); + } + return; + } + + if (_episodes.isNotEmpty) _lastEpisodeFocusPinnedKey ??= _episodes.last.globalKey; + + if (_suppressNextLastEpisodeFocusLoad) { + _suppressNextLastEpisodeFocusLoad = false; + return; + } + + // dpad/keyboard equivalent of scrolling to the end of the loaded episodes. + if (!_episodeListPageError) unawaited(_loadMoreEpisodeList()); + } + /// Load the next unwatched episode for offline mode (offline OnDeck) Future _loadOfflineOnDeckEpisode() async { final offlineWatchProvider = context.read(); @@ -2738,25 +2954,26 @@ class _MediaDetailScreenState extends State return; } - final firstSeason = _defaultPlaybackSeason(_seasons)!; + final firstSeason = defaultPlaybackSeason(_seasons)!; - // Get episodes of the first season - List episodes; + // Get the first episode of the first season. + MediaItem? firstEpisode; if (!mounted) return; if (widget.isOffline) { // In offline mode, get episodes from downloads final downloadProvider = context.read(); final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); // Filter to episodes of this season - episodes = allEpisodes.where((ep) => ep.parentIndex == firstSeason.index).toList() + final episodes = allEpisodes.where((ep) => ep.parentIndex == firstSeason.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + firstEpisode = episodes.isEmpty ? null : episodes.first; } else { final client = getServerBoundMediaClient(context); if (client == null) return; - episodes = await client.fetchChildren(firstSeason.id); + firstEpisode = await fetchFirstEpisodeForSeason(client, firstSeason.id); } - if (episodes.isEmpty) { + if (firstEpisode == null) { if (mounted) { showErrorSnackBar(context, t.messages.noEpisodesFound); } @@ -2764,7 +2981,6 @@ class _MediaDetailScreenState extends State } // Play the first episode - final firstEpisode = episodes.first; // Preserve serverId for the episode final episodeWithServerId = firstEpisode.copyWith( serverId: _metadata.serverId ?? firstEpisode.serverId, @@ -2918,6 +3134,8 @@ class _MediaDetailScreenState extends State // Season tabs + inline episodes if (_isLoadingSeasons) _sectionLoading + else if (_seasonsLoadFailed) + _sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons())) else if (_seasons.isEmpty) _sectionEmpty(context, t.messages.noSeasonsFound) else ...[ @@ -2931,6 +3149,11 @@ class _MediaDetailScreenState extends State const SizedBox(height: 16), if (_isLoadingSeasonEpisodes) _sectionLoading + else if (_seasonEpisodesFirstPageError && _episodes.isEmpty) + _sectionError( + t.messages.episodesLoadFailed, + () => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)), + ) else if (_episodes.isNotEmpty) _buildEpisodesList() else @@ -2943,6 +3166,8 @@ class _MediaDetailScreenState extends State const SizedBox(height: 12), if (_isLoadingSeasons || _isLoadingEpisodes) _sectionLoading + else if (_allEpisodesPageError && _episodes.isEmpty) + _sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes())) else if (_episodes.isNotEmpty) _buildEpisodesList() else @@ -3125,6 +3350,8 @@ class _MediaDetailScreenState extends State onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)), onActiveHubChanged: _handleTvDetailHubChanged, onActivateItem: _handleTvDetailRailItemActivated, + trailingForHub: _tvDetailTrailingState, + onRetryHub: _retryTvDetailHub, onNavigateUp: _focusTvDetailActionRow, onBack: _popMediaDetailIfBackNotSuppressed, tallPosterScale: _tvDetailTallPosterScale, @@ -3480,32 +3707,58 @@ class _MediaDetailScreenState extends State List _tvDetailHubs(MediaItem metadata) { final hubs = []; - if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { + if (metadata.isShow && !_showEpisodesDirectly && _seasonsLoadFailed) { + hubs.add( + MediaHub( + id: _tvDetailSeasonsErrorHubId, + title: t.libraries.groupings.seasons, + type: 'episode', + items: const [], + size: 0, + ), + ); + } else if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { + // Emit a hub for every season so TV users can choose a season before its + // episodes are fetched. Extra pages load in-place when focus reaches the + // last loaded episode; the trailing slot is reserved for loading/retry. for (var i = 0; i < _seasons.length; i++) { final season = _seasons[i]; - final cachedEpisodes = _episodeCache[season.id]; - if (cachedEpisodes == null) continue; - final episodes = i == _selectedSeasonIndex ? _episodes : cachedEpisodes; + final state = _seasonEpisodePager.stateFor(season.id); + final episodes = i == _selectedSeasonIndex ? _episodes : state.items; + final total = state.totalCount > episodes.length ? state.totalCount : (season.leafCount ?? episodes.length); hubs.add( MediaHub( id: '$_tvDetailSeasonHubIdPrefix$i', title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle), type: 'episode', items: episodes, - size: episodes.length, + size: total, ), ); } } else if (_episodes.isNotEmpty) { + final total = _allEpisodesTotal > _episodes.length ? _allEpisodesTotal : _episodes.length; hubs.add( MediaHub( id: 'detail_episodes', title: t.libraries.groupings.episodes, type: 'episode', items: _episodes, - size: _episodes.length, + size: total, ), ); + } else if ((metadata.isShow && _showEpisodesDirectly) || metadata.isSeason) { + if (_allEpisodesPageError || _isLoadingEpisodes) { + hubs.add( + MediaHub( + id: 'detail_episodes', + title: t.libraries.groupings.episodes, + type: 'episode', + items: const [], + size: 0, + ), + ); + } } final actors = _tvDetailActorItems(metadata); if (actors.isNotEmpty) { @@ -3610,6 +3863,18 @@ class _MediaDetailScreenState extends State setStateIfMounted(() { _tvDetailFocusedEpisode = item; }); + if (hub.id == 'detail_episodes') { + if (!_allEpisodesPageError && _episodes.isNotEmpty && item.id == _episodes.last.id) { + unawaited(_loadMoreAllEpisodes()); + } + return; + } + if (hub.id == '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex' && + !_seasonEpisodesPageError && + _episodes.isNotEmpty && + item.id == _episodes.last.id) { + unawaited(_loadMoreSeasonEpisodes()); + } } void _handleTvDetailHubChanged(MediaHub hub, int index) { @@ -3622,29 +3887,75 @@ class _MediaDetailScreenState extends State final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length)); if (seasonIndex == null || seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; - final cached = _episodeCache[season.id]; - if (_selectedSeasonIndex == seasonIndex && (cached != null || _episodes.isNotEmpty || _isLoadingSeasonEpisodes)) { + final state = _seasonEpisodePager.stateFor(season.id); + final hasLoadedState = + _seasonEpisodePager.hasState(season.id) && !state.isInitialLoading && !state.initialLoadFailed; + if (_selectedSeasonIndex == seasonIndex && + (hasLoadedState || _episodes.isNotEmpty || _isLoadingSeasonEpisodes || _seasonEpisodesFirstPageError)) { return; } - if (cached != null) { + if (hasLoadedState) { + final episodes = state.items.map(_applyLocalProgress).toList(); + _seasonEpisodePager.completeFirstPage(season.id, episodes, state.totalCount); setStateIfMounted(() { _selectedSeasonIndex = seasonIndex; - _episodes = cached.map(_applyLocalProgress).toList(); - _isLoadingSeasonEpisodes = false; + _episodes = List.of(_seasonEpisodePager.stateFor(season.id).items); }); + unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); return; } setStateIfMounted(() { _selectedSeasonIndex = seasonIndex; _episodes = const []; - _isLoadingSeasonEpisodes = true; + _seasonEpisodePager.markFirstPageLoading(season.id); }); unawaited(_fetchSeasonEpisodes(seasonIndex)); + unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex)); + } + + /// What the rail should show in a hub's trailing slot: a spinner while the + /// selected season page loads or a retry tile if it failed. Episode hubs do + /// not use the legacy "View All" slot; they page in-place on focus. + TvRailTrailing _tvDetailTrailingState(MediaHub hub) { + if (hub.id == _tvDetailSeasonsErrorHubId) return TvRailTrailing.error; + final isFlatten = hub.id == 'detail_episodes'; + final isSeason = hub.id.startsWith(_tvDetailSeasonHubIdPrefix); + if (!isFlatten && !isSeason) return hub.more ? TvRailTrailing.viewAll : TvRailTrailing.none; + if (isFlatten) { + if (_allEpisodesPageError) return TvRailTrailing.error; + if (_isLoadingEpisodes || _isLoadingAllEpisodes) return TvRailTrailing.loading; + return TvRailTrailing.none; + } + final isSelectedSeason = hub.id == '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex'; + if (isSelectedSeason) { + if (_seasonEpisodesFirstPageError || _seasonEpisodesPageError) return TvRailTrailing.error; + if (_isLoadingSeasonEpisodes || _isLoadingMoreSeasonEpisodes) return TvRailTrailing.loading; + } + return TvRailTrailing.none; + } + + void _retryTvDetailHub(MediaHub hub) { + if (hub.id == _tvDetailSeasonsErrorHubId) { + unawaited(_loadSeasons()); + return; + } + if (hub.id == 'detail_episodes') { + unawaited(_episodes.isEmpty ? _fetchAllEpisodes() : _loadMoreAllEpisodes()); + return; + } + if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) { + final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length)); + if (seasonIndex != null && seasonIndex >= 0 && seasonIndex < _seasons.length) { + final state = _seasonEpisodePager.stateFor(_seasons[seasonIndex].id); + unawaited(state.items.isNotEmpty ? _loadMoreSeasonEpisodes() : _fetchSeasonEpisodes(seasonIndex)); + } + } } IconData _getTvDetailHubIcon(MediaHub hub, int index) { + if (hub.id == _tvDetailSeasonsErrorHubId) return Symbols.error_outline_rounded; if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return Symbols.tv_rounded; if (hub.id == 'detail_episodes') return Symbols.tv_rounded; if (hub.id == _tvDetailExtrasHubId) return Symbols.theaters_rounded; @@ -4048,7 +4359,7 @@ class _MediaDetailScreenState extends State // (icon will indicate the difference) return t.discover.playEpisode(season: seasonNum.toString(), episode: episodeNum.toString()); } else { - final seasonNum = _defaultPlaybackSeason(_seasons)?.index ?? 1; + final seasonNum = defaultPlaybackSeason(_seasons)?.index ?? 1; return t.discover.playEpisode(season: seasonNum.toString(), episode: '1'); } } diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 1b4e2e6f..11e524a3 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -523,6 +523,82 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mapItems(allRaw); } + @override + Future> fetchChildrenPage( + String parentId, { + int? start, + int? size, + AbortController? abort, + }) async { + final offset = start ?? 0; + final pageSize = size ?? _pagedListPageSize; + final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}'; + final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}'; + + if (isOfflineMode) { + final cachedSeasons = await cache.get(ServerId(cacheServerId), seasonsKey); + if (cachedSeasons != null) { + final allSeasons = _mapItems(_itemsArray(cachedSeasons)); + if (allSeasons.isNotEmpty) { + final safeOffset = offset.clamp(0, allSeasons.length).toInt(); + final end = (safeOffset + pageSize).clamp(0, allSeasons.length).toInt(); + return LibraryPage( + items: allSeasons.sublist(safeOffset, end), + totalCount: allSeasons.length, + offset: offset, + ); + } + } + final cached = await cache.get(ServerId(cacheServerId), childrenKey); + final all = cached == null ? const [] : _mapItems(_itemsArray(cached)); + final safeOffset = offset.clamp(0, all.length).toInt(); + final end = (safeOffset + pageSize).clamp(0, all.length).toInt(); + final pageItems = all.sublist(safeOffset, end); + return LibraryPage(items: pageItems, totalCount: all.length, offset: offset); + } + + try { + final seasons = await _http.get( + '/Shows/${_segment(parentId)}/Seasons', + queryParameters: { + 'userId': connection.userId, + 'StartIndex': offset.toString(), + 'Limit': pageSize.toString(), + 'EnableTotalRecordCount': 'true', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + abort: abort, + ); + if (seasons.statusCode == 200) { + final data = seasons.data; + final items = _itemsArray(data); + final rawTotal = data is Map ? data['TotalRecordCount'] : null; + if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) { + return _pagedMediaItems(data, offset: offset, requestedSize: pageSize); + } + } + } on MediaServerHttpException { + // Not a series — fall through to the generic ParentId query. + } + + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'StartIndex': offset.toString(), + 'Limit': pageSize.toString(), + 'EnableTotalRecordCount': 'true', + 'Fields': _episodeRowFields, + ...jellyfinImageQueryParameters, + }, + abort: abort, + ); + throwIfHttpError(response); + return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + } + /// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query /// direct children of the library/folder with `Recursive=false`. This is /// distinct from [fetchLibraryContent], which intentionally recurses through diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9f7c625d..e79bcaae 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1206,6 +1206,56 @@ class PlexClient return all; } + /// Walk every page of [path] and return a single synthesized response whose + /// `MediaContainer.Metadata` concatenates all pages. Lets a caller (and its + /// cache layer) treat a large, server-paginated collection as one complete + /// response while each network request stays small. Raw-response analog of + /// [_fetchAllPages]; errors propagate. + Future _getAllPagesResponse( + String path, { + Map? queryParameters, + AbortController? abort, + }) async { + MediaServerResponse? firstResponse; + Map? firstContainer; + final allMetadata = []; + var start = 0; + while (true) { + final response = await _getWithFailover( + path, + queryParameters: {...?queryParameters, ..._buildPaginationParams(start, _fetchAllPageSize)}, + abort: abort, + ); + final container = _getMediaContainer(response); + final metadata = container?['Metadata']; + final pageItems = metadata is List ? metadata : const []; + firstResponse ??= response; + firstContainer ??= container; + allMetadata.addAll(pageItems); + final total = _responseTotalSize( + response, + itemCount: pageItems.length, + start: start, + requestedSize: _fetchAllPageSize, + ); + start += pageItems.length; + if (pageItems.isEmpty || start >= total) break; + } + return MediaServerResponse( + statusCode: firstResponse.statusCode, + data: { + 'MediaContainer': { + ...?firstContainer, + 'Metadata': allMetadata, + 'size': allMetadata.length, + 'totalSize': allMetadata.length, + }, + }, + headers: firstResponse.headers, + requestUri: firstResponse.requestUri, + ); + } + /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series /// For movies, use the movie's ratingKey @@ -1412,20 +1462,35 @@ class PlexClient return result; } - /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) - /// Uses cache when offline or as fallback on network error + /// Get children of a metadata item (e.g., seasons for a show, episodes for a season). + /// Walks every page so large shows (many seasons) aren't truncated by a + /// server-forced container limit; uses cache when offline or as fallback on + /// network error. (Large *episode* lists load lazily via [fetchChildrenPage]; + /// this full-fetch is for the seasons list and other complete-list callers.) Future> _getChildren(String ratingKey) async { final endpoint = '/library/metadata/$ratingKey/children'; return await fetchWithCacheFallback>( cacheKey: endpoint, - networkCall: () => _http.get(endpoint, queryParameters: {'includeStreams': 1}), + networkCall: () => _getAllPagesResponse(endpoint, queryParameters: {'includeStreams': 1}), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? []; } + /// Page through direct children of a metadata item (e.g. episodes of a + /// season). This uses `/children`; playable descendant paging uses + /// `/grandchildren` and intentionally has different semantics. + Future<_LibraryContentResult> _getChildrenPage(String ratingKey, {int? start, int? size, AbortController? abort}) => + _fetchPaginatedList( + '/library/metadata/$ratingKey/children', + start: start, + size: size, + abort: abort, + queryParameters: {'includeStreams': 1}, + ); + /// Page through playable episodes beneath a show or season. Uses /// `/grandchildren` rather than `/allLeaves` because the live server returns /// 0 items for `/allLeaves` on a season. @@ -3264,6 +3329,21 @@ class PlexClient return children.map((m) => PlexMappers.mediaItem(m)).toList(); } + @override + Future> fetchChildrenPage( + String parentId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getChildrenPage(parentId, start: start, size: size, abort: abort); + return LibraryPage( + items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + @override Future> fetchPlayableDescendants(String parentId) async { final leaves = await _fetchAllPages( diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 07d30b64..4c101cad 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -8,7 +8,7 @@ import '../media/media_server_client.dart'; import '../models/download_models.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; -import '../utils/episode_collection.dart'; +import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; import 'download_manager_service.dart'; import 'multi_server_manager.dart'; diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index c34d7924..1a629000 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -5,7 +5,7 @@ import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; -import '../../utils/episode_collection.dart'; +import '../../media/episode_collection.dart'; import 'anime_episode_progress_resolver.dart'; import 'anime_lists_mapping_store.dart'; import 'anilist/anilist_tracker.dart'; diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index d7af7b45..19a1d2e8 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -8,7 +8,7 @@ import '../../media/media_server_client.dart'; import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; -import '../../utils/episode_collection.dart'; +import '../../media/episode_collection.dart'; import '../../utils/watch_state_notifier.dart'; import '../multi_server_manager.dart'; import '../settings_service.dart'; diff --git a/lib/utils/download_version_utils.dart b/lib/utils/download_version_utils.dart index af1aa339..82ba52c9 100644 --- a/lib/utils/download_version_utils.dart +++ b/lib/utils/download_version_utils.dart @@ -4,6 +4,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../media/media_version.dart'; +import '../media/episode_collection.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/media_version_resolver.dart'; @@ -97,18 +98,13 @@ Future?> fetchRepresentativeVersions(MediaServerClient client String? episodeRatingKey; if (metadata.kind == MediaKind.season) { - final episodes = await client.fetchChildren(metadata.id); - final firstEpisode = episodes.where((e) => e.kind == MediaKind.episode).firstOrNull; + final firstEpisode = await fetchFirstEpisodeForSeason(client, metadata.id); episodeRatingKey = firstEpisode?.id; } else if (metadata.kind == MediaKind.show) { final seasons = await client.fetchChildren(metadata.id); - // Skip Season 0 (Specials) as it may have different encoding - final firstSeason = - seasons.where((s) => s.kind == MediaKind.season && (s.index ?? 0) > 0).firstOrNull ?? - seasons.where((s) => s.kind == MediaKind.season).firstOrNull; + final firstSeason = defaultPlaybackSeason(seasons); if (firstSeason != null) { - final episodes = await client.fetchChildren(firstSeason.id); - final firstEpisode = episodes.where((e) => e.kind == MediaKind.episode).firstOrNull; + final firstEpisode = await fetchFirstEpisodeForSeason(client, firstSeason.id); episodeRatingKey = firstEpisode?.id; } } diff --git a/lib/utils/episode_collection.dart b/lib/utils/episode_collection.dart deleted file mode 100644 index fb0774ed..00000000 --- a/lib/utils/episode_collection.dart +++ /dev/null @@ -1,78 +0,0 @@ -import '../media/media_item.dart'; -import '../media/media_kind.dart'; -import '../media/media_server_client.dart'; - -/// Collect every episode of a show into [out] using the backend's one-shot -/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] — -/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's -/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking -/// show → seasons → episodes client-side, so large series come back in one -/// trip and aren't capped by any per-page Limit. -/// -/// A failure of the underlying call propagates to the caller — both -/// [DownloadProvider.queueDownload] and the sync rule executor wrap their -/// invocations so the user-facing error surfaces / the rule run is rolled -/// back. -Future collectEpisodesForShow( - MediaServerClient client, - String showRatingKey, { - required bool unwatchedOnly, - required List out, - MediaItem? fallback, -}) { - return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); -} - -/// Collect every episode of a single season into [out] via the same -/// one-shot endpoint. On a season the leaves *are* the episodes, so the -/// shape matches the show case. -Future collectEpisodesForSeason( - MediaServerClient client, - String seasonRatingKey, { - required bool unwatchedOnly, - required List out, - MediaItem? fallback, -}) { - return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); -} - -Future _collectPlayable( - MediaServerClient client, - String parentId, { - required bool unwatchedOnly, - required List out, - MediaItem? fallback, -}) async { - final leaves = await client.fetchPlayableDescendants(parentId); - for (final ep in leaves) { - if (ep.kind != MediaKind.episode) continue; - if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; - out.add(_withFallbackLibrary(ep, fallback)); - } -} - -MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) { - if (fallback == null) return item; - final fallbackIsSeason = fallback.kind == MediaKind.season; - final fallbackIsShow = fallback.kind == MediaKind.show; - return item.copyWith( - serverId: item.serverId ?? fallback.serverId, - serverName: item.serverName ?? fallback.serverName, - libraryId: item.libraryId ?? fallback.libraryId, - libraryTitle: item.libraryTitle ?? fallback.libraryTitle, - parentId: item.parentId ?? (fallbackIsSeason ? fallback.id : null), - parentTitle: item.parentTitle ?? (fallbackIsSeason ? fallback.title : null), - grandparentId: item.grandparentId ?? _fallbackGrandparentId(fallback, isShow: fallbackIsShow), - grandparentTitle: item.grandparentTitle ?? _fallbackGrandparentTitle(fallback, isShow: fallbackIsShow), - ); -} - -String? _fallbackGrandparentId(MediaItem fallback, {required bool isShow}) { - if (isShow) return fallback.id; - return fallback.grandparentId ?? fallback.parentId; -} - -String? _fallbackGrandparentTitle(MediaItem fallback, {required bool isShow}) { - if (isShow) return fallback.title; - return fallback.grandparentTitle ?? fallback.parentTitle; -} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index dcadf44a..5a23ed12 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -137,7 +137,11 @@ class TvBrowseRailLayout { final railEdgePadding = focusExtra + (12 * scale); final itemGap = fullCardLayout ? fullCardItemGapForScale(scale) : itemGapForScale(scale); final isPersonHub = TvBrowseRailLayout.isPersonHub(hub); - final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final emptyEpisodeThumbnailHub = + hub.items.isEmpty && hub.type == 'episode' && episodePosterMode == EpisodePosterMode.episodeThumbnail; + final hasWide = + !isPersonHub && + (emptyEpisodeThumbnailHub || hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode))); final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); final isMixedHub = hasWide && hasTall; final useWideLayout = hasWide && (!hasTall || episodePosterMode == EpisodePosterMode.episodeThumbnail); @@ -205,9 +209,11 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double scale, + bool? hasTrailing, }) { + final showTrailing = hasTrailing ?? hub.more; final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap); - final moreContentWidth = hub.more ? viewAllItemWidthForScale(scale) + metrics.itemGap : 0.0; + final moreContentWidth = showTrailing ? viewAllItemWidthForScale(scale) + metrics.itemGap : 0.0; final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); } @@ -217,8 +223,10 @@ class TvBrowseRailLayout { required int index, required TvBrowseRailLayoutMetrics metrics, required double scale, + bool? hasTrailing, }) { - if (index == hub.items.length && hub.more) return viewAllItemWidthForScale(scale) + metrics.itemGap; + final showTrailing = hasTrailing ?? hub.more; + if (index == hub.items.length && showTrailing) return viewAllItemWidthForScale(scale) + metrics.itemGap; return metrics.cardWidth + metrics.itemGap; } @@ -229,15 +237,23 @@ class TvBrowseRailLayout { required double viewportWidth, required double maxScrollExtent, required double scale, + bool? hasTrailing, }) { - final totalCount = hub.items.length + (hub.more ? 1 : 0); + final showTrailing = hasTrailing ?? hub.more; + final totalCount = hub.items.length + (showTrailing ? 1 : 0); if (totalCount == 0) return 0; final clampedIndex = index.clamp(0, totalCount - 1).toInt(); final normalItemExtent = metrics.cardWidth + metrics.itemGap; final normalItemsBefore = clampedIndex < hub.items.length ? clampedIndex : hub.items.length; final leadingOffset = metrics.railEdgePadding + (normalItemsBefore * normalItemExtent); - final targetExtent = itemExtentForIndex(hub: hub, index: clampedIndex, metrics: metrics, scale: scale); + final targetExtent = itemExtentForIndex( + hub: hub, + index: clampedIndex, + metrics: metrics, + scale: scale, + hasTrailing: showTrailing, + ); final targetCenter = leadingOffset + (targetExtent / 2); return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble(); } @@ -280,6 +296,9 @@ class TvBrowseRailLayout { } } +/// What [TvBrowseRail] renders in a hub's trailing slot (after the last item). +enum TvRailTrailing { none, loading, error, viewAll } + class TvBrowseRail extends StatefulWidget { final List hubs; final IconData Function(MediaHub hub, int index) iconForHub; @@ -289,6 +308,13 @@ class TvBrowseRail extends StatefulWidget { final VoidCallback? onRemoveFromContinueWatching; final bool Function(MediaHub hub)? isContinueWatchingHub; final Future> Function(MediaHub hub)? loadMoreItems; + + /// Optional per-hub trailing-slot state (loading/error/viewAll). When null the + /// rail keeps the legacy "[MediaHub.more] → View All" behavior. + final TvRailTrailing Function(MediaHub hub)? trailingForHub; + + /// Invoked when the user activates a hub's trailing retry tile. + final void Function(MediaHub hub)? onRetryHub; final void Function(MediaHub hub, int index)? onActiveHubChanged; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateToSidebar; @@ -313,6 +339,8 @@ class TvBrowseRail extends StatefulWidget { this.onRemoveFromContinueWatching, this.isContinueWatchingHub, this.loadMoreItems, + this.trailingForHub, + this.onRetryHub, this.onActiveHubChanged, this.onNavigateUp, this.onNavigateToSidebar, @@ -347,6 +375,7 @@ class TvBrowseRailState extends State { final Map> _mediaCardKeys = {}; final Map _metricsByHub = {}; final Map _scaleByHub = {}; + final Map _lastTrailingByHubId = {}; int _hubIndex = 0; int _itemIndex = 0; @@ -382,6 +411,7 @@ class TvBrowseRailState extends State { _focusNode.addListener(_handleFocusChange); _selectInitialHubIfPossible(); final selectedInitialItem = _selectInitialItemIfPossible(); + _rememberTrailingStates(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || widget.hubs.isEmpty) return; if (selectedInitialItem) _scrollToItem(animate: false); @@ -395,11 +425,13 @@ class TvBrowseRailState extends State { @override void didUpdateWidget(covariant TvBrowseRail oldWidget) { super.didUpdateWidget(oldWidget); - final hubStateChanged = !_hasSameHubState(oldWidget.hubs, widget.hubs); + final trailingStateChanged = _hasTrailingStateChanged(widget.hubs); + final hubStateChanged = trailingStateChanged || !_hasSameHubState(oldWidget.hubs, widget.hubs); final initialSelectionChanged = oldWidget.initialHubId != widget.initialHubId || oldWidget.initialItemId != widget.initialItemId; if (!hubStateChanged && !initialSelectionChanged) { + _rememberTrailingStates(); if (!oldWidget.autofocus && widget.autofocus) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); @@ -415,6 +447,7 @@ class TvBrowseRailState extends State { if (widget.hubs.isEmpty) { _hubIndex = 0; _itemIndex = 0; + _rememberTrailingStates(); return; } @@ -435,11 +468,20 @@ class TvBrowseRailState extends State { _itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1); final selectedInitialItem = _selectInitialItemIfPossible(); final activeHubChanged = oldActiveHubId != _activeHub?.id; - final shouldAlignActiveHub = selectedInitialHub || activeHubChanged || !_hasUserChangedHub; + final activeHubStateChanged = + _hubStateChanged(oldWidget.hubs, widget.hubs, _hubIndex) || + (_activeHub != null && _trailingStateChanged(_activeHub!)); + final shouldJumpAlignActiveHub = selectedInitialHub || activeHubChanged || !_hasUserChangedHub; + final shouldAnimateAlignActiveHub = !shouldJumpAlignActiveHub && activeHubStateChanged; + _rememberTrailingStates(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (selectedInitialItem) _scrollToItem(animate: false); - if (shouldAlignActiveHub) _scrollActiveHubToTop(animate: false); + if (shouldJumpAlignActiveHub) { + _scrollActiveHubToTop(animate: false); + } else if (shouldAnimateAlignActiveHub) { + _scrollActiveHubToTop(); + } if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus(); if (activeHubChanged) _notifyActiveHubChanged(); _notifyFocusedItem(); @@ -449,18 +491,42 @@ class TvBrowseRailState extends State { bool _hasSameHubState(List oldHubs, List newHubs) { if (oldHubs.length != newHubs.length) return false; for (var i = 0; i < oldHubs.length; i++) { - final oldHub = oldHubs[i]; - final newHub = newHubs[i]; - if (oldHub.id != newHub.id || oldHub.more != newHub.more || oldHub.items.length != newHub.items.length) { - return false; - } - for (var j = 0; j < oldHub.items.length; j++) { - if (oldHub.items[j].globalKey != newHub.items[j].globalKey) return false; - } + if (_hubStateChanged(oldHubs, newHubs, i)) return false; } return true; } + bool _hubStateChanged(List oldHubs, List newHubs, int index) { + if (index < 0 || index >= oldHubs.length || index >= newHubs.length) return true; + final oldHub = oldHubs[index]; + final newHub = newHubs[index]; + if (oldHub.id != newHub.id || oldHub.more != newHub.more || oldHub.items.length != newHub.items.length) { + return true; + } + for (var j = 0; j < oldHub.items.length; j++) { + if (oldHub.items[j].globalKey != newHub.items[j].globalKey) return true; + } + return false; + } + + bool _hasTrailingStateChanged(List hubs) { + for (final hub in hubs) { + if (_trailingStateChanged(hub)) return true; + } + return false; + } + + bool _trailingStateChanged(MediaHub hub) { + final previous = _lastTrailingByHubId[hub.id]; + return previous != null && previous != _trailingFor(hub); + } + + void _rememberTrailingStates() { + _lastTrailingByHubId + ..clear() + ..addEntries(widget.hubs.map((hub) => MapEntry(hub.id, _trailingFor(hub)))); + } + @override void dispose() { _longPressTimer?.cancel(); @@ -492,7 +558,9 @@ class TvBrowseRailState extends State { _suppressSelectUntilKeyUp = false; } - int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0); + bool _hasTrailingFor(MediaHub hub) => _trailingFor(hub) != TvRailTrailing.none; + + int _totalItemCount(MediaHub hub) => hub.items.length + (_hasTrailingFor(hub) ? 1 : 0); bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub); @@ -735,6 +803,7 @@ class TvBrowseRailState extends State { viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, scale: scale, + hasTrailing: _hasTrailingFor(hub), ); final distance = (position.pixels - target).abs(); @@ -766,6 +835,7 @@ class TvBrowseRailState extends State { metrics: metrics, viewportWidth: viewportWidth, scale: scale, + hasTrailing: _hasTrailingFor(hub), ); final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( hub: hub, @@ -774,6 +844,7 @@ class TvBrowseRailState extends State { viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, scale: scale, + hasTrailing: _hasTrailingFor(hub), ); return ScrollController(initialScrollOffset: initialScrollOffset); }); @@ -793,8 +864,17 @@ class TvBrowseRailState extends State { Future _activateCurrentItem() async { final hub = _activeHub; if (hub == null) return; - if (_itemIndex == hub.items.length && hub.more) { - _navigateToHubDetail(hub); + if (_itemIndex == hub.items.length && _hasTrailingFor(hub)) { + switch (_trailingFor(hub)) { + case TvRailTrailing.error: + widget.onRetryHub?.call(hub); + break; + case TvRailTrailing.loading: + break; // no-op while the page loads + case TvRailTrailing.viewAll: + case TvRailTrailing.none: + _navigateToHubDetail(hub); + } return; } if (_itemIndex >= hub.items.length) return; @@ -1055,7 +1135,7 @@ class TvBrowseRailState extends State { ), ), ), - if (hub.more) ...[ + if (_trailingFor(hub) == TvRailTrailing.viewAll) ...[ SizedBox(width: 8 * scale), AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20 * scale, color: iconColor), SizedBox(width: 30 * scale), @@ -1108,8 +1188,13 @@ class TvBrowseRailState extends State { scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: .fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), - itemExtentBuilder: (itemIndex, _) => - TvBrowseRailLayout.itemExtentForIndex(hub: hub, index: itemIndex, metrics: metrics, scale: scale), + itemExtentBuilder: (itemIndex, _) => TvBrowseRailLayout.itemExtentForIndex( + hub: hub, + index: itemIndex, + metrics: metrics, + scale: scale, + hasTrailing: _hasTrailingFor(hub), + ), itemCount: totalCount, itemBuilder: (context, itemIndex) { final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; @@ -1118,15 +1203,7 @@ class TvBrowseRailState extends State { padding: .only(right: metrics.itemGap), child: Align( alignment: .centerLeft, - child: _buildViewAllButton( - context, - isFocused: isFocused, - scale: scale, - onTap: () { - _selectHubItem(hub, hubIndex, itemIndex); - _navigateToHubDetail(hub); - }, - ), + child: _buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale), ), ); } @@ -1316,11 +1393,88 @@ class TvBrowseRailState extends State { ); } + /// Resolve the trailing-slot state for [hub], defaulting to the legacy + /// "[MediaHub.more] → View All" behavior when no resolver was supplied. + TvRailTrailing _trailingFor(MediaHub hub) => + widget.trailingForHub?.call(hub) ?? (hub.more ? TvRailTrailing.viewAll : TvRailTrailing.none); + + Widget _buildTrailingSlot( + BuildContext context, + MediaHub hub, + int hubIndex, + int itemIndex, { + required bool isFocused, + required double scale, + }) { + switch (_trailingFor(hub)) { + case TvRailTrailing.loading: + return _buildTrailingSpinner(context, isFocused: isFocused, scale: scale); + case TvRailTrailing.error: + return _buildViewAllButton( + context, + isFocused: isFocused, + scale: scale, + label: t.common.retry, + icon: Symbols.refresh_rounded, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + widget.onRetryHub?.call(hub); + }, + ); + case TvRailTrailing.viewAll: + return _buildViewAllButton( + context, + isFocused: isFocused, + scale: scale, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + _navigateToHubDetail(hub); + }, + ); + case TvRailTrailing.none: + return const SizedBox.shrink(); + } + } + + Widget _buildTrailingSpinner(BuildContext context, {required bool isFocused, required double scale}) { + final theme = Theme.of(context); + final duration = FocusTheme.getAnimationDuration(context); + final width = TvBrowseRailLayout.viewAllItemWidthForScale(scale); + final height = TvBrowseRailLayout.viewAllPillHeightForScale(scale); + final foreground = isFocused ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.78); + final background = isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.2) + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.18); + return AnimatedScale( + scale: isFocused ? 1.04 : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + width: width, + height: height, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(height / 2), + boxShadow: isFocused + ? [BoxShadow(color: theme.colorScheme.primary.withValues(alpha: 0.2), blurRadius: 18, spreadRadius: 1)] + : null, + ), + child: Center( + child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: foreground)), + ), + ), + ); + } + Widget _buildViewAllButton( BuildContext context, { required bool isFocused, required double scale, required VoidCallback onTap, + String? label, + IconData icon = Symbols.arrow_forward_rounded, }) { final theme = Theme.of(context); final duration = FocusTheme.getAnimationDuration(context); @@ -1362,7 +1516,7 @@ class TvBrowseRailState extends State { children: [ Flexible( child: Text( - t.common.viewAll, + label ?? t.common.viewAll, maxLines: 1, overflow: .ellipsis, style: TextStyle( @@ -1374,12 +1528,7 @@ class TvBrowseRailState extends State { ), ), SizedBox(width: (5 * scale).clamp(4, 7).toDouble()), - AppIcon( - Symbols.arrow_forward_rounded, - fill: 1, - size: (18 * scale).clamp(16, 22).toDouble(), - color: foreground, - ), + AppIcon(icon, fill: 1, size: (18 * scale).clamp(16, 22).toDouble(), color: foreground), ], ), ),