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,
);
@@ -0,0 +1,103 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/folder_tree_view.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/backend_client_fixtures.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
testWidgets('Jellyfin music artists and albums expand through direct children', (tester) async {
final client = JellyfinClient.forTesting(
connection: testJellyfinConnection(machineId: 'music-server'),
httpClient: MockClient((request) async {
final parentId = request.url.queryParameters['ParentId'];
final foldersOnly = request.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder';
final items = foldersOnly
? const <Map<String, Object?>>[]
: switch (parentId) {
'music-library' => const [
{'Id': 'artist-1', 'Name': 'Artist One', 'Type': 'MusicArtist', 'IsFolder': true},
],
'artist-1' => const [
{
'Id': 'album-1',
'Name': 'Album One',
'Type': 'MusicAlbum',
'IsFolder': true,
'AlbumArtists': [
{'Id': 'artist-1', 'Name': 'Artist One'},
],
},
],
'album-1' => const [
{'Id': 'track-1', 'Name': 'Track One', 'Type': 'Audio', 'AlbumId': 'album-1', 'Album': 'Album One'},
],
_ => const <Map<String, Object?>>[],
};
return http.Response(
jsonEncode({'Items': items, 'TotalRecordCount': items.length}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(() {
provider.dispose();
manager.dispose();
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: CustomScrollView(
slivers: const [
FolderTreeView(libraryKey: 'music-library', serverId: 'music-server', libraryKind: MediaKind.artist),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Artist One'), findsOneWidget);
expect(find.text('Album One'), findsNothing);
await tester.tap(find.text('Artist One'));
await tester.pumpAndSettle();
expect(find.text('Album One'), findsOneWidget);
expect(find.text('Track One'), findsNothing);
await tester.tap(find.text('Album One'));
await tester.pumpAndSettle();
expect(find.text('Track One'), findsOneWidget);
});
}
@@ -0,0 +1,193 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_filter_chip.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/backend_client_fixtures.dart';
import '../../test_helpers/prefs.dart';
final _musicLibrary = MediaLibrary(
id: 'music-library',
backend: MediaBackend.jellyfin,
title: 'Music',
kind: MediaKind.artist,
serverId: ServerId('music-server'),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
await StorageService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('TV full-card preference keeps music browse captions visible', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
final harness = _MusicBrowseHarness();
addTearDown(harness.dispose);
await _pumpBrowseTab(tester, harness);
expect(harness.browseRequestCount, 1);
final card = tester.widget<MediaCard>(find.byType(MediaCard).first);
expect(card.fullBleedImage, isFalse);
expect(find.text('Artist One'), findsOneWidget);
expect(find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipOval)), findsOneWidget);
});
testWidgets('D-pad down focuses Retry and select reloads music browse', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final harness = _MusicBrowseHarness(failFirstBrowse: true);
addTearDown(harness.dispose);
await _pumpBrowseTab(tester, harness);
expect(find.byType(ErrorStateWidget), findsOneWidget);
expect(harness.browseRequestCount, 1);
final groupingChip = tester.widget<FocusableFilterChip>(find.byType(FocusableFilterChip).first);
groupingChip.focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
final retryFinder = find.descendant(of: find.byType(ErrorStateWidget), matching: find.byType(FocusableButton));
final retry = tester.widget<FocusableButton>(retryFinder);
expect(retry.focusNode, isNotNull);
expect(retry.focusNode!.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await _pumpRequestFrames(tester);
expect(harness.browseRequestCount, 2);
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.text('Artist One'), findsOneWidget);
});
}
Future<void> _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: harness.provider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
),
],
body: LibraryBrowseTab(
library: _musicLibrary,
canGroupByFolders: true,
suppressAutoFocus: true,
onBack: () {},
),
),
),
),
),
),
),
);
await _pumpRequestFrames(tester);
}
Future<void> _pumpRequestFrames(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 500));
}
class _MusicBrowseHarness {
final bool failFirstBrowse;
var browseRequestCount = 0;
late final JellyfinClient client;
late final MultiServerManager manager;
late final MultiServerProvider provider;
_MusicBrowseHarness({this.failFirstBrowse = false}) {
client = JellyfinClient.forTesting(
connection: testJellyfinConnection(machineId: 'music-server'),
httpClient: MockClient((request) async {
if (request.url.path == '/Items/Filters') {
return http.Response(
jsonEncode({'Genres': const [], 'OfficialRatings': const [], 'Tags': const [], 'Years': const []}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Artists/AlbumArtists') {
browseRequestCount++;
if (failFirstBrowse && browseRequestCount == 1) {
return http.Response('gateway timeout', 504);
}
return http.Response(
jsonEncode({
'Items': const [
{'Id': 'artist-1', 'Name': 'Artist One', 'Type': 'MusicArtist'},
],
'TotalRecordCount': 1,
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
}),
);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
}
void dispose() {
provider.dispose();
manager.dispose();
}
}
@@ -1754,6 +1754,40 @@ void main() {
expect(captured!.queryParameters['ImageTypeLimit'], '1');
});
test('music browse and detail requests use leaf-appropriate fields', () async {
final captured = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
captured.add(req.url);
return http.Response(
jsonEncode({'Items': const [], 'TotalRecordCount': 0}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.album, offset: 0, limit: 20));
await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.track, offset: 0, limit: 20));
await scoped.fetchArtistAlbums('artist-1');
await scoped.fetchAlbumTracks('album-1');
final albumBrowse = captured[0].queryParameters;
final trackBrowse = captured[1].queryParameters;
final artistAlbums = captured[2].queryParameters;
final albumTracks = captured[3].queryParameters;
expect(albumBrowse['Fields'], 'PremiereDate,OriginalTitle,SortName');
expect(albumBrowse['EnableUserData'], 'false');
expect(trackBrowse['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
expect(trackBrowse.containsKey('EnableUserData'), isFalse);
expect(artistAlbums['Fields'], 'PremiereDate,OriginalTitle,SortName');
expect(artistAlbums['EnableUserData'], 'false');
expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
});
test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async {
Uri? captured;
final scoped = JellyfinClient.forTesting(
@@ -1775,6 +1809,7 @@ void main() {
addTearDown(scoped.close);
final result = await scoped.fetchLibraryFiltersWithValues('lib-1');
final musicResult = await scoped.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.artist);
expect(captured, isNotNull);
expect(captured!.path, '/Items/Filters');
@@ -1791,6 +1826,7 @@ void main() {
expect(result.filters.first.filterType, 'boolean');
expect(result.filters.first.key, 'jellyfin:unwatched');
expect(result.filters.first.title, 'Unwatched');
expect(musicResult.filters.first.title, 'Unplayed');
expect(result.filters[1].filterType, 'boolean');
expect(result.filters[1].key, 'jellyfin:favorite');
expect(result.filters[1].title, 'Favorites');
@@ -99,6 +99,10 @@ void main() {
item.thumbPath,
'/Items/425f9ab168792a3be733d169b770853f/Images/Primary?tag=1ed1281fd45ff9b8b5ad62b6f6a34d17',
);
expect(
item.posterThumbFallback(),
'/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary?tag=233dccb8ad84d8ac473dbffb86c35e6c',
);
expect(item.durationMs, 12000);
});
@@ -129,14 +133,15 @@ void main() {
);
});
test('track without embedded art or album image tag keeps a null thumb', () {
test('track without image tags still tries the album primary endpoint', () {
final json = _audioJson()
..['ImageTags'] = <String, dynamic>{}
..remove('AlbumPrimaryImageTag');
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.thumbPath, isNull);
expect(item.thumbPath, '/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary');
expect(item.posterThumbFallback(), isNull);
});
test('maps a MusicAlbum with artist hierarchy and track counts', () {
+27
View File
@@ -138,6 +138,33 @@ void main() {
final imageBox = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
expect(tester.getSize(imageBox), Size(base, base));
});
testWidgets('track artwork failure falls back to album artwork', (tester) async {
const trackArtwork = 'https://media.example/track.jpg';
const albumArtwork = 'https://media.example/album.jpg';
final item = testMediaItem(
id: 'track-with-fallback',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
thumbPath: trackArtwork,
parentThumbPath: albumArtwork,
);
await tester.pumpWidget(
_TestApp(child: MediaCard(item: item, width: 200, height: 194, forceGridMode: true, isOffline: true)),
);
final primaryFinder = find.descendant(of: find.byType(MediaCard), matching: find.byType(OptimizedMediaImage)).first;
final primary = tester.widget<OptimizedMediaImage>(primaryFinder);
expect(primary.imagePath, trackArtwork);
expect(primary.errorWidget, isNotNull);
final fallback = primary.errorWidget!(tester.element(primaryFinder), trackArtwork, StateError('decode failed'));
expect(fallback, isA<OptimizedMediaImage>());
expect((fallback as OptimizedMediaImage).imagePath, albumArtwork);
expect(fallback.imageType, ImageType.square);
});
}
class _TestApp extends StatelessWidget {