merge: music-library polish and Live TV guide day navigation
Brings in the feedback-driven fixes: user-assigned Plex playlist posters, square music-grid gutters, grouped artist discography (albums/singles & EPs/live/compilations for Plex), and the guide day-jump rework (mounted reloads, immediate day apply, focus retention).
This commit is contained in:
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Hamısını təkrarla",
|
||||
"repeatOne": "Birini təkrarla",
|
||||
"instantMixNoServer": "Ani miks üçün heç bir server mövcud deyil",
|
||||
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil"
|
||||
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Birlikdə İzlə",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Повтаряне на всички",
|
||||
"repeatOne": "Повтаряне на една",
|
||||
"instantMixNoServer": "Няма наличен сървър за незабавен микс",
|
||||
"noAudioUrl": "Няма наличен URL за аудиото на ${track}"
|
||||
"noAudioUrl": "Няма наличен URL за аудиото на ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Гледане заедно",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Gentag alle",
|
||||
"repeatOne": "Gentag ét nummer",
|
||||
"instantMixNoServer": "Ingen server er tilgængelig til et øjeblikkeligt mix",
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}"
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Se sammen",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Alle wiederholen",
|
||||
"repeatOne": "Titel wiederholen",
|
||||
"instantMixNoServer": "Für einen Instant Mix ist kein Server verfügbar",
|
||||
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar"
|
||||
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Gemeinsam Schauen",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Repeat all",
|
||||
"repeatOne": "Repeat one",
|
||||
"instantMixNoServer": "No server is available for an instant mix",
|
||||
"noAudioUrl": "No audio URL is available for ${track}"
|
||||
"noAudioUrl": "No audio URL is available for ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singles & EPs",
|
||||
"live": "Live",
|
||||
"compilations": "Compilations"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Watch Together",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Repetir todo",
|
||||
"repeatOne": "Repetir una",
|
||||
"instantMixNoServer": "No hay ningún servidor disponible para una mezcla instantánea",
|
||||
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}"
|
||||
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Ver juntos",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Tout répéter",
|
||||
"repeatOne": "Répéter le titre",
|
||||
"instantMixNoServer": "Aucun serveur n’est disponible pour créer un mix instantané",
|
||||
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}"
|
||||
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Regarder ensemble",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Összes ismétlése",
|
||||
"repeatOne": "Egy szám ismétlése",
|
||||
"instantMixNoServer": "Nem érhető el szerver az azonnali mixhez",
|
||||
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}"
|
||||
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Watch Together (Közös nézés)",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Ripeti tutto",
|
||||
"repeatOne": "Ripeti il brano",
|
||||
"instantMixNoServer": "Nessun server disponibile per un mix istantaneo",
|
||||
"noAudioUrl": "Nessun URL audio disponibile per ${track}"
|
||||
"noAudioUrl": "Nessun URL audio disponibile per ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Guarda insieme",
|
||||
|
||||
@@ -1406,7 +1406,12 @@
|
||||
"repeatAll": "全曲リピート",
|
||||
"repeatOne": "1曲リピート",
|
||||
"instantMixNoServer": "インスタントミックスに利用できるサーバーがありません",
|
||||
"noAudioUrl": "${track}で利用可能な音声URLがありません"
|
||||
"noAudioUrl": "${track}で利用可能な音声URLがありません",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "一緒に見る",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Барлығын қайталау",
|
||||
"repeatOne": "Біреуін қайталау",
|
||||
"instantMixNoServer": "Жедел микс үшін қолжетімді сервер жоқ",
|
||||
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес"
|
||||
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Бірге көру",
|
||||
|
||||
@@ -1406,7 +1406,12 @@
|
||||
"repeatAll": "전체 반복",
|
||||
"repeatOne": "한 곡 반복",
|
||||
"instantMixNoServer": "즉석 믹스에 사용할 수 있는 서버가 없습니다",
|
||||
"noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다"
|
||||
"noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "함께 보기",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Gjenta alle",
|
||||
"repeatOne": "Gjenta ett spor",
|
||||
"instantMixNoServer": "Ingen server er tilgjengelig for en hurtigmiks",
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgjengelig for ${track}"
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgjengelig for ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Se sammen",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Alles herhalen",
|
||||
"repeatOne": "Eén herhalen",
|
||||
"instantMixNoServer": "Er is geen server beschikbaar voor een instantmix",
|
||||
"noAudioUrl": "Er is geen audio-URL beschikbaar voor ${track}"
|
||||
"noAudioUrl": "Er is geen audio-URL beschikbaar voor ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Samen kijken",
|
||||
|
||||
@@ -1436,7 +1436,12 @@
|
||||
"repeatAll": "Powtarzaj wszystko",
|
||||
"repeatOne": "Powtarzaj jeden",
|
||||
"instantMixNoServer": "Brak dostępnego serwera dla szybkiego miksu",
|
||||
"noAudioUrl": "Brak dostępnego adresu URL audio dla utworu ${track}"
|
||||
"noAudioUrl": "Brak dostępnego adresu URL audio dla utworu ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Oglądaj razem",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Repetir tudo",
|
||||
"repeatOne": "Repetir uma faixa",
|
||||
"instantMixNoServer": "Nenhum servidor está disponível para uma mistura instantânea",
|
||||
"noAudioUrl": "Nenhuma URL de áudio está disponível para ${track}"
|
||||
"noAudioUrl": "Nenhuma URL de áudio está disponível para ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Assistir Juntos",
|
||||
|
||||
@@ -1436,7 +1436,12 @@
|
||||
"repeatAll": "Повторять все",
|
||||
"repeatOne": "Повторять один",
|
||||
"instantMixNoServer": "Нет доступного сервера для мгновенного микса",
|
||||
"noAudioUrl": "Для трека ${track} нет доступного URL аудио"
|
||||
"noAudioUrl": "Для трека ${track} нет доступного URL аудио",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Смотреть вместе",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 40727 (1851 per locale)
|
||||
/// Strings: 40730 (1851 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -3696,6 +3696,8 @@ class Translations$music$en {
|
||||
|
||||
/// en: 'No audio URL is available for ${track}'
|
||||
String noAudioUrl({required Object track}) => 'No audio URL is available for ${track}';
|
||||
|
||||
late final Translations$music$discography$en discography = Translations$music$discography$en.internal(_root);
|
||||
}
|
||||
|
||||
// Path: watchTogether
|
||||
@@ -5955,6 +5957,24 @@ class Translations$explore$detail$en {
|
||||
String get background => 'Background';
|
||||
}
|
||||
|
||||
// Path: music.discography
|
||||
class Translations$music$discography$en {
|
||||
Translations$music$discography$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Singles & EPs'
|
||||
String get singlesAndEps => 'Singles & EPs';
|
||||
|
||||
/// en: 'Live'
|
||||
String get live => 'Live';
|
||||
|
||||
/// en: 'Compilations'
|
||||
String get compilations => 'Compilations';
|
||||
}
|
||||
|
||||
// Path: downloads.backgroundWarning
|
||||
class Translations$downloads$backgroundWarning$en {
|
||||
Translations$downloads$backgroundWarning$en.internal(this._root);
|
||||
@@ -7684,6 +7704,9 @@ extension on Translations {
|
||||
'music.repeatOne' => 'Repeat one',
|
||||
'music.instantMixNoServer' => 'No server is available for an instant mix',
|
||||
'music.noAudioUrl' => ({required Object track}) => 'No audio URL is available for ${track}',
|
||||
'music.discography.singlesAndEps' => 'Singles & EPs',
|
||||
'music.discography.live' => 'Live',
|
||||
'music.discography.compilations' => 'Compilations',
|
||||
'watchTogether.title' => 'Watch Together',
|
||||
'watchTogether.description' => 'Watch content in sync with friends and family',
|
||||
'watchTogether.createSession' => 'Create Session',
|
||||
@@ -7935,11 +7958,11 @@ extension on Translations {
|
||||
'companionRemote.errors.authenticationFailed' => 'Authentication failed',
|
||||
'companionRemote.errors.serverStartFailed' => ({required Object error}) => 'Failed to start remote server: ${error}',
|
||||
'companionRemote.errors.commandFailed' => ({required Object error}) => 'Failed to send remote command: ${error}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.errors.joinTimedOut' => 'Timed out joining session',
|
||||
'companionRemote.errors.failedToConnectAnyAddress' => 'Failed to connect to any address',
|
||||
'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Connection lost after ${attempts} attempts',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.errors.connectionLost' => 'Connection lost',
|
||||
'companionRemote.closedBeforeAuth' => 'The connection closed before authentication',
|
||||
'videoSettings.playbackSpeed' => 'Playback Speed',
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Upprepa alla",
|
||||
"repeatOne": "Upprepa en låt",
|
||||
"instantMixNoServer": "Ingen server är tillgänglig för en snabbmix",
|
||||
"noAudioUrl": "Ingen ljud-URL är tillgänglig för ${track}"
|
||||
"noAudioUrl": "Ingen ljud-URL är tillgänglig för ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Titta tillsammans",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Tümünü tekrarla",
|
||||
"repeatOne": "Tekini tekrarla",
|
||||
"instantMixNoServer": "Anında miks için kullanılabilir sunucu yok",
|
||||
"noAudioUrl": "${track} için kullanılabilir ses URL'si yok"
|
||||
"noAudioUrl": "${track} için kullanılabilir ses URL'si yok",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Birlikte İzle",
|
||||
|
||||
@@ -1416,7 +1416,12 @@
|
||||
"repeatAll": "Barchasini takrorlash",
|
||||
"repeatOne": "Birtasini takrorlash",
|
||||
"instantMixNoServer": "Tezkor miks uchun hech qanday server mavjud emas",
|
||||
"noAudioUrl": "${track} uchun audio URL mavjud emas"
|
||||
"noAudioUrl": "${track} uchun audio URL mavjud emas",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Birgalikda tomosha qilish",
|
||||
|
||||
@@ -1406,7 +1406,12 @@
|
||||
"repeatAll": "全部重複播放",
|
||||
"repeatOne": "單曲重複播放",
|
||||
"instantMixNoServer": "沒有可用於即時混音的伺服器",
|
||||
"noAudioUrl": "${track} 沒有可用的音訊 URL"
|
||||
"noAudioUrl": "${track} 沒有可用的音訊 URL",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "一起看",
|
||||
|
||||
@@ -1406,7 +1406,12 @@
|
||||
"repeatAll": "列表循环",
|
||||
"repeatOne": "单曲循环",
|
||||
"instantMixNoServer": "没有可用于即时混合的服务器",
|
||||
"noAudioUrl": "没有可供 ${track} 使用的音频 URL"
|
||||
"noAudioUrl": "没有可供 ${track} 使用的音频 URL",
|
||||
"discography": {
|
||||
"singlesAndEps": "",
|
||||
"live": "",
|
||||
"compilations": ""
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "一起看",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
/// Release-format sections of an artist's discography, mirroring how Plex's
|
||||
/// own clients split an artist's music library into Albums, Singles & EPs,
|
||||
/// Live, and Compilations. Declaration order is display order.
|
||||
enum DiscographyGroupKind { albums, singlesAndEps, live, compilations }
|
||||
|
||||
/// One discography section: a [kind] plus the albums it contains, in server
|
||||
/// order (newest first).
|
||||
class ArtistDiscographyGroup {
|
||||
final DiscographyGroupKind kind;
|
||||
final List<MediaItem> items;
|
||||
|
||||
const ArtistDiscographyGroup({required this.kind, required this.items});
|
||||
}
|
||||
|
||||
/// Partitions [albums] into ordered discography sections using the per-album
|
||||
/// [kindOf] classification. Album order is preserved within each section,
|
||||
/// sections appear in [DiscographyGroupKind] declaration order, and empty
|
||||
/// sections are dropped.
|
||||
List<ArtistDiscographyGroup> buildArtistDiscographyGroups(
|
||||
List<MediaItem> albums,
|
||||
DiscographyGroupKind Function(MediaItem album) kindOf,
|
||||
) {
|
||||
final buckets = {for (final kind in DiscographyGroupKind.values) kind: <MediaItem>[]};
|
||||
for (final album in albums) {
|
||||
buckets[kindOf(album)]!.add(album);
|
||||
}
|
||||
return [
|
||||
for (final kind in DiscographyGroupKind.values)
|
||||
if (buckets[kind]!.isNotEmpty) ArtistDiscographyGroup(kind: kind, items: buckets[kind]!),
|
||||
];
|
||||
}
|
||||
@@ -55,8 +55,9 @@ class MediaPlaylist {
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
/// Image used to represent the playlist in browse views.
|
||||
String? get displayImagePath => compositeImagePath ?? thumbPath;
|
||||
/// Image used to represent the playlist in browse views. A user-assigned
|
||||
/// poster ([thumbPath]) wins over the Plex auto-generated composite.
|
||||
String? get displayImagePath => thumbPath ?? compositeImagePath;
|
||||
|
||||
/// Display-friendly title (alias of [title] for parity with [MediaItem]).
|
||||
String get displayTitle => title;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse, throwIfHttpError;
|
||||
import '../utils/external_ids.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import 'artist_discography.dart';
|
||||
import 'download_resolution.dart';
|
||||
import 'ids.dart';
|
||||
import 'library_filter_result.dart';
|
||||
@@ -289,6 +290,20 @@ abstract class MediaServerClient {
|
||||
/// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`.
|
||||
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist);
|
||||
|
||||
/// The artist's discography split into release-format sections (albums,
|
||||
/// singles & EPs, live, compilations), in display order. Plex follows the
|
||||
/// album listing with one batched by-id metadata request for the
|
||||
/// `Format`/`Subformat` tags (listing rows never carry them) and classifies
|
||||
/// each album; if the tag fetch fails it degrades to a single flat
|
||||
/// `albums` group.
|
||||
///
|
||||
/// Jellyfin/Emby `BaseItemDto`s carry no single/EP/live/compilation
|
||||
/// taxonomy, so the MediaBrowser family always returns exactly one
|
||||
/// `albums` group. No [ServerCapabilities] flag gates this: support is
|
||||
/// encoded in the result shape (one group = no wire taxonomy) and no UI
|
||||
/// affordance would consult a flag, so a flag would be dead weight.
|
||||
Future<List<ArtistDiscographyGroup>> fetchArtistDiscography(MediaItem artist);
|
||||
|
||||
/// Tracks of album [albumId] in disc/track order. Plex:
|
||||
/// `/library/metadata/{id}/children`; Jellyfin:
|
||||
/// `/Items?AlbumIds={id}&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber`
|
||||
|
||||
@@ -170,6 +170,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
String? collectionId,
|
||||
VoidCallback? onListRefresh,
|
||||
CardShape? shape,
|
||||
int indexOffset = 0,
|
||||
}) {
|
||||
return buildSparseFocusableGrid(
|
||||
totalItems: items.length,
|
||||
@@ -178,6 +179,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
collectionId: collectionId,
|
||||
onListRefresh: onListRefresh,
|
||||
shape: shape,
|
||||
indexOffset: indexOffset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +187,14 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
||||
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
||||
/// the caller can kick off a page fetch containing that index.
|
||||
///
|
||||
/// [indexOffset] shifts this grid's slots into the screen-global focus
|
||||
/// index space (focus nodes and [lastFocusedGridIndex] are keyed 0..n-1
|
||||
/// across every grid on the screen). Screens that stack several titled
|
||||
/// grids pass the running total; single-grid screens keep the default 0.
|
||||
/// With an offset, up-navigation from this grid's first row falls through
|
||||
/// to framework traversal (crossing into the previous section's grid)
|
||||
/// instead of jumping to the app bar.
|
||||
Widget buildSparseFocusableGrid({
|
||||
required int totalItems,
|
||||
required MediaItem? Function(int index) itemAt,
|
||||
@@ -193,6 +203,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
String? collectionId,
|
||||
VoidCallback? onListRefresh,
|
||||
CardShape? shape,
|
||||
int indexOffset = 0,
|
||||
}) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
||||
@@ -205,12 +216,13 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
|
||||
Widget buildTile(MediaCardSliverPosition position) {
|
||||
final index = position.index;
|
||||
final globalIndex = index + indexOffset;
|
||||
final item = itemAt(index);
|
||||
if (item == null) {
|
||||
onSkeletonVisible?.call(index);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
final focusNode = _focusNodeForIndex(globalIndex);
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
@@ -222,9 +234,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
onListRefresh: onListRefresh,
|
||||
fullBleedImage: useFullCardLayout && position.isGrid,
|
||||
cardShapeOverride: shape,
|
||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||
// The first section's first row reaches the app bar; later
|
||||
// sections fall through to traversal, which enters the previous
|
||||
// section's grid.
|
||||
onNavigateUp: position.isFirstRow && indexOffset == 0 ? navigateToAppBar : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(globalIndex, hasFocus),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,14 @@ class GuideTabState extends State<GuideTab>
|
||||
Map<String, List<LiveTvProgram>> _programsByChannelScope = const {};
|
||||
Set<String> _scheduledRecordingKeys = const {};
|
||||
bool _isLoading = true;
|
||||
|
||||
/// Whether a load has completed at least once for this tab. Until the
|
||||
/// first successful load the guide shows a full-screen spinner; in-session
|
||||
/// window reloads keep the existing grid mounted and only overlay a
|
||||
/// lightweight loading indicator. Never reset on channel-list changes:
|
||||
/// `didUpdateWidget` re-indexes the loaded programs and fires no fetch, and
|
||||
/// a reset would restore the unmount (and D-pad focus loss) this flag fixes.
|
||||
bool _hasLoadedOnce = false;
|
||||
int _programLoadGeneration = 0;
|
||||
|
||||
late DateTime _gridStart;
|
||||
@@ -397,6 +405,7 @@ class GuideTabState extends State<GuideTab>
|
||||
_programsByChannelScope = programsByChannelScope;
|
||||
_scheduledRecordingKeys = scheduledRecordingKeys;
|
||||
_isLoading = false;
|
||||
_hasLoadedOnce = true;
|
||||
// Focus tracking compares by identity, so a reload orphans the
|
||||
// focused program — re-resolve it against the fresh list.
|
||||
if (_focusZone == _GuideZone.grid && _gridColumn == 1 && _focusedProgram != null) {
|
||||
@@ -758,6 +767,9 @@ class GuideTabState extends State<GuideTab>
|
||||
case 0:
|
||||
_shiftTimeRange(-2);
|
||||
case 1:
|
||||
// The menu takes focus; suppress the in-flight SELECT key-up so the
|
||||
// press that opened it cannot also activate the first menu item.
|
||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
||||
_showDayPicker();
|
||||
case 2:
|
||||
_shiftTimeRange(2);
|
||||
@@ -925,15 +937,33 @@ class GuideTabState extends State<GuideTab>
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (_isLoading) {
|
||||
// Full-screen spinner only before the first window has loaded. In-session
|
||||
// window reloads keep the grid mounted (so the day/time pickers and the
|
||||
// guide Focus stay alive for D-pad navigation) and show a lightweight
|
||||
// overlay instead — stale programs stay tappable while it settles.
|
||||
if (_isLoading && !_hasLoadedOnce) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Focus(
|
||||
focusNode: _guideFocusNode,
|
||||
onFocusChange: _handleGuideFocusChange,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildGuideGrid(theme),
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: _guideFocusNode,
|
||||
onFocusChange: _handleGuideFocusChange,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildGuideGrid(theme),
|
||||
),
|
||||
if (_isLoading)
|
||||
const Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: Color(0x33000000),
|
||||
child: Center(child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 3))),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1076,6 +1106,7 @@ class GuideTabState extends State<GuideTab>
|
||||
final target = DateTime(day.year, day.month, day.day);
|
||||
|
||||
if (target == today) return t.liveTv.today;
|
||||
if (target == today.add(const Duration(days: 1))) return t.liveTv.tomorrow;
|
||||
|
||||
return DateFormat('EEEE', LocaleSettings.currentLocale.intlLocaleName).format(target);
|
||||
}
|
||||
@@ -1133,10 +1164,28 @@ class GuideTabState extends State<GuideTab>
|
||||
_jumpToNow();
|
||||
_guideFocusNode.requestFocus();
|
||||
} else if (value is DateTime) {
|
||||
// Apply the picked day right away; the slot menu that follows only
|
||||
// refines it (dismissing it keeps this window).
|
||||
_applyDay(value);
|
||||
await _showTimeSlotPicker(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-anchors the 6h window onto [day] keeping the current window's
|
||||
/// time-of-day. Picking 'Now' instead re-anchors live via [_jumpToNow].
|
||||
/// #1297: a deliberately picked day is never yanked back by the drift
|
||||
/// checker — [_nowWasInWindow] stays false when now is outside it.
|
||||
void _applyDay(DateTime day) {
|
||||
final hour = _gridStart.hour;
|
||||
final minute = _gridStart.minute;
|
||||
setState(() {
|
||||
_gridStart = DateTime(day.year, day.month, day.day, hour, minute);
|
||||
_gridEnd = _gridStart.add(const Duration(hours: 6));
|
||||
_nowWasInWindow = _nowInWindow(DateTime.now());
|
||||
});
|
||||
unawaited(_loadPrograms());
|
||||
}
|
||||
|
||||
Future<void> _showTimeSlotPicker(DateTime day) async {
|
||||
final anchorRect = _menuAnchorRect();
|
||||
if (anchorRect == null) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../media/artist_discography.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/ids.dart';
|
||||
import '../../media/media_item.dart';
|
||||
@@ -15,6 +16,7 @@ import '../../utils/formatters.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/media_image_helper.dart';
|
||||
import '../../utils/music_navigation.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/collapsible_text.dart';
|
||||
@@ -45,6 +47,12 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
StandardItemLoader<ArtistDetailScreen> {
|
||||
final FocusNode _bioFocusNode = FocusNode(debugLabel: 'artist_bio');
|
||||
|
||||
/// Discography sections in display order. A single flat `albums` group
|
||||
/// (Plex with no singles/live/compilations, or any Jellyfin/Emby artist)
|
||||
/// renders the flat grid. The sections mirror the concatenation order of
|
||||
/// [items], so [updateItem]'s in-place swap stays visible.
|
||||
List<ArtistDiscographyGroup> _discographyGroups = const [];
|
||||
|
||||
@override
|
||||
Object get mediaItem => widget.artist;
|
||||
|
||||
@@ -58,7 +66,13 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
bool get hasItems => items.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist);
|
||||
Future<List<MediaItem>> fetchItems() async {
|
||||
final groups = await mediaClient.fetchArtistDiscography(widget.artist);
|
||||
_discographyGroups = groups;
|
||||
// Flat concatenation keeps BaseMediaListDetailScreen/StandardItemLoader/
|
||||
// updateItem working against one list; the grouped slivers slice it.
|
||||
return [for (final group in groups) ...group.items];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
@@ -168,6 +182,52 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
);
|
||||
}
|
||||
|
||||
/// Discography slivers. One group (or a backend without grouping) renders
|
||||
/// exactly today's flat grid; multiple groups render a titled section per
|
||||
/// group. Every section's grid shares the screen-global focus index space
|
||||
/// so D-pad navigation and [navigateToGrid] restore work across sections.
|
||||
List<Widget> _buildDiscographySlivers(BuildContext context) {
|
||||
if (_discographyGroups.length <= 1) {
|
||||
return [buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square)];
|
||||
}
|
||||
|
||||
final sectionTitleStyle = Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: .bold, fontSize: PlatformDetector.isTV() ? 28 : null);
|
||||
|
||||
final slivers = <Widget>[];
|
||||
var offset = 0;
|
||||
for (final group in _discographyGroups) {
|
||||
final groupItems = items.sublist(offset, offset + group.items.length);
|
||||
final sectionOffset = offset;
|
||||
offset += group.items.length;
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(_discographyGroupTitle(group.kind), style: sectionTitleStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(
|
||||
buildFocusableGrid(
|
||||
items: groupItems,
|
||||
onRefresh: updateItem,
|
||||
shape: CardShape.square,
|
||||
indexOffset: sectionOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
return slivers;
|
||||
}
|
||||
|
||||
String _discographyGroupTitle(DiscographyGroupKind kind) => switch (kind) {
|
||||
DiscographyGroupKind.albums => t.libraries.groupings.albums,
|
||||
DiscographyGroupKind.singlesAndEps => t.music.discography.singlesAndEps,
|
||||
DiscographyGroupKind.live => t.music.discography.live,
|
||||
DiscographyGroupKind.compilations => t.music.discography.compilations,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return buildDetailScaffold(
|
||||
@@ -176,7 +236,7 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
...buildStateSlivers(),
|
||||
// Albums arrive newest-first from both backends — no client-side sort.
|
||||
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
|
||||
if (hasItems) ..._buildDiscographySlivers(context),
|
||||
// Keep the last rows reachable above the floating mini-player.
|
||||
SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../connection/connection.dart';
|
||||
import '../media/artist_discography.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../media/library_filter_result.dart';
|
||||
import '../media/library_first_character.dart';
|
||||
|
||||
@@ -28,6 +28,16 @@ mixin _JellyfinMusicMethods on _JellyfinClientInternals {
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}
|
||||
|
||||
/// MediaBrowser `BaseItemDto`s carry no single/EP/live/compilation
|
||||
/// taxonomy, so the discography is a single albums group — the neutral
|
||||
/// contract's MediaBrowser-family shape.
|
||||
@override
|
||||
Future<List<ArtistDiscographyGroup>> fetchArtistDiscography(MediaItem artist) async {
|
||||
final albums = await fetchArtistAlbums(artist);
|
||||
if (albums.isEmpty) return const <ArtistDiscographyGroup>[];
|
||||
return [ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)];
|
||||
}
|
||||
|
||||
/// Tracks of [albumId] in disc/track order. `AlbumIds` (not `ParentId`) so
|
||||
/// tag-based albums whose files share one physical folder still resolve;
|
||||
/// `ParentIndexNumber,IndexNumber` yields correct multi-disc ordering.
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../media/artist_discography.dart';
|
||||
import '../media/download_resolution.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../media/library_filter_result.dart';
|
||||
@@ -3440,6 +3441,65 @@ class PlexClient
|
||||
return (metadata ?? const <PlexMetadataDto>[]).map((item) => PlexMappers.mediaItem(item)).toList();
|
||||
}
|
||||
|
||||
/// Grouped discography for [artist]: albums, singles & EPs, live, and
|
||||
/// compilations. Plex listing rows never carry `Format`/`Subformat` tags
|
||||
/// (even with `resolveTags=1`), so the album list is followed by one
|
||||
/// batched `/library/metadata/{ids}` detail request, whose rows do include
|
||||
/// them; each album is then classified individually. A failed tag fetch
|
||||
/// degrades to the flat albums list rather than sinking the screen.
|
||||
@override
|
||||
Future<List<ArtistDiscographyGroup>> fetchArtistDiscography(MediaItem artist) async {
|
||||
final albums = await fetchArtistAlbums(artist);
|
||||
// With one album at most, grouping is invisible (a single section renders
|
||||
// as the flat grid), so the tag lookup is pure overhead.
|
||||
if (albums.length <= 1) {
|
||||
return [if (albums.isNotEmpty) ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)];
|
||||
}
|
||||
|
||||
final Map<String, DiscographyGroupKind> kinds;
|
||||
try {
|
||||
kinds = await _fetchDiscographyKinds(artist.id, [for (final album in albums) album.id]);
|
||||
} catch (e) {
|
||||
appLogger.w('Discography tag fetch failed for artist ${artist.id}, degrading to a flat albums list', error: e);
|
||||
return [ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)];
|
||||
}
|
||||
return buildArtistDiscographyGroups(albums, (album) => kinds[album.id] ?? DiscographyGroupKind.albums);
|
||||
}
|
||||
|
||||
/// Ids per batched `/library/metadata/{ids}` request. Plex ids are short,
|
||||
/// so 100 keeps the URL well under proxy limits.
|
||||
static const _discographyTagChunkSize = 100;
|
||||
|
||||
/// Fetches `Format`/`Subformat` tags for [albumIds] via batched by-id
|
||||
/// metadata requests and classifies each album. Explicit container bounds
|
||||
/// defeat any server-side default page cap; a missing row simply leaves
|
||||
/// that album in the default section.
|
||||
Future<Map<String, DiscographyGroupKind>> _fetchDiscographyKinds(String artistId, List<String> albumIds) async {
|
||||
final chunks = [
|
||||
for (var i = 0; i < albumIds.length; i += _discographyTagChunkSize)
|
||||
albumIds.sublist(
|
||||
i,
|
||||
i + _discographyTagChunkSize > albumIds.length ? albumIds.length : i + _discographyTagChunkSize,
|
||||
),
|
||||
];
|
||||
final results = await Future.wait([
|
||||
for (final (index, chunk) in chunks.indexed)
|
||||
fetchWithCacheFallback<List<PlexMetadataDto>>(
|
||||
cacheKey: '/library/metadata/$artistId/discography-tags/$index',
|
||||
networkCall: () => _http.get(
|
||||
'/library/metadata/${chunk.join(',')}',
|
||||
queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': chunk.length},
|
||||
),
|
||||
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
|
||||
parseResponse: (response) => _extractMetadataList(response),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
for (final metadata in results)
|
||||
for (final dto in metadata ?? const <PlexMetadataDto>[]) dto.ratingKey: PlexMappers.discographyKind(dto),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchAlbumTracks(String albumId) => fetchChildren(albumId);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// resolution and server-tagging.
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import '../media/artist_discography.dart';
|
||||
import '../media/ids.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -637,6 +638,10 @@ class PlexMetadataDto {
|
||||
final List<String>? style;
|
||||
@JsonKey(name: 'Mood', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? mood;
|
||||
@JsonKey(name: 'Format', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? format;
|
||||
@JsonKey(name: 'Subformat', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? subformat;
|
||||
final String? audioLanguage;
|
||||
final String? subtitleLanguage;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
@@ -718,6 +723,8 @@ class PlexMetadataDto {
|
||||
this.label,
|
||||
this.style,
|
||||
this.mood,
|
||||
this.format,
|
||||
this.subformat,
|
||||
this.audioLanguage,
|
||||
this.subtitleLanguage,
|
||||
this.subtitleMode,
|
||||
@@ -843,6 +850,8 @@ class PlexMetadataDto {
|
||||
List<String>? label,
|
||||
List<String>? style,
|
||||
List<String>? mood,
|
||||
List<String>? format,
|
||||
List<String>? subformat,
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
int? subtitleMode,
|
||||
@@ -915,6 +924,8 @@ class PlexMetadataDto {
|
||||
label: label ?? this.label,
|
||||
style: style ?? this.style,
|
||||
mood: mood ?? this.mood,
|
||||
format: format ?? this.format,
|
||||
subformat: subformat ?? this.subformat,
|
||||
audioLanguage: audioLanguage ?? this.audioLanguage,
|
||||
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
|
||||
subtitleMode: subtitleMode ?? this.subtitleMode,
|
||||
@@ -1041,6 +1052,19 @@ class PlexMappers {
|
||||
return mediaItem(dto);
|
||||
}
|
||||
|
||||
/// Plex's release-format taxonomy for an album row. `Format` tags EP/Single
|
||||
/// mark Singles & EPs; otherwise `Subformat` live/compilation mark those
|
||||
/// sections; everything else is a standard album. Matching is
|
||||
/// case-insensitive, mirroring Plex's own filter semantics.
|
||||
static DiscographyGroupKind discographyKind(PlexMetadataDto dto) {
|
||||
bool hasTag(List<String>? tags, Set<String> wanted) =>
|
||||
tags?.any((tag) => wanted.contains(tag.toLowerCase())) ?? false;
|
||||
if (hasTag(dto.format, const {'ep', 'single'})) return DiscographyGroupKind.singlesAndEps;
|
||||
if (hasTag(dto.subformat, const {'live'})) return DiscographyGroupKind.live;
|
||||
if (hasTag(dto.subformat, const {'compilation'})) return DiscographyGroupKind.compilations;
|
||||
return DiscographyGroupKind.albums;
|
||||
}
|
||||
|
||||
/// Map a parsed [PlexMetadataDto] into a [PlexMediaItem].
|
||||
static PlexMediaItem mediaItem(PlexMetadataDto dto) {
|
||||
final kind = MediaKind.fromString(dto.type);
|
||||
|
||||
@@ -143,6 +143,8 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
|
||||
label: _tagListFromJson(json['Label']),
|
||||
style: _tagListFromJson(json['Style']),
|
||||
mood: _tagListFromJson(json['Mood']),
|
||||
format: _tagListFromJson(json['Format']),
|
||||
subformat: _tagListFromJson(json['Subformat']),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
subtitleMode: flexibleInt(json['subtitleMode']),
|
||||
|
||||
@@ -51,6 +51,11 @@ class GridLayoutConstants {
|
||||
/// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator).
|
||||
static const double squareGridCellAspectRatio = 2 / 2.3;
|
||||
|
||||
/// Inter-card gutter for square (music) grids on touch platforms. Cards
|
||||
/// already carry their own 3px internal padding, so the net visual gutter
|
||||
/// is ~14px horizontal. Automotive keeps its larger [crossAxisSpacing].
|
||||
static const double squareGridSpacing = 8.0;
|
||||
|
||||
static double get crossAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0;
|
||||
static double get mainAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0;
|
||||
|
||||
|
||||
@@ -36,9 +36,22 @@ class MediaGridDelegate {
|
||||
return maxCrossAxisExtent;
|
||||
}
|
||||
|
||||
static double spacingFor({required BuildContext context, bool fullBleedImage = false}) {
|
||||
/// Inter-cell gutter for the resolved shape. Square (music) grids get
|
||||
/// [GridLayoutConstants.squareGridSpacing] so cards have breathing room;
|
||||
/// every other shape keeps the platform default (0, or 24 on automotive).
|
||||
/// Full-bleed TV grids use the scaled full-card gutter.
|
||||
static double spacingFor({
|
||||
required BuildContext context,
|
||||
bool useWideAspectRatio = false,
|
||||
bool fullBleedImage = false,
|
||||
CardShape? shape,
|
||||
}) {
|
||||
if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing;
|
||||
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
|
||||
if (!fullBleedImage) {
|
||||
return _resolveShape(shape, useWideAspectRatio) == CardShape.square
|
||||
? GridLayoutConstants.squareGridSpacing
|
||||
: GridLayoutConstants.crossAxisSpacing;
|
||||
}
|
||||
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
|
||||
}
|
||||
|
||||
@@ -101,7 +114,12 @@ class MediaGridGeometry {
|
||||
bool fullBleedImage = false,
|
||||
CardShape? shape,
|
||||
}) {
|
||||
final spacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullBleedImage);
|
||||
final spacing = MediaGridDelegate.spacingFor(
|
||||
context: context,
|
||||
useWideAspectRatio: useWideAspectRatio,
|
||||
fullBleedImage: fullBleedImage,
|
||||
shape: shape,
|
||||
);
|
||||
final aspectRatio = MediaGridDelegate.aspectRatioFor(
|
||||
useWideAspectRatio: useWideAspectRatio,
|
||||
fullBleedImage: fullBleedImage,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/artist_discography.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
|
||||
void main() {
|
||||
MediaItem album(String id) => MediaItem.plex(id: id, kind: MediaKind.album, title: id);
|
||||
|
||||
final kinds = <String, DiscographyGroupKind>{
|
||||
'ep-1': DiscographyGroupKind.singlesAndEps,
|
||||
'live-1': DiscographyGroupKind.live,
|
||||
'comp-1': DiscographyGroupKind.compilations,
|
||||
};
|
||||
DiscographyGroupKind kindOf(MediaItem item) => kinds[item.id] ?? DiscographyGroupKind.albums;
|
||||
|
||||
test('partitions albums into sections in display order', () {
|
||||
final groups = buildArtistDiscographyGroups([
|
||||
album('lp-1'),
|
||||
album('ep-1'),
|
||||
album('live-1'),
|
||||
album('comp-1'),
|
||||
album('lp-2'),
|
||||
], kindOf);
|
||||
|
||||
expect(groups.map((group) => group.kind), [
|
||||
DiscographyGroupKind.albums,
|
||||
DiscographyGroupKind.singlesAndEps,
|
||||
DiscographyGroupKind.live,
|
||||
DiscographyGroupKind.compilations,
|
||||
]);
|
||||
expect(groups[0].items.map((item) => item.id), ['lp-1', 'lp-2']);
|
||||
expect(groups[1].items.map((item) => item.id), ['ep-1']);
|
||||
expect(groups[2].items.map((item) => item.id), ['live-1']);
|
||||
expect(groups[3].items.map((item) => item.id), ['comp-1']);
|
||||
});
|
||||
|
||||
test('preserves album order within each section', () {
|
||||
final groups = buildArtistDiscographyGroups([album('lp-3'), album('ep-1'), album('lp-1'), album('lp-2')], kindOf);
|
||||
|
||||
expect(groups[0].items.map((item) => item.id), ['lp-3', 'lp-1', 'lp-2']);
|
||||
expect(groups[1].items.map((item) => item.id), ['ep-1']);
|
||||
});
|
||||
|
||||
test('drops empty sections and returns an empty list for no albums', () {
|
||||
final groups = buildArtistDiscographyGroups([album('lp-1'), album('live-1')], kindOf);
|
||||
expect(groups.map((group) => group.kind), [DiscographyGroupKind.albums, DiscographyGroupKind.live]);
|
||||
|
||||
expect(buildArtistDiscographyGroups(const [], kindOf), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -27,14 +27,14 @@ MediaPlaylist _playlist({
|
||||
|
||||
void main() {
|
||||
group('MediaPlaylist.displayImagePath', () {
|
||||
test('prefers compositeImagePath over thumbPath', () {
|
||||
test('prefers the user-assigned thumbPath over compositeImagePath', () {
|
||||
final pl = _playlist(compositeImagePath: '/composite/grid', thumbPath: '/thumb/single');
|
||||
expect(pl.displayImagePath, '/composite/grid');
|
||||
expect(pl.displayImagePath, '/thumb/single');
|
||||
});
|
||||
|
||||
test('falls back to thumbPath when composite is null', () {
|
||||
final pl = _playlist(compositeImagePath: null, thumbPath: '/thumb/single');
|
||||
expect(pl.displayImagePath, '/thumb/single');
|
||||
test('falls back to compositeImagePath when thumb is null', () {
|
||||
final pl = _playlist(compositeImagePath: '/composite/grid', thumbPath: null);
|
||||
expect(pl.displayImagePath, '/composite/grid');
|
||||
});
|
||||
|
||||
test('is null when both are null', () {
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/layout_constants.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/card_inflation_budget.dart';
|
||||
import 'package:plezy/widgets/focusable_media_card.dart';
|
||||
@@ -182,6 +183,13 @@ void main() {
|
||||
.every((card) => card.cardShapeOverride == CardShape.square),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// Square grids keep their square gutter spacing even on TV full-card
|
||||
// layout (which is disabled for square shapes).
|
||||
final gridDelegate =
|
||||
tester.widget<SliverGrid>(find.byType(SliverGrid)).gridDelegate as SliverGridDelegateWithMaxCrossAxisExtent;
|
||||
expect(gridDelegate.crossAxisSpacing, GridLayoutConstants.squareGridSpacing);
|
||||
expect(gridDelegate.mainAxisSpacing, GridLayoutConstants.squareGridSpacing);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -175,14 +175,108 @@ void main() {
|
||||
|
||||
harness.serverA.schedule.complete(1, 'Obsolete');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
// In-session reloads keep the guide mounted: the indicator is the
|
||||
// lightweight overlay rather than a full-screen spinner, and the guide
|
||||
// Focus + day chip stay in the tree so D-pad navigation survives.
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode?.debugLabel == 'guide_tab'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byWidgetPredicate((widget) => widget is AppIcon && widget.icon == Symbols.arrow_drop_down_rounded),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Obsolete'), findsNothing);
|
||||
});
|
||||
|
||||
harness.serverA.schedule.complete(2, 'Current');
|
||||
testWidgets('picking a day applies it immediately and slot-menu dismissal keeps it', (tester) async {
|
||||
final harness = _GuideHarness.oneServer();
|
||||
addTearDown(harness.dispose);
|
||||
await harness.pump(tester);
|
||||
await harness.completeInitial(tester);
|
||||
|
||||
_guideTabFocusNode(tester).requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await _openDayPicker(tester);
|
||||
// The day menu labels tomorrow via the translation.
|
||||
expect(find.text(t.liveTv.tomorrow), findsOneWidget);
|
||||
await _selectTomorrowInDayMenu(tester);
|
||||
|
||||
// The picked day is applied right away: a fetch for it already went out,
|
||||
// keeping the current window's time-of-day.
|
||||
final requests = harness.serverA.schedule.requests;
|
||||
expect(requests, hasLength(2));
|
||||
final first = requests[0];
|
||||
final dayRequest = requests[1];
|
||||
final gridStartLocal = first.from.toLocal();
|
||||
final now = DateTime.now();
|
||||
final tomorrow = DateTime(now.year, now.month, now.day).add(const Duration(days: 1));
|
||||
final expectedFrom = DateTime(
|
||||
tomorrow.year,
|
||||
tomorrow.month,
|
||||
tomorrow.day,
|
||||
gridStartLocal.hour,
|
||||
gridStartLocal.minute,
|
||||
).toUtc();
|
||||
expect(dayRequest.from, expectedFrom);
|
||||
expect(dayRequest.to, expectedFrom.add(const Duration(hours: 6)));
|
||||
|
||||
// Dismissing the refinement menu keeps the already-applied day, and the
|
||||
// day chip renders the picked day via the translation too.
|
||||
await tester.tapAt(const Offset(1270, 700));
|
||||
await _pumpMenuTransition(tester);
|
||||
expect(harness.serverA.schedule.requests, hasLength(2));
|
||||
expect(find.text(t.liveTv.tomorrow), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('picking a slot after the day refines the window to that slot', (tester) async {
|
||||
final harness = _GuideHarness.oneServer();
|
||||
addTearDown(harness.dispose);
|
||||
await harness.pump(tester);
|
||||
await harness.completeInitial(tester);
|
||||
|
||||
_guideTabFocusNode(tester).requestFocus();
|
||||
await tester.pump();
|
||||
await _openDayPicker(tester);
|
||||
await _selectTomorrowInDayMenu(tester);
|
||||
expect(harness.serverA.schedule.requests, hasLength(2));
|
||||
|
||||
await tester.tap(find.text(t.liveTv.morning));
|
||||
await _pumpMenuTransition(tester);
|
||||
|
||||
final requests = harness.serverA.schedule.requests;
|
||||
expect(requests, hasLength(3));
|
||||
final now = DateTime.now();
|
||||
final tomorrow = DateTime(now.year, now.month, now.day).add(const Duration(days: 1));
|
||||
final expectedFrom = DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 6).toUtc();
|
||||
expect(requests[2].from, expectedFrom);
|
||||
expect(requests[2].to, expectedFrom.add(const Duration(hours: 6)));
|
||||
});
|
||||
|
||||
testWidgets('after a day change completes the guide keeps D-pad focus', (tester) async {
|
||||
final harness = _GuideHarness.oneServer();
|
||||
addTearDown(harness.dispose);
|
||||
await harness.pump(tester);
|
||||
await harness.completeInitial(tester);
|
||||
|
||||
_guideTabFocusNode(tester).requestFocus();
|
||||
await tester.pump();
|
||||
await _openDayPicker(tester);
|
||||
await _selectTomorrowInDayMenu(tester);
|
||||
|
||||
// Dismiss the refinement menu without picking: focus returns to the guide.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await _pumpMenuTransition(tester);
|
||||
expect(_guideTabFocusNode(tester).hasFocus, isTrue);
|
||||
|
||||
// Completing the day fetch must not drop focus (the remote stays alive).
|
||||
harness.serverA.schedule.complete(1, 'Tomorrow Programs');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Current'), findsOneWidget);
|
||||
expect(find.text('Obsolete'), findsNothing);
|
||||
expect(_guideTabFocusNode(tester).hasFocus, isTrue);
|
||||
expect(find.text('Tomorrow Programs'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('horizontal guide virtualization keeps the D-pad focus target rendered', (tester) async {
|
||||
@@ -328,6 +422,35 @@ void _expectFocusedChannel(WidgetTester tester, String callSign) {
|
||||
);
|
||||
}
|
||||
|
||||
FocusNode _guideTabFocusNode(WidgetTester tester) {
|
||||
final guideFocus = tester.widget<Focus>(
|
||||
find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode?.debugLabel == 'guide_tab'),
|
||||
);
|
||||
return guideFocus.focusNode!;
|
||||
}
|
||||
|
||||
/// Opens the day picker menu via SELECT on the focused time-nav day chip.
|
||||
Future<void> _openDayPicker(WidgetTester tester) async {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Selects 'Tomorrow' in the open day menu by tapping its entry; the slot
|
||||
/// refinement menu opens on top. The keyboard open/close paths are covered by
|
||||
/// the focus test; menu entry taps keep this selection deterministic.
|
||||
Future<void> _selectTomorrowInDayMenu(WidgetTester tester) async {
|
||||
await tester.tap(find.text(t.liveTv.tomorrow));
|
||||
await _pumpMenuTransition(tester);
|
||||
}
|
||||
|
||||
/// Pumps through a menu open/close transition (120ms). Cannot pumpAndSettle:
|
||||
/// while a guide load is in flight the overlay's indeterminate spinner keeps
|
||||
/// scheduling frames forever.
|
||||
Future<void> _pumpMenuTransition(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
final class _GuideHarness {
|
||||
_GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels});
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/artist_discography.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/music/artist_detail_screen.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/music/music_playback_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/widgets/focusable_media_card.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
import '../../test_helpers/multi_server_fixtures.dart';
|
||||
import '../../test_helpers/stub_music_playback_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
const artist = MediaItem.plex(
|
||||
id: 'artist_1',
|
||||
kind: MediaKind.artist,
|
||||
title: 'Test Artist',
|
||||
serverId: 'server_1',
|
||||
serverName: 'Server',
|
||||
);
|
||||
|
||||
MediaItem album(String id) => MediaItem.plex(
|
||||
id: id,
|
||||
kind: MediaKind.album,
|
||||
title: id,
|
||||
parentId: 'artist_1',
|
||||
parentTitle: 'Test Artist',
|
||||
serverId: 'server_1',
|
||||
serverName: 'Server',
|
||||
);
|
||||
|
||||
testWidgets('multiple groups render titled sections in order', (tester) async {
|
||||
// Tall viewport so every lazily-built section sliver is inside the
|
||||
// cache extent; the default 600px fits only the first two sections.
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final harness = await _createHarness([
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1')]),
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.singlesAndEps, items: [album('album-s1')]),
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.live, items: [album('album-l1')]),
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.compilations, items: [album('album-c1')]),
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FocusableMediaCard), findsNWidgets(4));
|
||||
final titles = ['Albums', 'Singles & EPs', 'Live', 'Compilations'];
|
||||
for (final title in titles) {
|
||||
expect(find.text(title), findsOneWidget);
|
||||
}
|
||||
final dy = [for (final title in titles) tester.getTopLeft(find.text(title)).dy];
|
||||
expect(dy, List.of(dy)..sort(), reason: 'sections must render in display order');
|
||||
});
|
||||
|
||||
testWidgets('a single group renders the flat grid without section headers', (tester) async {
|
||||
final harness = await _createHarness([
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1'), album('album-a2')]),
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FocusableMediaCard), findsNWidgets(2));
|
||||
expect(find.text('Albums'), findsNothing);
|
||||
expect(find.text('Singles & EPs'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('d-pad up from a later section enters the previous section grid', (tester) async {
|
||||
final harness = await _createHarness([
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1')]),
|
||||
ArtistDiscographyGroup(kind: DiscographyGroupKind.singlesAndEps, items: [album('album-s1')]),
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cards = find.byType(FocusableMediaCard);
|
||||
// Section 2's card: global index 1, its own focus node.
|
||||
tester.widget<FocusableMediaCard>(cards.at(1)).focusNode!.requestFocus();
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_grid_item_1');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_first_item');
|
||||
|
||||
// The first section's first row still exits the grid upward, landing on
|
||||
// the header action bar's play button — the flat screen's pre-existing
|
||||
// navigateToAppBar contract.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pumpAndSettle();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'music_play');
|
||||
});
|
||||
}
|
||||
|
||||
Future<_ArtistHarness> _createHarness(List<ArtistDiscographyGroup> groups) async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
final client = _FakeDiscographyClient(groups);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final multiServerProvider = testMultiServerProvider(manager);
|
||||
|
||||
addTearDown(multiServerProvider.dispose);
|
||||
|
||||
return _ArtistHarness(client: client, multiServerProvider: multiServerProvider);
|
||||
}
|
||||
|
||||
class _ArtistHarness {
|
||||
final _FakeDiscographyClient client;
|
||||
final MultiServerProvider multiServerProvider;
|
||||
|
||||
const _ArtistHarness({required this.client, required this.multiServerProvider});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
||||
ChangeNotifierProvider<MusicPlaybackService>(create: (_) => StubMusicPlaybackService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: SizedBox(width: 1280, height: 720, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDiscographyClient implements MediaServerClient {
|
||||
final List<ArtistDiscographyGroup> groups;
|
||||
final List<String> fetchedArtistIds = [];
|
||||
|
||||
_FakeDiscographyClient(this.groups);
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('server_1');
|
||||
|
||||
@override
|
||||
String? get serverName => 'Server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
Future<List<ArtistDiscographyGroup>> fetchArtistDiscography(MediaItem artist) async {
|
||||
fetchedArtistIds.add(artist.id);
|
||||
return groups;
|
||||
}
|
||||
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) => '';
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/media/artist_discography.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
@@ -3015,6 +3016,30 @@ void main() {
|
||||
expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
|
||||
});
|
||||
|
||||
test('fetchArtistDiscography wraps the album listing in one albums group', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
return jsonResponse({
|
||||
'Items': [
|
||||
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Album 1'},
|
||||
{'Id': 'album-2', 'Type': 'MusicAlbum', 'Name': 'Album 2'},
|
||||
],
|
||||
'TotalRecordCount': 2,
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final groups = await scoped.fetchArtistDiscography(
|
||||
testMediaItem(id: 'artist-1', backend: MediaBackend.jellyfin, kind: MediaKind.artist),
|
||||
);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single.kind, DiscographyGroupKind.albums);
|
||||
expect(groups.single.items.map((item) => item.id), ['album-1', 'album-2']);
|
||||
});
|
||||
|
||||
test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async {
|
||||
Uri? captured;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/artist_discography.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
@@ -1066,6 +1067,191 @@ void main() {
|
||||
expect(albums.map((album) => album.id), ['album-1']);
|
||||
});
|
||||
|
||||
test('fetchArtistDiscography fetches tags in one batch and classifies per album', () async {
|
||||
const baseKey = '/library/metadata/artist-1/children';
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.path == '/library/sections/7/all') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'librarySectionID': 7,
|
||||
'size': 5,
|
||||
'totalSize': 5,
|
||||
'Metadata': [
|
||||
for (final id in ['album-1', 'album-2', 'album-ep', 'album-live', 'album-comp'])
|
||||
{'ratingKey': id, 'type': 'album', 'title': id},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
// Batched by-id metadata request: the only response shape that carries
|
||||
// Format/Subformat. `album-ep` is deliberately dual-tagged to pin the
|
||||
// singles-over-live precedence, and tag casing is mixed to pin
|
||||
// case-insensitive matching.
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 5,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'album-1', 'type': 'album', 'title': 'album-1'},
|
||||
{'ratingKey': 'album-2', 'type': 'album', 'title': 'album-2'},
|
||||
{
|
||||
'ratingKey': 'album-ep',
|
||||
'type': 'album',
|
||||
'title': 'album-ep',
|
||||
'Format': [
|
||||
{'tag': 'EP'},
|
||||
],
|
||||
'Subformat': [
|
||||
{'tag': 'Live'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'ratingKey': 'album-live',
|
||||
'type': 'album',
|
||||
'title': 'album-live',
|
||||
'Subformat': [
|
||||
{'tag': 'Live'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'ratingKey': 'album-comp',
|
||||
'type': 'album',
|
||||
'title': 'album-comp',
|
||||
'Subformat': [
|
||||
{'tag': 'compilation'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final groups = await client.fetchArtistDiscography(
|
||||
testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'),
|
||||
);
|
||||
|
||||
// Exactly two requests: the unchanged album listing plus one batched
|
||||
// by-id tag lookup with explicit container bounds.
|
||||
expect(requests, hasLength(2));
|
||||
expect(requests[0].path, '/library/sections/7/all');
|
||||
expect(requests[0].queryParameters['type'], '9');
|
||||
expect(requests[0].queryParameters['artist.id'], 'artist-1');
|
||||
expect(requests[0].queryParameters['sort'], 'album.year:desc');
|
||||
expect(Uri.decodeComponent(requests[1].path), '/library/metadata/album-1,album-2,album-ep,album-live,album-comp');
|
||||
expect(requests[1].queryParameters['X-Plex-Container-Start'], '0');
|
||||
expect(requests[1].queryParameters['X-Plex-Container-Size'], '5');
|
||||
|
||||
expect(groups.map((group) => group.kind), [
|
||||
DiscographyGroupKind.albums,
|
||||
DiscographyGroupKind.singlesAndEps,
|
||||
DiscographyGroupKind.live,
|
||||
DiscographyGroupKind.compilations,
|
||||
]);
|
||||
expect(groups.map((group) => group.items.map((item) => item.id).toList()), [
|
||||
['album-1', 'album-2'],
|
||||
['album-ep'],
|
||||
['album-live'],
|
||||
['album-comp'],
|
||||
]);
|
||||
|
||||
// The listing keeps the fetchArtistAlbums cache identity; the tag batch
|
||||
// gets its own row.
|
||||
final cache = PlexApiCache.instance;
|
||||
Future<List<String>> cachedRatingKeys(String key) async {
|
||||
final cached = await cache.get(defaultProfileScopeId.cacheServerId, key);
|
||||
final container = cached!['MediaContainer'] as Map<String, dynamic>;
|
||||
return [
|
||||
for (final item in container['Metadata'] as List<dynamic>)
|
||||
(item as Map<String, dynamic>)['ratingKey'] as String,
|
||||
];
|
||||
}
|
||||
|
||||
expect(await cachedRatingKeys(baseKey), ['album-1', 'album-2', 'album-ep', 'album-live', 'album-comp']);
|
||||
expect(await cachedRatingKeys('/library/metadata/artist-1/discography-tags/0'), [
|
||||
'album-1',
|
||||
'album-2',
|
||||
'album-ep',
|
||||
'album-live',
|
||||
'album-comp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fetchArtistDiscography degrades to a flat albums group when the tag batch fails', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.path != '/library/sections/7/all') {
|
||||
return http.Response('boom', 500, headers: const {'content-type': 'application/json'});
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'librarySectionID': 7,
|
||||
'size': 2,
|
||||
'totalSize': 2,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'album-1', 'type': 'album', 'title': 'Album 1'},
|
||||
{'ratingKey': 'album-2', 'type': 'album', 'title': 'Album 2'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final groups = await client.fetchArtistDiscography(
|
||||
testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'),
|
||||
);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single.kind, DiscographyGroupKind.albums);
|
||||
expect(groups.single.items.map((item) => item.id), ['album-1', 'album-2']);
|
||||
});
|
||||
|
||||
test('fetchArtistDiscography skips the tag batch for a single-album artist', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'librarySectionID': 7,
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'album-1', 'type': 'album', 'title': 'Album 1'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final groups = await client.fetchArtistDiscography(
|
||||
testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'),
|
||||
);
|
||||
|
||||
// Grouping is invisible with one album, so no tag lookup is issued.
|
||||
expect(requests, hasLength(1));
|
||||
expect(requests.single.path, '/library/sections/7/all');
|
||||
expect(groups.single.kind, DiscographyGroupKind.albums);
|
||||
expect(groups.single.items.map((item) => item.id), ['album-1']);
|
||||
});
|
||||
|
||||
test('profile transition isolates metadata and every direct cache-only bypass', () async {
|
||||
final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a');
|
||||
final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b');
|
||||
|
||||
@@ -60,6 +60,64 @@ void main() {
|
||||
expect(MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true), GridLayoutConstants.episodeGridCellAspectRatio);
|
||||
});
|
||||
|
||||
testWidgets('square grid delegates use square gutters, others unchanged', (tester) async {
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.view.resetPhysicalSize();
|
||||
});
|
||||
|
||||
SliverGridDelegateWithMaxCrossAxisExtent? square;
|
||||
SliverGridDelegateWithMaxCrossAxisExtent? poster;
|
||||
SliverGridDelegateWithMaxCrossAxisExtent? wide;
|
||||
SliverGridDelegateWithMaxCrossAxisExtent? fullBleed;
|
||||
await tester.pumpWidget(
|
||||
_TestApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
square = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: 1280,
|
||||
density: LibraryDensity.defaultValue,
|
||||
shape: CardShape.square,
|
||||
).delegate;
|
||||
poster = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: 1280,
|
||||
density: LibraryDensity.defaultValue,
|
||||
).delegate;
|
||||
wide = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: 1280,
|
||||
density: LibraryDensity.defaultValue,
|
||||
useWideAspectRatio: true,
|
||||
).delegate;
|
||||
fullBleed = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: 1280,
|
||||
density: LibraryDensity.defaultValue,
|
||||
fullBleedImage: true,
|
||||
).delegate;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Square (music) grids get breathing room.
|
||||
expect(square!.crossAxisSpacing, GridLayoutConstants.squareGridSpacing);
|
||||
expect(square!.mainAxisSpacing, GridLayoutConstants.squareGridSpacing);
|
||||
// Poster and wide grids keep the platform default (0 off-automotive).
|
||||
expect(poster!.crossAxisSpacing, GridLayoutConstants.crossAxisSpacing);
|
||||
expect(poster!.mainAxisSpacing, GridLayoutConstants.crossAxisSpacing);
|
||||
expect(wide!.crossAxisSpacing, GridLayoutConstants.crossAxisSpacing);
|
||||
expect(wide!.mainAxisSpacing, GridLayoutConstants.crossAxisSpacing);
|
||||
// Full-bleed TV gutters are unchanged.
|
||||
expect(fullBleed!.crossAxisSpacing, GridLayoutConstants.fullCardGridSpacingForScale(0.85));
|
||||
expect(fullBleed!.mainAxisSpacing, fullBleed!.crossAxisSpacing);
|
||||
});
|
||||
|
||||
test('list layout sizes square cards 1:1', () {
|
||||
final base = MediaCardListLayout.basePosterWidth(LibraryDensity.defaultValue);
|
||||
expect(MediaCardListLayout.posterWidth(density: LibraryDensity.defaultValue, shape: CardShape.square), base);
|
||||
|
||||
Reference in New Issue
Block a user