@@ -785,6 +785,7 @@
|
||||
"contentRating": "Възрастов рейтинг",
|
||||
"tag": "Таг",
|
||||
"unwatched": "Негледани",
|
||||
"unplayed": "Непускани",
|
||||
"favorites": "Любими"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Aldersvurdering",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Usete",
|
||||
"unplayed": "Ikke afspillet",
|
||||
"favorites": "Favoritter"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Altersfreigabe",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Ungesehene",
|
||||
"unplayed": "Nicht abgespielt",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"contentRating": "Content Rating",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Unwatched",
|
||||
"unplayed": "Unplayed",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Clasificación",
|
||||
"tag": "Etiqueta",
|
||||
"unwatched": "No vistos",
|
||||
"unplayed": "No reproducido",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Classification",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Non vus",
|
||||
"unplayed": "Non lu",
|
||||
"favorites": "Favoris"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Classificazione",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Non visti",
|
||||
"unplayed": "Non riprodotto",
|
||||
"favorites": "Preferiti"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "視聴年齢区分",
|
||||
"tag": "タグ",
|
||||
"unwatched": "未視聴",
|
||||
"unplayed": "未再生",
|
||||
"favorites": "お気に入り"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "시청 등급",
|
||||
"tag": "태그",
|
||||
"unwatched": "미시청",
|
||||
"unplayed": "재생하지 않음",
|
||||
"favorites": "즐겨찾기"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Aldersgrense",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Usette",
|
||||
"unplayed": "Ikke avspilt",
|
||||
"favorites": "Favoritter"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Leeftijdsclassificatie",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Onbekeken",
|
||||
"unplayed": "Niet afgespeeld",
|
||||
"favorites": "Favorieten"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Klasyfikacja wiekowa",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Nieobejrzane",
|
||||
"unplayed": "Nieodtworzone",
|
||||
"favorites": "Ulubione"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Classificação",
|
||||
"tag": "Tag",
|
||||
"unwatched": "Não assistidos",
|
||||
"unplayed": "Não reproduzido",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Возрастной рейтинг",
|
||||
"tag": "Тег",
|
||||
"unwatched": "Непросмотренные",
|
||||
"unplayed": "Не прослушано",
|
||||
"favorites": "Избранное"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => 'Всички епизоди вече са изтеглени',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'すべてのエピソードはすでにダウンロード済みです',
|
||||
|
||||
@@ -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' => '모든 에피소드가 이미 다운로드되었습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Все эпизоды уже загружены',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '所有剧集均已下载',
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "Åldersgräns",
|
||||
"tag": "Tagg",
|
||||
"unwatched": "Osedda",
|
||||
"unplayed": "Ospelat",
|
||||
"favorites": "Favoriter"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
"contentRating": "内容分级",
|
||||
"tag": "标签",
|
||||
"unwatched": "未观看",
|
||||
"unplayed": "未播放",
|
||||
"favorites": "收藏夹"
|
||||
},
|
||||
"sortLabels": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user