diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index f1290242..e7ecae9f 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Възрастов рейтинг", "tag": "Таг", "unwatched": "Негледани", + "unplayed": "Непускани", "favorites": "Любими" }, "sortLabels": { diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index cf681e74..687fc452 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Aldersvurdering", "tag": "Tag", "unwatched": "Usete", + "unplayed": "Ikke afspillet", "favorites": "Favoritter" }, "sortLabels": { diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 8f8e5cae..29391f6b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Altersfreigabe", "tag": "Tag", "unwatched": "Ungesehene", + "unplayed": "Nicht abgespielt", "favorites": "Favoriten" }, "sortLabels": { diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 2731ec10..65161141 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -789,6 +789,7 @@ "contentRating": "Content Rating", "tag": "Tag", "unwatched": "Unwatched", + "unplayed": "Unplayed", "favorites": "Favorites" }, "sortLabels": { diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index d02f375f..1afcad2d 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Clasificación", "tag": "Etiqueta", "unwatched": "No vistos", + "unplayed": "No reproducido", "favorites": "Favoritos" }, "sortLabels": { diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 9462563a..f1e842bd 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Classification", "tag": "Tag", "unwatched": "Non vus", + "unplayed": "Non lu", "favorites": "Favoris" }, "sortLabels": { diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 3fef8d8c..93131218 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Classificazione", "tag": "Tag", "unwatched": "Non visti", + "unplayed": "Non riprodotto", "favorites": "Preferiti" }, "sortLabels": { diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 541a667a..06b5c901 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -785,6 +785,7 @@ "contentRating": "視聴年齢区分", "tag": "タグ", "unwatched": "未視聴", + "unplayed": "未再生", "favorites": "お気に入り" }, "sortLabels": { diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 2e37f6b6..7055b519 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -785,6 +785,7 @@ "contentRating": "시청 등급", "tag": "태그", "unwatched": "미시청", + "unplayed": "재생하지 않음", "favorites": "즐겨찾기" }, "sortLabels": { diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 223f5db7..2428a60d 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Aldersgrense", "tag": "Tag", "unwatched": "Usette", + "unplayed": "Ikke avspilt", "favorites": "Favoritter" }, "sortLabels": { diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 607558bd..d439d24f 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Leeftijdsclassificatie", "tag": "Tag", "unwatched": "Onbekeken", + "unplayed": "Niet afgespeeld", "favorites": "Favorieten" }, "sortLabels": { diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 77310427..83dd36ce 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Klasyfikacja wiekowa", "tag": "Tag", "unwatched": "Nieobejrzane", + "unplayed": "Nieodtworzone", "favorites": "Ulubione" }, "sortLabels": { diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index e3f325aa..fefb494e 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Classificação", "tag": "Tag", "unwatched": "Não assistidos", + "unplayed": "Não reproduzido", "favorites": "Favoritos" }, "sortLabels": { diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 66104840..63173b5b 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Возрастной рейтинг", "tag": "Тег", "unwatched": "Непросмотренные", + "unplayed": "Не прослушано", "favorites": "Избранное" }, "sortLabels": { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 2868ac1d..c10b6efd 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22524 (1407 per locale) +/// Strings: 22540 (1408 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index f7e7b7b0..9aeedcdc 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -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' => 'Всички епизоди вече са изтеглени', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index f16c7fda..b2ebde45 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index a1dffa70..b4841fa5 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index e0dfc90d..9734338b 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index d243c1bb..2ae9a708 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 4b0340cc..a92f5d8c 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 872f0900..b23e6e94 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 0100204a..09116585 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => 'すべてのエピソードはすでにダウンロード済みです', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 4b56b038..c37cc02b 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '모든 에피소드가 이미 다운로드되었습니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 871cc55f..7af00168 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index a5f60523..26752419 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index f5feb840..3695e16a 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index e7fa7395..705b6028 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 92d7fc48..7ecb42ef 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Все эпизоды уже загружены', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 35653441..1d9069a2 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 4b00d9aa..7100e476 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '所有剧集均已下载', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 616f7f31..eb4b8a59 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -785,6 +785,7 @@ "contentRating": "Åldersgräns", "tag": "Tagg", "unwatched": "Osedda", + "unplayed": "Ospelat", "favorites": "Favoriter" }, "sortLabels": { diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 212c44b0..11d9bb7b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -785,6 +785,7 @@ "contentRating": "内容分级", "tag": "标签", "unwatched": "未观看", + "unplayed": "未播放", "favorites": "收藏夹" }, "sortLabels": { diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 3ebf2495..afe10dca 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -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; } diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 3e50f567..5081c678 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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 fetchLibraryFiltersWithValues(String libraryId); + /// [libraryKind] lets backends use media-appropriate labels for synthetic + /// filters. Backends that have no filter listing return + /// [LibraryFilterResult.empty]. + Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}); /// Backend-aware sort options for [libraryId]. Plex hits /// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index c194ac02..23ac8d13 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -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, ), ); } diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index d0a50493..af21dd70 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -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 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 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 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 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, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index a16131b1..76ca64bb 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -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 { /// 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 { ); } - /// 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 { 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 { 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 { 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 { 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, diff --git a/lib/screens/libraries/library_filter_sort_loader.dart b/lib/screens/libraries/library_filter_sort_loader.dart index 8cd6dda6..9944c43c 100644 --- a/lib/screens/libraries/library_filter_sort_loader.dart +++ b/lib/screens/libraries/library_filter_sort_loader.dart @@ -37,7 +37,7 @@ class LibraryFilterSortLoader { Future 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; diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index 9f526da7..d97d1e41 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -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, ); } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 05a4af70..3b3a53f0 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -426,6 +426,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 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 _selectedGrouping == 'folders' || loadedItems.isNotEmpty; + bool get hasFocusableContent => _selectedGrouping == 'folders' || loadedItems.isNotEmpty || _hasFocusableStateAction; @override void focusContentOrChrome() { @@ -610,7 +613,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState crossAxisExtent + (rightPadding - 8.0) : null, @@ -1899,7 +1921,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _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 fetchLibraryFiltersWithValues(String libraryId) async { + Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async { final filters = [ 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( diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index 60a5825d..5ea64b4e 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -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, }, ); diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 32277159..f8b2fe6e 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -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 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 item) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 1b3fba62..bdb96708 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 fetchLibraryFiltersWithValues(String libraryId) async { + Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async { final filters = await getLibraryFilters(libraryId); return LibraryFilterResult(filters: filters, cachedValues: const {}); } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 7f744745..b531f83f 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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, ); diff --git a/test/screens/libraries/folder_tree_view_test.dart b/test/screens/libraries/folder_tree_view_test.dart new file mode 100644 index 00000000..491bbae7 --- /dev/null +++ b/test/screens/libraries/folder_tree_view_test.dart @@ -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 >[] + : 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 >[], + }; + 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.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); + }); +} diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart new file mode 100644 index 00000000..333410de --- /dev/null +++ b/test/screens/libraries/library_browse_music_test.dart @@ -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(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(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(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 _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.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 _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(); + } +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index db4c85b8..e8f751c0 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1754,6 +1754,40 @@ void main() { expect(captured!.queryParameters['ImageTypeLimit'], '1'); }); + test('music browse and detail requests use leaf-appropriate fields', () async { + final captured = []; + 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'); diff --git a/test/services/jellyfin_music_mapper_test.dart b/test/services/jellyfin_music_mapper_test.dart index 7b7f7dea..8e063397 100644 --- a/test/services/jellyfin_music_mapper_test.dart +++ b/test/services/jellyfin_music_mapper_test.dart @@ -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'] = {} ..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', () { diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart index eba6954c..15509f47 100644 --- a/test/widgets/media_card_square_test.dart +++ b/test/widgets/media_card_square_test.dart @@ -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(primaryFinder); + expect(primary.imagePath, trackArtwork); + expect(primary.errorWidget, isNotNull); + + final fallback = primary.errorWidget!(tester.element(primaryFinder), trackArtwork, StateError('decode failed')); + expect(fallback, isA()); + expect((fallback as OptimizedMediaImage).imagePath, albumArtwork); + expect(fallback.imageType, ImageType.square); + }); } class _TestApp extends StatelessWidget {