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:
edde746
2026-08-17 23:03:24 +02:00
45 changed files with 1111 additions and 50 deletions
+6 -1
View File
@@ -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ə",
+6 -1
View File
@@ -1416,7 +1416,12 @@
"repeatAll": "Повтаряне на всички",
"repeatOne": "Повтаряне на една",
"instantMixNoServer": "Няма наличен сървър за незабавен микс",
"noAudioUrl": "Няма наличен URL за аудиото на ${track}"
"noAudioUrl": "Няма наличен URL за аудиото на ${track}",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "Гледане заедно",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -1416,7 +1416,12 @@
"repeatAll": "Tout répéter",
"repeatOne": "Répéter le titre",
"instantMixNoServer": "Aucun serveur nest disponible pour créer un mix instantané",
"noAudioUrl": "Aucune URL audio nest disponible pour ${track}"
"noAudioUrl": "Aucune URL audio nest disponible pour ${track}",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "Regarder ensemble",
+6 -1
View File
@@ -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)",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -1406,7 +1406,12 @@
"repeatAll": "全曲リピート",
"repeatOne": "1曲リピート",
"instantMixNoServer": "インスタントミックスに利用できるサーバーがありません",
"noAudioUrl": "${track}で利用可能な音声URLがありません"
"noAudioUrl": "${track}で利用可能な音声URLがありません",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "一緒に見る",
+6 -1
View File
@@ -1416,7 +1416,12 @@
"repeatAll": "Барлығын қайталау",
"repeatOne": "Біреуін қайталау",
"instantMixNoServer": "Жедел микс үшін қолжетімді сервер жоқ",
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес"
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "Бірге көру",
+6 -1
View File
@@ -1406,7 +1406,12 @@
"repeatAll": "전체 반복",
"repeatOne": "한 곡 반복",
"instantMixNoServer": "즉석 믹스에 사용할 수 있는 서버가 없습니다",
"noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다"
"noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "함께 보기",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -1436,7 +1436,12 @@
"repeatAll": "Повторять все",
"repeatOne": "Повторять один",
"instantMixNoServer": "Нет доступного сервера для мгновенного микса",
"noAudioUrl": "Для трека ${track} нет доступного URL аудио"
"noAudioUrl": "Для трека ${track} нет доступного URL аудио",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "Смотреть вместе",
+1 -1
View File
@@ -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
+25 -2
View File
@@ -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',
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -1406,7 +1406,12 @@
"repeatAll": "全部重複播放",
"repeatOne": "單曲重複播放",
"instantMixNoServer": "沒有可用於即時混音的伺服器",
"noAudioUrl": "${track} 沒有可用的音訊 URL"
"noAudioUrl": "${track} 沒有可用的音訊 URL",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "一起看",
+6 -1
View File
@@ -1406,7 +1406,12 @@
"repeatAll": "列表循环",
"repeatOne": "单曲循环",
"instantMixNoServer": "没有可用于即时混合的服务器",
"noAudioUrl": "没有可供 ${track} 使用的音频 URL"
"noAudioUrl": "没有可供 ${track} 使用的音频 URL",
"discography": {
"singlesAndEps": "",
"live": "",
"compilations": ""
}
},
"watchTogether": {
"title": "一起看",
+33
View File
@@ -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]!),
];
}
+3 -2
View File
@@ -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;
+15
View File
@@ -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`
+18 -3
View File
@@ -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),
);
}
+55 -6
View File
@@ -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;
+62 -2
View File
@@ -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)),
],
+1
View File
@@ -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.
+60
View File
@@ -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);
+24
View File
@@ -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);
+2
View File
@@ -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']),
+5
View File
@@ -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;
+21 -3
View File
@@ -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,
+50
View File
@@ -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);
});
}
+5 -5
View File
@@ -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);
});
}
+127 -4
View File
@@ -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');
+58
View File
@@ -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);