feat(music): music browsing UI — square cards, library groupings, artist/album screens

Un-gates music libraries; adds CardShape.square plumbing (circular
artists), artists/albums/tracks browse groupings with per-grouping
sorts, audio playlists tab, artist + album detail screens with dpad
track rows, music context-menu actions, and a stub MusicPlaybackService
seam for the upcoming engine.
This commit is contained in:
edde746
2026-07-05 17:20:28 +02:00
parent fff559da06
commit 05a631415e
30 changed files with 2235 additions and 67 deletions
+133
View File
@@ -18,6 +18,7 @@ import '../media/media_version.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../services/plex_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../services/music/music_playback_service.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/playlist_items_loader.dart';
import '../services/watch_actions.dart';
@@ -37,6 +38,7 @@ import '../utils/app_logger.dart';
import '../utils/library_refresh_notifier.dart';
import '../utils/media_navigation_helper.dart';
import '../utils/media_server_http_client.dart';
import '../utils/music_navigation.dart';
import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import '../utils/dialogs.dart';
@@ -46,6 +48,8 @@ import '../focus/focusable_text_field.dart';
import '../screens/plex_match_screen.dart';
import '../screens/media_detail_screen.dart';
import '../screens/metadata_edit_screen.dart';
import '../screens/music/album_detail_screen.dart';
import '../screens/music/artist_detail_screen.dart';
import '../utils/smart_deletion_handler.dart';
import '../utils/video_player_navigation.dart';
import '../utils/deletion_notifier.dart';
@@ -276,6 +280,52 @@ class MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true),
);
} else {
// Music (artist/album/track) playback + navigation actions. Play is
// always offered — the shared music_navigation helpers surface the
// "not supported yet" notice while the stub service is bound. Queue
// insertion only exists once a real playback engine is available.
final isMusicKind = mediaKind != null && mediaKind.isMusic;
if (isMusicKind) {
menuActions.add(_MenuAction(value: 'music_play', icon: Symbols.play_arrow_rounded, label: t.common.play));
final musicAvailable = context.read<MusicPlaybackService?>()?.isAvailable ?? false;
if (musicAvailable) {
menuActions.add(
_MenuAction(value: 'music_play_next', icon: Symbols.playlist_play_rounded, label: t.music.playNext),
);
menuActions.add(
_MenuAction(value: 'music_add_queue', icon: Symbols.queue_music_rounded, label: t.music.addToQueue),
);
}
// Instant Mix — capability-gated, and only while the server is
// reachable (capabilities stay truthy for offline servers).
if (itemServerOnline && (mediaClient?.capabilities.instantMix ?? false)) {
menuActions.add(
_MenuAction(value: 'music_instant_mix', icon: Symbols.instant_mix_rounded, label: t.music.instantMix),
);
}
// Go to Album (tracks only) — hidden when already on that album's
// detail screen, mirroring the Go to Series ancestor check.
final ancestorAlbumId = context.findAncestorWidgetOfExactType<AlbumDetailScreen>()?.album.id;
if (mediaKind == MediaKind.track && mediaItem!.parentId != null && ancestorAlbumId != mediaItem.parentId) {
menuActions.add(_MenuAction(value: 'music_album', icon: Symbols.album_rounded, label: t.music.goToAlbum));
}
// Go to Artist — album: parent, track: grandparent; hidden when
// already on that artist's detail screen.
final musicArtistId = switch (mediaKind) {
MediaKind.album => mediaItem!.parentId,
MediaKind.track => mediaItem!.grandparentId,
_ => null,
};
final ancestorArtistId = context.findAncestorWidgetOfExactType<ArtistDetailScreen>()?.artist.id;
if (musicArtistId != null && ancestorArtistId != musicArtistId) {
menuActions.add(_MenuAction(value: 'music_artist', icon: Symbols.artist_rounded, label: t.music.goToArtist));
}
}
if (hasActiveProgress) {
menuActions.add(
_MenuAction(value: 'play_from_beginning', icon: Symbols.replay_rounded, label: t.mediaMenu.playFromBeginning),
@@ -719,6 +769,42 @@ class MediaContextMenuState extends State<MediaContextMenu> {
case 'delete_media':
await _handleDeleteMediaItem(context, mediaKind);
break;
case 'music_play':
await _handleMusicPlay(context);
break;
case 'music_play_next':
await _handleMusicEnqueue(context, playNext: true);
break;
case 'music_add_queue':
await _handleMusicEnqueue(context, playNext: false);
break;
case 'music_instant_mix':
await playInstantMix(context, mediaItem!);
break;
case 'music_album':
didNavigate = true;
await _navigateToRelated(
context,
mediaItem!.parentId,
(item) => MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: item)),
t.common.error,
);
break;
case 'music_artist':
didNavigate = true;
await _navigateToRelated(
context,
mediaItem!.kind == MediaKind.album ? mediaItem.parentId : mediaItem.grandparentId,
(item) => MaterialPageRoute(builder: (_) => ArtistDetailScreen(artist: item)),
t.common.error,
);
break;
}
} catch (e, st) {
appLogger.e('Media context menu action failed', error: e, stackTrace: st);
@@ -933,6 +1019,53 @@ class MediaContextMenuState extends State<MediaContextMenu> {
return total > 0 ? total : null;
}
/// The track list music playback should operate on for [item]: the item
/// itself for a track, an album's tracks, or an artist's playable
/// descendants (one server round-trip for the container kinds).
Future<List<MediaItem>> _musicTracksForItem(MediaItem item) async {
final client = _getMediaClientForItem();
return switch (item.kind) {
MediaKind.album => await client.fetchAlbumTracks(item.id),
MediaKind.artist => await client.fetchPlayableDescendants(item.id),
_ => [item],
};
}
Future<void> _handleMusicPlay(BuildContext context) async {
final item = _mediaItem!;
if (item.kind == MediaKind.track) {
await playTrackWithAlbumContext(context, item);
return;
}
// Availability gate before the container fetch so the stub costs no
// server round-trip.
if (!ensureMusicPlaybackAvailable(context)) return;
final tracks = await _musicTracksForItem(item);
if (!context.mounted) return;
await playTracks(
context,
tracks: tracks,
playContext: MusicPlayContext(
id: item.id,
title: item.displayTitle,
kind: item.kind == MediaKind.artist ? MusicPlayContextKind.artist : MusicPlayContextKind.album,
),
);
}
Future<void> _handleMusicEnqueue(BuildContext context, {required bool playNext}) async {
final service = context.read<MusicPlaybackService?>();
// Menu entries are hidden on the stub; defensive re-check.
if (service == null || !service.isAvailable) return;
final tracks = await _musicTracksForItem(_mediaItem!);
if (tracks.isEmpty) return;
if (playNext) {
service.addNext(tracks);
} else {
service.addToEnd(tracks);
}
}
/// Handle shuffle play using play queues — dispatches via the
/// neutral [MediaListPlaybackLauncher] so Jellyfin items get routed to
/// [JellyfinSequentialLauncher] instead of falling through to the