fix(jellyfin): repair music library browsing

Fixes #1557
This commit is contained in:
edde746
2026-07-13 22:58:12 +02:00
parent d6ad6a9506
commit ec249d2ed4
51 changed files with 674 additions and 82 deletions
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Възрастов рейтинг",
"tag": "Таг",
"unwatched": "Негледани",
"unplayed": "Непускани",
"favorites": "Любими"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Aldersvurdering",
"tag": "Tag",
"unwatched": "Usete",
"unplayed": "Ikke afspillet",
"favorites": "Favoritter"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Altersfreigabe",
"tag": "Tag",
"unwatched": "Ungesehene",
"unplayed": "Nicht abgespielt",
"favorites": "Favoriten"
},
"sortLabels": {
+1
View File
@@ -789,6 +789,7 @@
"contentRating": "Content Rating",
"tag": "Tag",
"unwatched": "Unwatched",
"unplayed": "Unplayed",
"favorites": "Favorites"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Clasificación",
"tag": "Etiqueta",
"unwatched": "No vistos",
"unplayed": "No reproducido",
"favorites": "Favoritos"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Classification",
"tag": "Tag",
"unwatched": "Non vus",
"unplayed": "Non lu",
"favorites": "Favoris"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Classificazione",
"tag": "Tag",
"unwatched": "Non visti",
"unplayed": "Non riprodotto",
"favorites": "Preferiti"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "視聴年齢区分",
"tag": "タグ",
"unwatched": "未視聴",
"unplayed": "未再生",
"favorites": "お気に入り"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "시청 등급",
"tag": "태그",
"unwatched": "미시청",
"unplayed": "재생하지 않음",
"favorites": "즐겨찾기"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Aldersgrense",
"tag": "Tag",
"unwatched": "Usette",
"unplayed": "Ikke avspilt",
"favorites": "Favoritter"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Leeftijdsclassificatie",
"tag": "Tag",
"unwatched": "Onbekeken",
"unplayed": "Niet afgespeeld",
"favorites": "Favorieten"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Klasyfikacja wiekowa",
"tag": "Tag",
"unwatched": "Nieobejrzane",
"unplayed": "Nieodtworzone",
"favorites": "Ulubione"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Classificação",
"tag": "Tag",
"unwatched": "Não assistidos",
"unplayed": "Não reproduzido",
"favorites": "Favoritos"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Возрастной рейтинг",
"tag": "Тег",
"unwatched": "Непросмотренные",
"unplayed": "Не прослушано",
"favorites": "Избранное"
},
"sortLabels": {
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22524 (1407 per locale)
/// Strings: 22540 (1408 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesBg extends TranslationsLibrariesFilt
@override String get contentRating => 'Възрастов рейтинг';
@override String get tag => 'Таг';
@override String get unwatched => 'Негледани';
@override String get unplayed => 'Непускани';
@override String get favorites => 'Любими';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsBg {
'libraries.filterCategories.contentRating' => 'Възрастов рейтинг',
'libraries.filterCategories.tag' => 'Таг',
'libraries.filterCategories.unwatched' => 'Негледани',
'libraries.filterCategories.unplayed' => 'Непускани',
'libraries.filterCategories.favorites' => 'Любими',
'libraries.sortLabels.title' => 'Заглавие',
'libraries.sortLabels.dateAdded' => 'Дата на добавяне',
@@ -3125,9 +3127,9 @@ extension on TranslationsBg {
'downloads.serverErrorBitrate' => 'Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт',
'downloads.episodesQueued' => ({required Object count}) => '${count} епизода са добавени в опашката за изтегляне',
'downloads.downloadDeleted' => 'Изтеглянето е изтрито',
'downloads.deleteConfirm' => ({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?',
'downloads.cancelledDownloadTitle' => 'Отменено изтегляне',
'downloads.cancelledDownloadMessage' => 'Това изтегляне беше отменено. Какво искате да направите?',
'downloads.allEpisodesAlreadyDownloaded' => 'Всички епизоди вече са изтеглени',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesDa extends TranslationsLibrariesFilt
@override String get contentRating => 'Aldersvurdering';
@override String get tag => 'Tag';
@override String get unwatched => 'Usete';
@override String get unplayed => 'Ikke afspillet';
@override String get favorites => 'Favoritter';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsDa {
'libraries.filterCategories.contentRating' => 'Aldersvurdering',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Usete',
'libraries.filterCategories.unplayed' => 'Ikke afspillet',
'libraries.filterCategories.favorites' => 'Favoritter',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Tilføjet dato',
@@ -3125,9 +3127,9 @@ extension on TranslationsDa {
'downloads.serverErrorBitrate' => 'Serverfejl: filen overskrider muligvis grænsen for ekstern bitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i downloadkø',
'downloads.downloadDeleted' => 'Download slettet',
'downloads.deleteConfirm' => ({required Object title}) => 'Slet "${title}" fra denne enhed?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Slet "${title}" fra denne enhed?',
'downloads.cancelledDownloadTitle' => 'Annulleret download',
'downloads.cancelledDownloadMessage' => 'Denne download blev annulleret. Hvad vil du gøre?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle episoder er allerede downloadet',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesDe extends TranslationsLibrariesFilt
@override String get contentRating => 'Altersfreigabe';
@override String get tag => 'Tag';
@override String get unwatched => 'Ungesehene';
@override String get unplayed => 'Nicht abgespielt';
@override String get favorites => 'Favoriten';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsDe {
'libraries.filterCategories.contentRating' => 'Altersfreigabe',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Ungesehene',
'libraries.filterCategories.unplayed' => 'Nicht abgespielt',
'libraries.filterCategories.favorites' => 'Favoriten',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Hinzugefügt am',
@@ -3125,9 +3127,9 @@ extension on TranslationsDe {
'downloads.serverErrorBitrate' => 'Serverfehler: Datei überschreitet möglicherweise das Remote-Bitrate-Limit',
'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt',
'downloads.downloadDeleted' => 'Download gelöscht',
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" von diesem Gerät löschen?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" von diesem Gerät löschen?',
'downloads.cancelledDownloadTitle' => 'Abgebrochener Download',
'downloads.cancelledDownloadMessage' => 'Dieser Download wurde abgebrochen. Was möchtest du tun?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle Episoden sind bereits heruntergeladen',
+5 -1
View File
@@ -4461,6 +4461,9 @@ class TranslationsLibrariesFilterCategoriesEn {
/// en: 'Unwatched'
String get unwatched => 'Unwatched';
/// en: 'Unplayed'
String get unplayed => 'Unplayed';
/// en: 'Favorites'
String get favorites => 'Favorites';
}
@@ -5684,6 +5687,7 @@ extension on Translations {
'libraries.filterCategories.contentRating' => 'Content Rating',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Unwatched',
'libraries.filterCategories.unplayed' => 'Unplayed',
'libraries.filterCategories.favorites' => 'Favorites',
'libraries.sortLabels.title' => 'Title',
'libraries.sortLabels.dateAdded' => 'Date Added',
@@ -5972,9 +5976,9 @@ extension on Translations {
'downloads.noDownloadsDescription' => 'Downloaded content will appear here for offline viewing',
'downloads.downloadNow' => 'Download',
'downloads.deleteDownload' => 'Delete download',
'downloads.retryDownload' => 'Retry download',
_ => null,
} ?? switch (path) {
'downloads.retryDownload' => 'Retry download',
'downloads.downloadQueued' => 'Download queued',
'downloads.downloadResumed' => 'Download resumed',
'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesEs extends TranslationsLibrariesFilt
@override String get contentRating => 'Clasificación';
@override String get tag => 'Etiqueta';
@override String get unwatched => 'No vistos';
@override String get unplayed => 'No reproducido';
@override String get favorites => 'Favoritos';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsEs {
'libraries.filterCategories.contentRating' => 'Clasificación',
'libraries.filterCategories.tag' => 'Etiqueta',
'libraries.filterCategories.unwatched' => 'No vistos',
'libraries.filterCategories.unplayed' => 'No reproducido',
'libraries.filterCategories.favorites' => 'Favoritos',
'libraries.sortLabels.title' => 'Título',
'libraries.sortLabels.dateAdded' => 'Fecha de adición',
@@ -3125,9 +3127,9 @@ extension on TranslationsEs {
'downloads.serverErrorBitrate' => 'Error del servidor: el archivo puede superar el límite de bitrate remoto',
'downloads.episodesQueued' => ({required Object count}) => '${count} episodios en cola para descargar',
'downloads.downloadDeleted' => 'Descarga eliminada',
'downloads.deleteConfirm' => ({required Object title}) => '¿Eliminar "${title}" de este dispositivo?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => '¿Eliminar "${title}" de este dispositivo?',
'downloads.cancelledDownloadTitle' => 'Descarga cancelada',
'downloads.cancelledDownloadMessage' => 'Esta descarga se canceló. ¿Qué quieres hacer?',
'downloads.allEpisodesAlreadyDownloaded' => 'Todos los episodios ya están descargados',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesFr extends TranslationsLibrariesFilt
@override String get contentRating => 'Classification';
@override String get tag => 'Tag';
@override String get unwatched => 'Non vus';
@override String get unplayed => 'Non lu';
@override String get favorites => 'Favoris';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsFr {
'libraries.filterCategories.contentRating' => 'Classification',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Non vus',
'libraries.filterCategories.unplayed' => 'Non lu',
'libraries.filterCategories.favorites' => 'Favoris',
'libraries.sortLabels.title' => 'Titre',
'libraries.sortLabels.dateAdded' => 'Date d\'ajout',
@@ -3125,9 +3127,9 @@ extension on TranslationsFr {
'downloads.serverErrorBitrate' => 'Erreur serveur : le fichier peut dépasser la limite de bitrate distant',
'downloads.episodesQueued' => ({required Object count}) => '${count} épisodes en attente de téléchargement',
'downloads.downloadDeleted' => 'Télécharger supprimé',
'downloads.deleteConfirm' => ({required Object title}) => 'Supprimer "${title}" de cet appareil ?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Supprimer "${title}" de cet appareil ?',
'downloads.cancelledDownloadTitle' => 'Téléchargement annulé',
'downloads.cancelledDownloadMessage' => 'Ce téléchargement a été annulé. Que voulez-vous faire ?',
'downloads.allEpisodesAlreadyDownloaded' => 'Tous les épisodes sont déjà téléchargés',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesIt extends TranslationsLibrariesFilt
@override String get contentRating => 'Classificazione';
@override String get tag => 'Tag';
@override String get unwatched => 'Non visti';
@override String get unplayed => 'Non riprodotto';
@override String get favorites => 'Preferiti';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsIt {
'libraries.filterCategories.contentRating' => 'Classificazione',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Non visti',
'libraries.filterCategories.unplayed' => 'Non riprodotto',
'libraries.filterCategories.favorites' => 'Preferiti',
'libraries.sortLabels.title' => 'Titolo',
'libraries.sortLabels.dateAdded' => 'Data di aggiunta',
@@ -3125,9 +3127,9 @@ extension on TranslationsIt {
'downloads.serverErrorBitrate' => 'Errore server: il file può superare il limite di bitrate remoto',
'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download',
'downloads.downloadDeleted' => 'Download eliminato',
'downloads.deleteConfirm' => ({required Object title}) => 'Eliminare "${title}" da questo dispositivo?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Eliminare "${title}" da questo dispositivo?',
'downloads.cancelledDownloadTitle' => 'Download annullato',
'downloads.cancelledDownloadMessage' => 'Questo download è stato annullato. Cosa vuoi fare?',
'downloads.allEpisodesAlreadyDownloaded' => 'Tutti gli episodi sono già stati scaricati',
+3 -1
View File
@@ -1865,6 +1865,7 @@ class _TranslationsLibrariesFilterCategoriesJa extends TranslationsLibrariesFilt
@override String get contentRating => '視聴年齢区分';
@override String get tag => 'タグ';
@override String get unwatched => '未視聴';
@override String get unplayed => '未再生';
@override String get favorites => 'お気に入り';
}
@@ -2831,6 +2832,7 @@ extension on TranslationsJa {
'libraries.filterCategories.contentRating' => '視聴年齢区分',
'libraries.filterCategories.tag' => 'タグ',
'libraries.filterCategories.unwatched' => '未視聴',
'libraries.filterCategories.unplayed' => '未再生',
'libraries.filterCategories.favorites' => 'お気に入り',
'libraries.sortLabels.title' => 'タイトル',
'libraries.sortLabels.dateAdded' => '追加日',
@@ -3123,9 +3125,9 @@ extension on TranslationsJa {
'downloads.serverErrorBitrate' => 'サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります',
'downloads.episodesQueued' => ({required Object count}) => '${count}エピソードをダウンロードキューに追加しました',
'downloads.downloadDeleted' => 'ダウンロードを削除しました',
'downloads.deleteConfirm' => ({required Object title}) => 'このデバイスから「${title}」を削除しますか?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'このデバイスから「${title}」を削除しますか?',
'downloads.cancelledDownloadTitle' => 'キャンセルされたダウンロード',
'downloads.cancelledDownloadMessage' => 'このダウンロードはキャンセルされました。どうしますか?',
'downloads.allEpisodesAlreadyDownloaded' => 'すべてのエピソードはすでにダウンロード済みです',
+3 -1
View File
@@ -1865,6 +1865,7 @@ class _TranslationsLibrariesFilterCategoriesKo extends TranslationsLibrariesFilt
@override String get contentRating => '시청 등급';
@override String get tag => '태그';
@override String get unwatched => '미시청';
@override String get unplayed => '재생하지 않음';
@override String get favorites => '즐겨찾기';
}
@@ -2831,6 +2832,7 @@ extension on TranslationsKo {
'libraries.filterCategories.contentRating' => '시청 등급',
'libraries.filterCategories.tag' => '태그',
'libraries.filterCategories.unwatched' => '미시청',
'libraries.filterCategories.unplayed' => '재생하지 않음',
'libraries.filterCategories.favorites' => '즐겨찾기',
'libraries.sortLabels.title' => '제목',
'libraries.sortLabels.dateAdded' => '추가된 날짜',
@@ -3123,9 +3125,9 @@ extension on TranslationsKo {
'downloads.serverErrorBitrate' => '서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다',
'downloads.episodesQueued' => ({required Object count}) => '${count} 에피소드가 다운로드 대기열에 추가 되었습니다',
'downloads.downloadDeleted' => '다운로드 삭제됨',
'downloads.deleteConfirm' => ({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?',
'downloads.cancelledDownloadTitle' => '취소된 다운로드',
'downloads.cancelledDownloadMessage' => '이 다운로드가 취소되었습니다. 어떻게 하시겠습니까?',
'downloads.allEpisodesAlreadyDownloaded' => '모든 에피소드가 이미 다운로드되었습니다',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesNb extends TranslationsLibrariesFilt
@override String get contentRating => 'Aldersgrense';
@override String get tag => 'Tag';
@override String get unwatched => 'Usette';
@override String get unplayed => 'Ikke avspilt';
@override String get favorites => 'Favoritter';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsNb {
'libraries.filterCategories.contentRating' => 'Aldersgrense',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Usette',
'libraries.filterCategories.unplayed' => 'Ikke avspilt',
'libraries.filterCategories.favorites' => 'Favoritter',
'libraries.sortLabels.title' => 'Tittel',
'libraries.sortLabels.dateAdded' => 'Lagt til-dato',
@@ -3125,9 +3127,9 @@ extension on TranslationsNb {
'downloads.serverErrorBitrate' => 'Serverfeil: filen kan overskride grensen for ekstern bitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i nedlastingskø',
'downloads.downloadDeleted' => 'Nedlasting slettet',
'downloads.deleteConfirm' => ({required Object title}) => 'Slette "${title}" fra denne enheten?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Slette "${title}" fra denne enheten?',
'downloads.cancelledDownloadTitle' => 'Avbrutt nedlasting',
'downloads.cancelledDownloadMessage' => 'Denne nedlastingen ble avbrutt. Hva vil du gjøre?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle episoder er allerede lastet ned',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesNl extends TranslationsLibrariesFilt
@override String get contentRating => 'Leeftijdsclassificatie';
@override String get tag => 'Tag';
@override String get unwatched => 'Onbekeken';
@override String get unplayed => 'Niet afgespeeld';
@override String get favorites => 'Favorieten';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsNl {
'libraries.filterCategories.contentRating' => 'Leeftijdsclassificatie',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Onbekeken',
'libraries.filterCategories.unplayed' => 'Niet afgespeeld',
'libraries.filterCategories.favorites' => 'Favorieten',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Toegevoegd op',
@@ -3125,9 +3127,9 @@ extension on TranslationsNl {
'downloads.serverErrorBitrate' => 'Serverfout: bestand overschrijdt mogelijk de externe bitrate-limiet',
'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download',
'downloads.downloadDeleted' => 'Download verwijderd',
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" van dit apparaat verwijderen?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" van dit apparaat verwijderen?',
'downloads.cancelledDownloadTitle' => 'Geannuleerde download',
'downloads.cancelledDownloadMessage' => 'Deze download is geannuleerd. Wat wil je doen?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle afleveringen zijn al gedownload',
+3 -1
View File
@@ -1871,6 +1871,7 @@ class _TranslationsLibrariesFilterCategoriesPl extends TranslationsLibrariesFilt
@override String get contentRating => 'Klasyfikacja wiekowa';
@override String get tag => 'Tag';
@override String get unwatched => 'Nieobejrzane';
@override String get unplayed => 'Nieodtworzone';
@override String get favorites => 'Ulubione';
}
@@ -2837,6 +2838,7 @@ extension on TranslationsPl {
'libraries.filterCategories.contentRating' => 'Klasyfikacja wiekowa',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Nieobejrzane',
'libraries.filterCategories.unplayed' => 'Nieodtworzone',
'libraries.filterCategories.favorites' => 'Ulubione',
'libraries.sortLabels.title' => 'Tytuł',
'libraries.sortLabels.dateAdded' => 'Data dodania',
@@ -3129,9 +3131,9 @@ extension on TranslationsPl {
'downloads.serverErrorBitrate' => 'Błąd serwera: plik może przekraczać zdalny limit bitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} odcinków w kolejce pobierania',
'downloads.downloadDeleted' => 'Pobranie usunięte',
'downloads.deleteConfirm' => ({required Object title}) => 'Usunąć "${title}" z tego urządzenia?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Usunąć "${title}" z tego urządzenia?',
'downloads.cancelledDownloadTitle' => 'Anulowane pobieranie',
'downloads.cancelledDownloadMessage' => 'To pobieranie zostało anulowane. Co chcesz zrobić?',
'downloads.allEpisodesAlreadyDownloaded' => 'Wszystkie odcinki są już pobrane',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesPt extends TranslationsLibrariesFilt
@override String get contentRating => 'Classificação';
@override String get tag => 'Tag';
@override String get unwatched => 'Não assistidos';
@override String get unplayed => 'Não reproduzido';
@override String get favorites => 'Favoritos';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsPt {
'libraries.filterCategories.contentRating' => 'Classificação',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Não assistidos',
'libraries.filterCategories.unplayed' => 'Não reproduzido',
'libraries.filterCategories.favorites' => 'Favoritos',
'libraries.sortLabels.title' => 'Título',
'libraries.sortLabels.dateAdded' => 'Data de adição',
@@ -3125,9 +3127,9 @@ extension on TranslationsPt {
'downloads.serverErrorBitrate' => 'Erro do servidor: o arquivo pode exceder o limite remoto de bitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} episódios na fila de download',
'downloads.downloadDeleted' => 'Download excluído',
'downloads.deleteConfirm' => ({required Object title}) => 'Excluir "${title}" deste dispositivo?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Excluir "${title}" deste dispositivo?',
'downloads.cancelledDownloadTitle' => 'Download cancelado',
'downloads.cancelledDownloadMessage' => 'Este download foi cancelado. O que você deseja fazer?',
'downloads.allEpisodesAlreadyDownloaded' => 'Todos os episódios já foram baixados',
+3 -1
View File
@@ -1871,6 +1871,7 @@ class _TranslationsLibrariesFilterCategoriesRu extends TranslationsLibrariesFilt
@override String get contentRating => 'Возрастной рейтинг';
@override String get tag => 'Тег';
@override String get unwatched => 'Непросмотренные';
@override String get unplayed => 'Не прослушано';
@override String get favorites => 'Избранное';
}
@@ -2837,6 +2838,7 @@ extension on TranslationsRu {
'libraries.filterCategories.contentRating' => 'Возрастной рейтинг',
'libraries.filterCategories.tag' => 'Тег',
'libraries.filterCategories.unwatched' => 'Непросмотренные',
'libraries.filterCategories.unplayed' => 'Не прослушано',
'libraries.filterCategories.favorites' => 'Избранное',
'libraries.sortLabels.title' => 'Название',
'libraries.sortLabels.dateAdded' => 'Дата добавления',
@@ -3129,9 +3131,9 @@ extension on TranslationsRu {
'downloads.serverErrorBitrate' => 'Ошибка сервера: файл может превышать удаленный лимит bitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} эпизодов поставлено в очередь загрузки',
'downloads.downloadDeleted' => 'Загрузка удалена',
'downloads.deleteConfirm' => ({required Object title}) => 'Удалить "${title}" с этого устройства?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Удалить "${title}" с этого устройства?',
'downloads.cancelledDownloadTitle' => 'Загрузка отменена',
'downloads.cancelledDownloadMessage' => 'Эта загрузка была отменена. Что вы хотите сделать?',
'downloads.allEpisodesAlreadyDownloaded' => 'Все эпизоды уже загружены',
+3 -1
View File
@@ -1867,6 +1867,7 @@ class _TranslationsLibrariesFilterCategoriesSv extends TranslationsLibrariesFilt
@override String get contentRating => 'Åldersgräns';
@override String get tag => 'Tagg';
@override String get unwatched => 'Osedda';
@override String get unplayed => 'Ospelat';
@override String get favorites => 'Favoriter';
}
@@ -2833,6 +2834,7 @@ extension on TranslationsSv {
'libraries.filterCategories.contentRating' => 'Åldersgräns',
'libraries.filterCategories.tag' => 'Tagg',
'libraries.filterCategories.unwatched' => 'Osedda',
'libraries.filterCategories.unplayed' => 'Ospelat',
'libraries.filterCategories.favorites' => 'Favoriter',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Tillagd',
@@ -3125,9 +3127,9 @@ extension on TranslationsSv {
'downloads.serverErrorBitrate' => 'Serverfel: filen kan överskrida gränsen för fjärrbitrate',
'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning',
'downloads.downloadDeleted' => 'Nedladdning borttagen',
'downloads.deleteConfirm' => ({required Object title}) => 'Ta bort "${title}" från den här enheten?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => 'Ta bort "${title}" från den här enheten?',
'downloads.cancelledDownloadTitle' => 'Avbruten nedladdning',
'downloads.cancelledDownloadMessage' => 'Den här nedladdningen avbröts. Vad vill du göra?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alla avsnitt är redan nedladdade',
+3 -1
View File
@@ -1865,6 +1865,7 @@ class _TranslationsLibrariesFilterCategoriesZh extends TranslationsLibrariesFilt
@override String get contentRating => '内容分级';
@override String get tag => '标签';
@override String get unwatched => '未观看';
@override String get unplayed => '未播放';
@override String get favorites => '收藏夹';
}
@@ -2831,6 +2832,7 @@ extension on TranslationsZh {
'libraries.filterCategories.contentRating' => '内容分级',
'libraries.filterCategories.tag' => '标签',
'libraries.filterCategories.unwatched' => '未观看',
'libraries.filterCategories.unplayed' => '未播放',
'libraries.filterCategories.favorites' => '收藏夹',
'libraries.sortLabels.title' => '标题',
'libraries.sortLabels.dateAdded' => '添加日期',
@@ -3123,9 +3125,9 @@ extension on TranslationsZh {
'downloads.serverErrorBitrate' => '服务器错误:文件可能超过远程比特率限制',
'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列',
'downloads.downloadDeleted' => '下载已删除',
'downloads.deleteConfirm' => ({required Object title}) => '要从此设备删除“${title}”吗?',
_ => null,
} ?? switch (path) {
'downloads.deleteConfirm' => ({required Object title}) => '要从此设备删除“${title}”吗?',
'downloads.cancelledDownloadTitle' => '已取消的下载',
'downloads.cancelledDownloadMessage' => '此下载已取消。你想怎么做?',
'downloads.allEpisodesAlreadyDownloaded' => '所有剧集均已下载',
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "Åldersgräns",
"tag": "Tagg",
"unwatched": "Osedda",
"unplayed": "Ospelat",
"favorites": "Favoriter"
},
"sortLabels": {
+1
View File
@@ -785,6 +785,7 @@
"contentRating": "内容分级",
"tag": "标签",
"unwatched": "未观看",
"unplayed": "未播放",
"favorites": "收藏夹"
},
"sortLabels": {
+8 -2
View File
@@ -567,8 +567,14 @@ sealed class MediaItem with _$MediaItem {
/// Secondary poster path to try when [posterThumb] returns an image URL that
/// exists syntactically but the server cannot serve it.
String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null;
final fallback = grandparentThumbPath ?? thumbPath;
final String? fallback;
if (kind == MediaKind.track) {
fallback = parentThumbPath;
} else if (kind == MediaKind.episode && mode == EpisodePosterMode.seasonPoster) {
fallback = grandparentThumbPath ?? thumbPath;
} else {
return null;
}
return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null;
}
+4 -2
View File
@@ -133,8 +133,10 @@ abstract class MediaServerClient {
/// up-front. Plex returns categories without values (the FiltersBottomSheet
/// fetches values lazily per category); Jellyfin returns both in a single
/// `/Items/Filters` call and pre-populates [LibraryFilterResult.cachedValues].
/// Backends that have no filter listing return [LibraryFilterResult.empty].
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId);
/// [libraryKind] lets backends use media-appropriate labels for synthetic
/// filters. Backends that have no filter listing return
/// [LibraryFilterResult.empty].
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind});
/// Backend-aware sort options for [libraryId]. Plex hits
/// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list
@@ -8,8 +8,25 @@ class SliverErrorState extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final String? retryLabel;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const SliverErrorState({super.key, required this.message, this.onRetry, this.retryLabel});
const SliverErrorState({
super.key,
required this.message,
this.onRetry,
this.retryLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
Widget build(BuildContext context) => SliverFillRemaining(
@@ -18,6 +35,12 @@ class SliverErrorState extends StatelessWidget {
icon: Symbols.error_outline_rounded,
onRetry: onRetry,
retryLabel: retryLabel ?? t.common.retry,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
),
);
}
@@ -30,6 +53,12 @@ class SliverEmptyState extends StatelessWidget {
final VoidCallback? onAction;
final String? actionLabel;
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const SliverEmptyState({
super.key,
@@ -39,6 +68,12 @@ class SliverEmptyState extends StatelessWidget {
this.onAction,
this.actionLabel,
this.actionIcon,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
@@ -50,6 +85,12 @@ class SliverEmptyState extends StatelessWidget {
onAction: onAction,
actionLabel: actionLabel,
actionIcon: actionIcon,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
),
);
}
+16 -7
View File
@@ -25,13 +25,15 @@ import '../../theme/mono_tokens.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/loading_indicator_box.dart';
/// Individual item in the folder tree
/// Can be either a folder (expandable) or a file (tappable)
/// Individual item in the folder tree. [isExpandable] controls hierarchy
/// behavior independently from [isFolder], which identifies plain directory
/// rows and their folder-specific visuals/actions.
class FolderTreeItem extends StatefulWidget {
final MediaItem item;
final int depth;
final bool isExpanded;
final bool isFolder;
final bool isExpandable;
final VoidCallback? onTap;
final VoidCallback? onExpand;
final VoidCallback? onPlayAll;
@@ -50,6 +52,7 @@ class FolderTreeItem extends StatefulWidget {
required this.depth,
this.isExpanded = false,
this.isFolder = false,
this.isExpandable = false,
this.onTap,
this.onExpand,
this.onPlayAll,
@@ -69,10 +72,8 @@ class FolderTreeItem extends StatefulWidget {
class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixin {
/// Whether the row is a real media item that gets the standard media
/// context menu. Jellyfin series/seasons act as expandable folders in the
/// tree but are still real media items.
bool get _isMediaRow =>
!widget.isFolder || widget.item.kind == MediaKind.show || widget.item.kind == MediaKind.season;
/// presentation and context menu, even when it is also expandable.
bool get _isMediaRow => !widget.isFolder;
/// Plain folders only offer the Play/Shuffle actions of their trailing buttons.
bool get _hasFolderMenu => !_isMediaRow && (widget.onPlayAll != null || widget.onShuffle != null);
@@ -109,7 +110,7 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
}
void _handleTap() {
if (widget.isFolder) {
if (widget.isExpandable) {
widget.onExpand?.call();
} else {
widget.onTap?.call();
@@ -259,6 +260,7 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount);
final expandIcon = widget.isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded;
final isWide = widget.item.usesWideAspectRatio(episodePosterMode);
final thumbWidth = isWide ? 130.0 : 53.0;
@@ -272,6 +274,13 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
child: Row(
crossAxisAlignment: .center,
children: [
if (widget.isExpandable) ...[
SizedBox(
width: 24,
child: widget.isLoading ? const LoadingIndicatorBox(size: 16) : AppIcon(expandIcon, fill: 1, size: 20),
),
const SizedBox(width: 8),
],
// Thumbnail with progress overlay
SizedBox(
width: thumbWidth,
+24 -12
View File
@@ -26,6 +26,7 @@ class FolderTreeView extends StatefulWidget {
final FocusNode? firstItemFocusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
const FolderTreeView({
super.key,
@@ -36,6 +37,7 @@ class FolderTreeView extends StatefulWidget {
this.firstItemFocusNode,
this.onNavigateUp,
this.onNavigateLeft,
this.onBack,
});
@override
@@ -65,7 +67,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
/// Stable expand/cache key for an expandable row: the backend folder key
/// where one exists (Plex `/folder` rows), the item id otherwise.
String? _folderIdentity(MediaItem item) {
if (!_isFolder(item)) return null;
if (!_isExpandable(item)) return null;
return item.backendFolderKey ?? item.id;
}
@@ -278,14 +280,18 @@ class FolderTreeViewState extends State<FolderTreeView> {
);
}
/// Expandable rows: directory rows (classified as [MediaKind.folder] by the
/// backend's folder fetchers) plus Jellyfin shows/seasons, which surface as
/// expandable media containers in folder browsing.
bool _isFolder(MediaItem item) {
/// Expandable rows: directory rows plus Jellyfin media containers whose
/// direct children form the folder tree. Music libraries expose folder-
/// backed artists and albums as MusicArtist/MusicAlbum rather than generic
/// Folder DTOs, so those rows must expand instead of opening empty details.
bool _isExpandable(MediaItem item) {
return item.kind == MediaKind.folder || (item.backend == MediaBackend.jellyfin && _isJellyfinMediaContainer(item));
}
bool _isJellyfinMediaContainer(MediaItem item) => item.kind == MediaKind.show || item.kind == MediaKind.season;
bool _isJellyfinMediaContainer(MediaItem item) {
if (item.kind == MediaKind.show || item.kind == MediaKind.season) return true;
return widget.libraryKind?.isMusic == true && (item.kind == MediaKind.artist || item.kind == MediaKind.album);
}
bool _canPlayFolder(MediaItem item) {
if (item.backend == MediaBackend.plex) return true;
@@ -324,7 +330,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
out.add((item: item, depth: depth, path: itemPath, parent: parent));
final folderKey = _folderIdentity(item);
if (_isFolder(item) &&
if (_isExpandable(item) &&
folderKey != null &&
_expandedFolders.contains(folderKey) &&
_childrenCache.containsKey(folderKey)) {
@@ -347,6 +353,10 @@ class FolderTreeViewState extends State<FolderTreeView> {
icon: Symbols.error_outline_rounded,
onRetry: _loadRootFolders,
retryLabel: t.common.retry,
actionFocusNode: widget.firstItemFocusNode,
onActionNavigateUp: widget.onNavigateUp,
onActionNavigateLeft: widget.onNavigateLeft,
onActionBack: widget.onBack,
),
);
}
@@ -368,12 +378,13 @@ class FolderTreeViewState extends State<FolderTreeView> {
itemBuilder: (context, index) {
final entry = flattened[index];
final item = entry.item;
final isFolder = _isFolder(item);
final isExpandable = _isExpandable(item);
final isPlainFolder = item.kind == MediaKind.folder;
final folderKey = _folderIdentity(item);
final isExpanded = folderKey != null && _expandedFolders.contains(folderKey);
final isLoading = folderKey != null && _loadingFolders.contains(folderKey);
final isFirstRootItem = index == 0;
final canPlayFolder = isFolder && _canPlayFolder(item);
final canPlayFolder = isPlainFolder && _canPlayFolder(item);
return FolderTreeItem(
// Path alone isn't unique enough as identity (the same Plex item
@@ -381,12 +392,13 @@ class FolderTreeViewState extends State<FolderTreeView> {
key: ValueKey('${entry.path}:${item.id}'),
item: item,
depth: entry.depth,
isFolder: isFolder,
isFolder: isPlainFolder,
isExpandable: isExpandable,
isExpanded: isExpanded,
isLoading: isLoading,
serverId: widget.serverId,
onExpand: isFolder ? () => _toggleFolder(item) : null,
onTap: !isFolder ? () => _handleItemTap(item) : null,
onExpand: isExpandable ? () => _toggleFolder(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item) : null,
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
@@ -37,7 +37,7 @@ class LibraryFilterSortLoader {
Future<LoadedFiltersAndSorts> load(MediaLibrary library, {String? sortLibraryType}) async {
final client = clientFor(library);
final results = await Future.wait([
client.fetchLibraryFiltersWithValues(library.id),
client.fetchLibraryFiltersWithValues(library.id, libraryKind: library.kind),
client.fetchSortOptions(library.id, libraryType: sortLibraryType ?? library.kind.id),
]);
final filterResult = results.first as LibraryFilterResult;
+61 -1
View File
@@ -36,6 +36,12 @@ class StateMessageWidget extends StatelessWidget {
/// Optional icon for the action button
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const StateMessageWidget({
super.key,
@@ -48,6 +54,12 @@ class StateMessageWidget extends StatelessWidget {
this.subtitleColor,
this.onAction,
this.actionLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
this.actionIcon,
});
@@ -87,6 +99,12 @@ class StateMessageWidget extends StatelessWidget {
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FocusableButton(
focusNode: actionFocusNode,
onNavigateUp: onActionNavigateUp,
onNavigateDown: onActionNavigateDown,
onNavigateLeft: onActionNavigateLeft,
onNavigateRight: onActionNavigateRight,
onBack: onActionBack,
onPressed: onAction,
child: FilledButton.icon(
onPressed: onAction,
@@ -124,6 +142,12 @@ class EmptyStateWidget extends StatelessWidget {
/// Optional icon for the action button (defaults to a generic add icon)
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const EmptyStateWidget({
super.key,
@@ -134,6 +158,12 @@ class EmptyStateWidget extends StatelessWidget {
this.onAction,
this.actionLabel,
this.actionIcon,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
@@ -146,6 +176,12 @@ class EmptyStateWidget extends StatelessWidget {
onAction: onAction,
actionLabel: actionLabel,
actionIcon: actionIcon ?? Symbols.add_rounded,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
);
}
}
@@ -163,8 +199,26 @@ class ErrorStateWidget extends StatelessWidget {
/// Optional label for the retry button
final String? retryLabel;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const ErrorStateWidget({super.key, required this.message, this.icon, this.onRetry, this.retryLabel});
const ErrorStateWidget({
super.key,
required this.message,
this.icon,
this.onRetry,
this.retryLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
Widget build(BuildContext context) {
@@ -176,6 +230,12 @@ class ErrorStateWidget extends StatelessWidget {
onAction: onRetry,
actionLabel: retryLabel ?? 'Retry',
actionIcon: Symbols.refresh_rounded,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
);
}
}
@@ -426,6 +426,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override
Widget buildContent(List<MediaItem> items) => const SizedBox.shrink();
bool get _hasFocusableStateAction =>
(errorMessage != null && loadedItems.isEmpty) || (totalSize == 0 && !isLoading && _selectedFilters.isNotEmpty);
/// Focus the first item in the grid/list/folder tree (for tab activation)
@override
void focusFirstItem() {
@@ -442,11 +445,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return;
}
if (loadedItems.isNotEmpty) {
if (loadedItems.isNotEmpty || _hasFocusableStateAction) {
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
if (mounted && (loadedItems.isNotEmpty || _hasFocusableStateAction) && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -457,7 +460,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
@override
bool get hasFocusableContent => _selectedGrouping == 'folders' || loadedItems.isNotEmpty;
bool get hasFocusableContent => _selectedGrouping == 'folders' || loadedItems.isNotEmpty || _hasFocusableStateAction;
@override
void focusContentOrChrome() {
@@ -610,7 +613,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final client = context.getMediaClientForLibrary(widget.library);
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id)
.fetchLibraryFiltersWithValues(widget.library.id, libraryKind: widget.library.kind)
.then((result) {
if (generation != _contentRequestId || !mounted) return;
setState(() {
@@ -1113,10 +1116,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return;
}
if (totalSize == 0) return;
if (totalSize == 0 && !_hasFocusableStateAction) return;
final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
final targetIndex = totalSize == 0
? 0
: shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
? lastFocusedGridIndex!
: 0;
@@ -1748,6 +1752,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: _navigateToChips,
onNavigateLeft: _navigateToSidebar,
onBack: widget.onBack,
),
];
}
@@ -1757,7 +1762,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
if (errorMessage != null && loadedItems.isEmpty) {
return [SliverErrorState(message: errorMessage!, onRetry: _loadContent)];
return [
SliverErrorState(
message: errorMessage!,
onRetry: _loadContent,
actionFocusNode: firstItemFocusNode,
onActionNavigateUp: _navigateToChips,
onActionNavigateLeft: _navigateToSidebar,
onActionBack: widget.onBack,
),
];
}
if (totalSize == 0 && !isLoading) {
@@ -1769,6 +1783,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
onAction: _resetFilters,
actionLabel: t.libraries.resetFilters,
actionIcon: Symbols.clear_all_rounded,
actionFocusNode: firstItemFocusNode,
onActionNavigateUp: _navigateToChips,
onActionNavigateLeft: _navigateToSidebar,
onActionBack: widget.onBack,
),
];
}
@@ -1869,6 +1887,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_selectedGrouping == browseGroupingAlbums ||
_selectedGrouping == browseGroupingTracks;
final browseShape = isMusicGrouping ? CardShape.square : null;
// Full-bleed TV cards intentionally hide captions. Music artwork alone
// is not a reliable identity, so artist/album/track grids always keep the
// standard captioned card while preserving their circular/square artwork.
final useFullCardLayout = fullCardLayout && !isMusicGrouping;
if (viewMode == ViewMode.list) {
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio, shape: browseShape);
@@ -1882,7 +1904,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
useWideAspectRatio: useWideRatio,
shape: browseShape,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
crossAxisExtentForColumnCount: hasAlphaBarReservation
? (crossAxisExtent) => crossAxisExtent + (rightPadding - 8.0)
: null,
@@ -1899,7 +1921,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
ViewMode.grid,
geometry.columnCount,
itemCount,
fullCardLayout,
useFullCardLayout,
useWideRatio,
browseShape,
libraryDensity,
@@ -1951,7 +1973,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
isLastColumn: position.isLastColumn,
columnCount: position.columnCount,
itemCount: itemCount,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
),
);
},
+24 -14
View File
@@ -183,12 +183,20 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
LibraryQuery query, {
AbortController? abort,
}) async {
final translator = JellyfinLibraryQueryTranslator(
userId: connection.userId,
parentId: libraryId,
fields: _browseFields,
);
final fields = switch (query.kind) {
MediaKind.album => _musicAlbumRowFields,
MediaKind.track => _musicTrackRowFields,
_ => _browseFields,
};
final translator = JellyfinLibraryQueryTranslator(userId: connection.userId, parentId: libraryId, fields: fields);
final params = translator.toQueryParameters(query);
// MusicAlbum is a folder-like DTO. Asking for UserData makes Jellyfin
// compute recursive play state for every album, which is prohibitively
// expensive on large music libraries. The IsUnplayed query filter still
// works server-side when result DTO user data is disabled.
if (query.kind == MediaKind.album) {
params['EnableUserData'] = 'false';
}
// Artist browsing routes to `/Artists/AlbumArtists` instead of
// `/Items?IncludeItemTypes=MusicArtist`: the /Items query only returns
@@ -225,21 +233,23 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
/// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags /
/// Categories + values from `/Items/Filters` in a single call. The unwatched
/// boolean is synthetic because Jellyfin exposes it as an `/Items` query
/// filter, not a filter-listing category. Keys are translated to Plex's
/// filter naming so the existing filter-param map round-trips through
/// `_buildFilterParams` unchanged; the synthesised `MediaFilter.key` is
/// prefixed `jellyfin:` so FiltersBottomSheet can recognise it as cached and
/// skip the per-category value fetch.
/// Categories + values from `/Items/Filters` in a single call. The
/// unwatched/unplayed boolean is synthetic because Jellyfin exposes it as
/// an `/Items` query filter, not a filter-listing category. Keys are
/// translated to Plex's filter naming so the existing filter-param map
/// round-trips through `_buildFilterParams` unchanged; the synthesised
/// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can
/// recognise it as cached and skip the per-category value fetch.
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
final filters = <MediaFilter>[
MediaFilter(
filter: 'unwatched',
filterType: 'boolean',
key: 'jellyfin:unwatched',
title: t.libraries.filterCategories.unwatched,
title: libraryKind?.isMusic == true
? t.libraries.filterCategories.unplayed
: t.libraries.filterCategories.unwatched,
type: 'filter',
),
MediaFilter(
@@ -23,7 +23,8 @@ mixin _JellyfinMusicMethods on MediaServerCacheMixin {
'Recursive': 'true',
'SortBy': 'PremiereDate,ProductionYear,SortName',
'SortOrder': 'Descending',
'Fields': _browseFields,
'Fields': _musicAlbumRowFields,
'EnableUserData': 'false',
...jellyfinImageQueryParameters,
},
);
@@ -45,7 +46,7 @@ mixin _JellyfinMusicMethods on MediaServerCacheMixin {
'Recursive': 'true',
'SortBy': 'ParentIndexNumber,IndexNumber,SortName',
'SortOrder': 'Ascending',
'Fields': _browseFields,
'Fields': _musicTrackRowFields,
...jellyfinImageQueryParameters,
},
);
+9 -9
View File
@@ -156,6 +156,7 @@ class JellyfinMappers {
// Folder/CollectionFolder rows resolve via fromString) classify as
// folders so folder browsing never falls back to raw-map sniffing.
final kind = type == null && item['IsFolder'] == true ? MediaKind.folder : MediaKind.fromString(type);
final albumPrimaryImage = kind == MediaKind.track ? _albumPrimaryImage(item) : null;
final mapped = JellyfinMediaItem(
id: id,
@@ -188,7 +189,7 @@ class JellyfinMappers {
item['SeasonName'] as String? ??
item['Album'] as String? ??
(kind == MediaKind.album ? item['AlbumArtist'] as String? : null),
parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary'),
parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary') ?? albumPrimaryImage,
parentIndex: item['ParentIndexNumber'] as int?,
index: item['IndexNumber'] as int?,
grandparentId: item['SeriesId'] as String? ?? (kind == MediaKind.track ? _firstAlbumArtistId(item) : null),
@@ -196,7 +197,7 @@ class JellyfinMappers {
item['SeriesName'] as String? ?? (kind == MediaKind.track ? item['AlbumArtist'] as String? : null),
grandparentThumbPath: _seriesPrimaryImage(item),
grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item),
thumbPath: _selfImagePath(id, item, 'Primary') ?? _albumPrimaryImage(item),
thumbPath: _selfImagePath(id, item, 'Primary') ?? albumPrimaryImage,
artPath: _selfImagePath(id, item, 'Backdrop'),
// Episodes/seasons don't carry their own logo — Jellyfin exposes the
// parent's logo via ParentLogoItemId/ParentLogoImageTag, which is
@@ -511,15 +512,14 @@ class JellyfinMappers {
return joined == item['AlbumArtist'] as String? ? null : joined;
}
/// Album cover fallback for tracks without embedded art. Requires the
/// `AlbumPrimaryImageTag` — its presence is Jellyfin's signal that the
/// album actually has a primary image, so we never emit a 404-ing URL as
/// an item's main thumb.
/// Album-cover fallback for Audio rows. Jellyfin normally supplies
/// `AlbumPrimaryImageTag`, but the image endpoint does not require it and
/// older/incompletely scanned libraries can still serve a primary image by
/// `AlbumId`. Keep the tag when present for cache invalidation.
static String? _albumPrimaryImage(Map<String, dynamic> item) {
final albumId = item['AlbumId'] as String?;
final tag = item['AlbumPrimaryImageTag'] as String?;
if (albumId == null || tag == null) return null;
return _itemImagePath(albumId, 'Primary', tag: tag);
if (albumId == null || albumId.isEmpty) return null;
return _itemImagePath(albumId, 'Primary', tag: item['AlbumPrimaryImageTag'] as String?);
}
static String? _seriesPrimaryImage(Map<String, dynamic> item) {
+1 -1
View File
@@ -3225,7 +3225,7 @@ class PlexClient
/// when the user opens a filter. The result has empty [LibraryFilterResult.cachedValues];
/// the FiltersBottomSheet hits the per-category endpoint on demand.
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
final filters = await getLibraryFilters(libraryId);
return LibraryFilterResult(filters: filters, cachedValues: const {});
}
+15
View File
@@ -894,6 +894,21 @@ Widget _buildPosterImage(
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
errorWidget: posterFallbackUrl == null || useRememberedFallback
? null
: (_, _, _) {
_rememberFailedPosterUrl(primaryPosterUrl);
return OptimizedMediaImage(
client: mediaClient,
imagePath: posterFallbackUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
imageType: ImageType.square,
);
},
imageType: ImageType.square,
localFilePath: localPosterPath,
);