diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4c5dfd20..8081c9f7 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -784,6 +784,9 @@ "shows": "TV Shows", "seasons": "Seasons", "episodes": "Episodes", + "artists": "Artists", + "albums": "Albums", + "tracks": "Tracks", "folders": "Folders" }, "filterCategories": { @@ -970,6 +973,18 @@ "errorReordering": "Failed to reorder playlist item", "errorRemoving": "Failed to remove from playlist" }, + "music": { + "goToAlbum": "Go to album", + "goToArtist": "Go to artist", + "instantMix": "Instant Mix", + "playNext": "Play next", + "addToQueue": "Add to queue", + "discNumber": "Disc ${n}", + "trackCount": { + "one": "${n} track", + "other": "${n} tracks" + } + }, "watchTogether": { "title": "Watch Together", "description": "Watch content in sync with friends and family", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 53747078..7054b3c1 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20866 (1304 per locale) +/// Strings: 20877 (1304 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 89aa6d6c..6eb746fa 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -73,6 +73,7 @@ class Translations with BaseTranslations { late final TranslationsLiveTvEn liveTv = TranslationsLiveTvEn.internal(_root); late final TranslationsCollectionsEn collections = TranslationsCollectionsEn.internal(_root); late final TranslationsPlaylistsEn playlists = TranslationsPlaylistsEn.internal(_root); + late final TranslationsMusicEn music = TranslationsMusicEn.internal(_root); late final TranslationsWatchTogetherEn watchTogether = TranslationsWatchTogetherEn.internal(_root); late final TranslationsDownloadsEn downloads = TranslationsDownloadsEn.internal(_root); late final TranslationsShadersEn shaders = TranslationsShadersEn.internal(_root); @@ -2863,6 +2864,39 @@ class TranslationsPlaylistsEn { String get errorRemoving => 'Failed to remove from playlist'; } +// Path: music +class TranslationsMusicEn { + TranslationsMusicEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Go to album' + String get goToAlbum => 'Go to album'; + + /// en: 'Go to artist' + String get goToArtist => 'Go to artist'; + + /// en: 'Instant Mix' + String get instantMix => 'Instant Mix'; + + /// en: 'Play next' + String get playNext => 'Play next'; + + /// en: 'Add to queue' + String get addToQueue => 'Add to queue'; + + /// en: 'Disc ${n}' + String discNumber({required Object n}) => 'Disc ${n}'; + + /// en: '(one) {${n} track} (other) {${n} tracks}' + String trackCount({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: '${n} track', + other: '${n} tracks', + ); +} + // Path: watchTogether class TranslationsWatchTogetherEn { TranslationsWatchTogetherEn.internal(this._root); @@ -4183,6 +4217,15 @@ class TranslationsLibrariesGroupingsEn { /// en: 'Episodes' String get episodes => 'Episodes'; + /// en: 'Artists' + String get artists => 'Artists'; + + /// en: 'Albums' + String get albums => 'Albums'; + + /// en: 'Tracks' + String get tracks => 'Tracks'; + /// en: 'Folders' String get folders => 'Folders'; } @@ -5351,6 +5394,9 @@ extension on Translations { 'libraries.groupings.shows' => 'TV Shows', 'libraries.groupings.seasons' => 'Seasons', 'libraries.groupings.episodes' => 'Episodes', + 'libraries.groupings.artists' => 'Artists', + 'libraries.groupings.albums' => 'Albums', + 'libraries.groupings.tracks' => 'Tracks', 'libraries.groupings.folders' => 'Folders', 'libraries.filterCategories.genre' => 'Genre', 'libraries.filterCategories.year' => 'Year', @@ -5513,6 +5559,13 @@ extension on Translations { 'playlists.errorAdding' => 'Failed to add to playlist', 'playlists.errorReordering' => 'Failed to reorder playlist item', 'playlists.errorRemoving' => 'Failed to remove from playlist', + 'music.goToAlbum' => 'Go to album', + 'music.goToArtist' => 'Go to artist', + 'music.instantMix' => 'Instant Mix', + 'music.playNext' => 'Play next', + 'music.addToQueue' => 'Add to queue', + 'music.discNumber' => ({required Object n}) => 'Disc ${n}', + 'music.trackCount' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} track', other: '${n} tracks', ), 'watchTogether.title' => 'Watch Together', 'watchTogether.description' => 'Watch content in sync with friends and family', 'watchTogether.createSession' => 'Create Session', @@ -5634,6 +5687,8 @@ extension on Translations { 'downloads.editSyncFilter' => 'Sync filter', 'downloads.syncAllItems' => 'Syncing all items', 'downloads.syncUnwatchedItems' => 'Syncing unwatched items', + _ => null, + } ?? switch (path) { 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', 'downloads.syncRuleAvailable' => 'Available', 'downloads.syncRuleOffline' => 'Offline', @@ -5644,8 +5699,6 @@ extension on Translations { 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'No video enhancement', 'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video', - _ => null, - } ?? switch (path) { 'shaders.artcnnVariantNeutral' => 'Neutral', 'shaders.artcnnVariantDenoise' => 'Denoise', 'shaders.artcnnVariantDenoiseSharpen' => 'Denoise + Sharpen', diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 972dcb17..72e1e475 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -548,6 +548,14 @@ sealed class MediaItem with _$MediaItem { return false; } + /// The card silhouette this item renders with. Music items (artist/album/ + /// track) are square; everything else folds in the [usesWideAspectRatio] + /// wide-vs-poster decision, so the two can never disagree. + CardShape cardShape(EpisodePosterMode mode, {bool mixedHubContext = false}) { + if (kind.isMusic) return CardShape.square; + return usesWideAspectRatio(mode, mixedHubContext: mixedHubContext) ? CardShape.wide : CardShape.poster; + } + /// Returns the best hero art path based on the container's aspect ratio. String? heroArt({required double containerAspectRatio}) { final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); @@ -573,6 +581,11 @@ sealed class MediaItem with _$MediaItem { } } +/// The silhouette a media card renders with: 2:3 posters, 16:9 wide +/// thumbnails (episodes/clips), or 1:1 squares (music artwork; artists clip +/// to a circle). Resolved per item via [MediaItem.cardShape]. +enum CardShape { poster, wide, square } + MediaKind _mediaKindFromJson(Object? raw) => MediaKind.fromString(raw as String?); String _mediaKindToJson(MediaKind kind) => kind.id; diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 52df4710..c5c2d502 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -21,6 +21,7 @@ import '../providers/trackers_provider.dart'; import '../providers/watch_state_store.dart'; import '../screens/main_screen.dart'; import '../services/api_cache.dart'; +import '../services/music/music_playback_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../watch_together/providers/watch_together_provider.dart'; @@ -165,6 +166,9 @@ class _ProfileSessionScreenState extends State { }, ), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), + // Stub until the music playback engine binds a real service; + // profile-session scope so a profile switch ends the session. + ChangeNotifierProvider(create: (context) => StubMusicPlaybackService()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ChangeNotifierProvider( create: (context) { diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 65707041..fc08e405 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -5,7 +5,6 @@ import '../mixins/disposable_change_notifier_mixin.dart'; import '../services/data_aggregation_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; -import '../utils/content_utils.dart'; import 'multi_server_provider.dart'; /// Load state for the libraries provider @@ -68,7 +67,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi /// without refetching the already-loaded servers. final Set _pendingDeltaServerIds = {}; - /// Unmodifiable list of all libraries (filtered for supported types, ordered) + /// Unmodifiable list of all libraries (ordered) List get libraries => List.unmodifiable(_libraries); /// Whether libraries are currently being loaded @@ -119,7 +118,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi /// Load libraries from all connected servers, unconditionally. Used by /// pull-to-refresh, inline connection-add, and library reordering. - /// Filters out music libraries and applies saved ordering. + /// Applies saved ordering. Future loadLibraries() => _load(); /// Single entry point for every full (re)load. Concurrent callers coalesce @@ -161,7 +160,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi try { final result = await _aggregationService!.getMediaLibrariesFromAllServers(serverIds: ids); - final fresh = result.libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); + final fresh = result.libraries; final merged = [ for (final lib in _libraries) @@ -229,13 +228,10 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi } } - // Filter out music libraries (not supported) - final filteredLibraries = result.libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); - // Apply saved library order final storage = _storageService ??= await StorageService.getInstance(); final savedOrder = storage.getLibraryOrder(); - final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder); + final orderedLibraries = _applyLibraryOrder(result.libraries, savedOrder); _libraries = orderedLibraries; // Track which servers actually responded so [syncToOnlineServers] can tell diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 05da003c..b58acaf4 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -162,12 +162,15 @@ mixin FocusableDetailScreenMixin on State, GridFocu } /// Build a standard focusable grid sliver for media items. - /// Used by collection and smart playlist detail screens. + /// Used by collection, smart playlist, and music artist detail screens. + /// [shape] overrides the grid cell silhouette (e.g. [CardShape.square] + /// for album grids); null keeps the stock poster geometry. Widget buildFocusableGrid({ required List items, required void Function(String itemId) onRefresh, String? collectionId, VoidCallback? onListRefresh, + CardShape? shape, }) { return SettingsBuilder( prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], @@ -214,6 +217,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu crossAxisExtent: crossAxisExtent, density: libraryDensity, fullBleedImage: fullCardLayout, + shape: shape, ); return SliverGrid.builder( addAutomaticKeepAlives: false, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index e02ef209..4f966528 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -534,6 +534,11 @@ class _HubDetailScreenState extends State final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + // Music hubs render square album/artist artwork + final isSquareHub = + _filteredItems.isNotEmpty && + _filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square); + if (isListMode) { return SliverPadding( padding: const EdgeInsets.all(8), @@ -579,6 +584,7 @@ class _HubDetailScreenState extends State horizontalPadding: 16, useWideAspectRatio: useWideLayout, fullBleedImage: fullCardLayout, + shape: isSquareHub ? CardShape.square : null, ); final columnCount = geometry.columnCount; diff --git a/lib/screens/libraries/library_browse_grouping.dart b/lib/screens/libraries/library_browse_grouping.dart index 490cfdce..9b4d799f 100644 --- a/lib/screens/libraries/library_browse_grouping.dart +++ b/lib/screens/libraries/library_browse_grouping.dart @@ -6,6 +6,9 @@ const browseGroupingMovies = 'movies'; const browseGroupingShows = 'shows'; const browseGroupingSeasons = 'seasons'; const browseGroupingEpisodes = 'episodes'; +const browseGroupingArtists = 'artists'; +const browseGroupingAlbums = 'albums'; +const browseGroupingTracks = 'tracks'; const browseGroupingFolders = 'folders'; List libraryBrowseGroupingOptions(MediaLibrary library, {required bool canGroupByFolders}) { @@ -27,6 +30,12 @@ List libraryBrowseGroupingOptions(MediaLibrary library, {required bool c if (canGroupByFolders) browseGroupingFolders, ], MediaKind.movie => [browseGroupingMovies, if (canGroupByFolders) browseGroupingFolders], + MediaKind.artist => [ + browseGroupingArtists, + browseGroupingAlbums, + browseGroupingTracks, + if (canGroupByFolders) browseGroupingFolders, + ], _ => [browseGroupingAll, if (canGroupByFolders) browseGroupingFolders], }; } @@ -36,6 +45,7 @@ String defaultLibraryBrowseGrouping(MediaLibrary library) { return switch (library.kind) { MediaKind.show => browseGroupingShows, MediaKind.movie => browseGroupingMovies, + MediaKind.artist => browseGroupingArtists, _ => browseGroupingAll, }; } diff --git a/lib/screens/libraries/library_filter_sort_loader.dart b/lib/screens/libraries/library_filter_sort_loader.dart index c42bd2e7..8cd6dda6 100644 --- a/lib/screens/libraries/library_filter_sort_loader.dart +++ b/lib/screens/libraries/library_filter_sort_loader.dart @@ -30,11 +30,15 @@ class LibraryFilterSortLoader { LibraryFilterSortLoader({required this.clientFor}); - Future load(MediaLibrary library) async { + /// [sortLibraryType] overrides the type the sort listing is fetched for — + /// music libraries serve a distinct sort list per browse grouping + /// (artist/album/track), so the caller passes the active grouping's kind id. + /// Defaults to the library's own kind. + Future load(MediaLibrary library, {String? sortLibraryType}) async { final client = clientFor(library); final results = await Future.wait([ client.fetchLibraryFiltersWithValues(library.id), - client.fetchSortOptions(library.id, libraryType: library.kind.id), + client.fetchSortOptions(library.id, libraryType: sortLibraryType ?? library.kind.id), ]); final filterResult = results.first as LibraryFilterResult; final sorts = results[1] as List; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 2c001beb..1bcdb146 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -9,6 +9,7 @@ import '../../../media/library_first_character.dart'; import '../../../media/library_query.dart'; import '../../../media/media_backend.dart'; import '../../../media/media_item.dart'; +import '../../../media/media_kind.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../utils/media_server_http_client.dart'; import '../../../exceptions/media_server_exceptions.dart'; @@ -266,6 +267,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState MediaKind.album.id, + browseGroupingTracks => MediaKind.track.id, + _ => widget.library.kind.id, + }; + } + List _getGroupingOptions() { return libraryBrowseGroupingOptions(widget.library, canGroupByFolders: widget.canGroupByFolders); } @@ -788,6 +812,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _reloadSortOptionsForGrouping() async { + final generation = _contentRequestId; + final grouping = _selectedGrouping; + var sorts = const []; + try { + final client = context.getMediaClientForLibrary(widget.library); + sorts = await client.fetchSortOptions(widget.library.id, libraryType: _sortOptionsLibraryType(grouping)); + } catch (e, st) { + appLogger.w('Failed to load sort options for grouping $grouping', error: e, stackTrace: st); + } + if (!mounted || generation != _contentRequestId || grouping != _selectedGrouping) return; + setState(() { + _sortOptions = sorts; + if (_selectedSort != null && sorts.every((s) => s.key != _selectedSort!.key)) { + _selectedSort = null; + _isSortDescending = false; + } + }); + unawaited(_loadItems()); + unawaited(_loadFirstCharacters()); + } + void _showFiltersBottomSheet() { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); OverlaySheetController.of(context).show(builder: (_) => _buildFiltersBottomSheet()); @@ -1736,18 +1799,33 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> fetchPage(int start, int size, AbortController? abort) { // Both backends return playlists scoped to the server (not the library) — - // neither Plex nor Jellyfin's API filters playlists by section. + // neither Plex nor Jellyfin's API filters playlists by section. Music + // libraries surface audio playlists; everything else keeps video. final client = getMediaClientForLibrary(); - return client.fetchPlaylistsPage(playlistType: 'video', start: start, size: size, abort: abort); + final playlistType = widget.library.kind == MediaKind.artist ? 'audio' : 'video'; + return client.fetchPlaylistsPage(playlistType: playlistType, start: start, size: size, abort: abort); } @override diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart new file mode 100644 index 00000000..ffc1ed49 --- /dev/null +++ b/lib/screens/music/album_detail_screen.dart @@ -0,0 +1,394 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../focus/focus_theme.dart'; +import '../../focus/focusable_action_bar.dart'; +import '../../focus/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; +import '../../i18n/strings.g.dart'; +import '../../media/ids.dart'; +import '../../media/media_item.dart'; +import '../../mixins/grid_focus_node_mixin.dart'; +import '../../services/music/music_playback_service.dart'; +import '../../theme/mono_tokens.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/formatters.dart'; +import '../../utils/layout_constants.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/app_icon.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; +import '../../widgets/media_context_menu.dart'; +import '../../widgets/music/music_actions.dart'; +import '../../widgets/music/track_row.dart'; +import '../../widgets/optimized_media_image.dart'; +import '../../widgets/overlay_sheet.dart'; +import '../base_media_list_detail_screen.dart'; +import '../focusable_detail_screen_mixin.dart'; + +/// Detail screen for a music album: square cover header, Play/Shuffle/ +/// Instant Mix action row, and the track list rendered as grouped +/// [TrackRow] cards with per-disc headers on multi-disc albums. +class AlbumDetailScreen extends StatefulWidget { + final MediaItem album; + + const AlbumDetailScreen({super.key, required this.album}); + + @override + State createState() => _AlbumDetailScreenState(); +} + +/// A row of the track list: either a disc header ([discNumber] non-null) or +/// the track at [trackIndex]. [isFirst]/[isLast] mark the track's position +/// within its disc group for the grouped-card corner radii. +class _TrackListEntry { + final int? discNumber; + final int? trackIndex; + final bool isFirst; + final bool isLast; + + const _TrackListEntry.header(int this.discNumber) : trackIndex = null, isFirst = false, isLast = false; + + const _TrackListEntry.track(int this.trackIndex, {required this.isFirst, required this.isLast}) : discNumber = null; +} + +class _AlbumDetailScreenState extends BaseMediaListDetailScreen + with + GridFocusNodeMixin, + FocusableDetailScreenMixin, + StandardItemLoader { + final GlobalKey _contextMenuKey = GlobalKey(); + + @override + Object get mediaItem => widget.album; + + @override + String get title => widget.album.displayTitle; + + @override + String get emptyMessage => t.messages.noItemsAvailable; + + @override + bool get hasItems => items.isNotEmpty; + + @override + Future> fetchItems() => mediaClient.fetchAlbumTracks(widget.album.id); + + @override + String getLoadErrorMessage(Object error) => t.messages.errorLoading(error: error.toString()); + + @override + Future loadItems() async { + await super.loadItems(); + autoFocusFirstItemAfterLoad(); + } + + @override + void dispose() { + disposeFocusResources(); + super.dispose(); + } + + MusicPlayContext get _playContext => + MusicPlayContext(id: widget.album.id, title: widget.album.displayTitle, kind: MusicPlayContextKind.album); + + /// Plays the already-fetched track list — no extra server round-trip. + Future _playAll({bool shuffle = false}) async { + if (items.isEmpty) { + showAppSnackBar(context, emptyMessage); + return; + } + await playTracks(context, tracks: items, playContext: _playContext, shuffle: shuffle); + } + + Future _openArtist() async { + final parentId = widget.album.parentId; + if (parentId == null) return; + MediaItem? artist; + try { + artist = await mediaClient.fetchItem(parentId); + } catch (e) { + appLogger.w('Failed to fetch artist $parentId for album ${widget.album.id}', error: e); + } + if (artist == null || !mounted) return; + await navigateToArtist(context, artist); + } + + void _showOverflowMenuAt(BuildContext buttonContext) { + final box = buttonContext.findRenderObject() as RenderBox?; + Offset? position; + if (box != null) position = box.localToGlobal(box.size.center(Offset.zero)); + _contextMenuKey.currentState?.showContextMenu(buttonContext, position: position); + } + + /// Album ⋮ — opens the item's standard context menu (Instant Mix, Go to + /// artist, Mark played/unplayed…), same pattern as the media detail row. + FocusableAction _overflowAction() { + return FocusableAction( + debugLabel: 'album_more', + onPressed: () => _contextMenuKey.currentState?.showContextMenu(context), + builder: (context, state) => MediaContextMenu( + key: _contextMenuKey, + item: widget.album, + child: Builder( + builder: (buttonContext) => Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: state.showFocus, borderRadius: 20), + child: IconButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + onPressed: () => _showOverflowMenuAt(buttonContext), + ), + ), + ), + ), + ); + } + + @override + List getAppBarActions() { + final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId)); + return buildMusicActions( + onPlay: () => unawaited(_playAll()), + onShuffle: () => unawaited(_playAll(shuffle: true)), + onInstantMix: (client?.capabilities.instantMix ?? false) + ? () => unawaited(playInstantMix(context, widget.album)) + : null, + trailing: _overflowAction(), + ); + } + + int get _totalDurationMs => items.fold(0, (sum, item) => sum + (item.durationMs ?? 0)); + + Widget _buildHeader() { + final tk = tokens(context); + final textTheme = Theme.of(context).textTheme; + final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId)); + final artistName = widget.album.albumArtistTitle; + + final metaParts = [ + if (widget.album.year != null) '${widget.album.year}', + if (items.isNotEmpty) t.music.trackCount(n: items.length), + if (_totalDurationMs > 0) formatDurationTextual(_totalDurationMs), + ]; + + Widget cover(double size) => ClipRRect( + borderRadius: BorderRadius.circular(tk.radiusLg), + child: OptimizedMediaImage( + client: client, + imagePath: widget.album.thumbPath, + imageType: ImageType.square, + width: size, + height: size, + fallbackIcon: Symbols.album_rounded, + ), + ); + + Widget info({required bool centered}) => Column( + mainAxisSize: .min, + crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + Text( + widget.album.displayTitle, + style: textTheme.titleLarge, + textAlign: centered ? TextAlign.center : TextAlign.start, + ), + if (artistName != null && artistName.isNotEmpty) ...[ + const SizedBox(height: 4), + _ArtistLink(name: artistName, onTap: widget.album.parentId == null ? null : () => unawaited(_openArtist())), + ], + if (metaParts.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(toBulletedString(metaParts), style: textTheme.bodyMedium?.copyWith(color: tk.textMuted)), + ], + ], + ); + + final actionRow = FocusableActionBar( + key: actionBarKey, + spacing: 4, + actions: getAppBarActions(), + onNavigateDown: navigateToGrid, + onBack: () => Navigator.pop(context), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < ScreenBreakpoints.mobile; + if (narrow) { + return Column( + children: [ + cover(200), + const SizedBox(height: 16), + info(centered: true), + const SizedBox(height: 16), + actionRow, + ], + ); + } + return Row( + crossAxisAlignment: .end, + children: [ + cover(180), + const SizedBox(width: 24), + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [info(centered: false), const SizedBox(height: 16), actionRow], + ), + ), + ], + ); + }, + ), + ); + } + + /// Flattens tracks into list rows, inserting disc headers when the album + /// spans multiple discs. Tracks arrive in disc/track order from both + /// backends, so grouping is a single pass. + List<_TrackListEntry> _rowModels() { + final multiDisc = items.map((item) => item.discNumber ?? 1).toSet().length > 1; + final rows = <_TrackListEntry>[]; + for (var i = 0; i < items.length; i++) { + final disc = items[i].discNumber ?? 1; + final previousDisc = i > 0 ? (items[i - 1].discNumber ?? 1) : null; + final nextDisc = i < items.length - 1 ? (items[i + 1].discNumber ?? 1) : null; + if (multiDisc && disc != previousDisc) rows.add(_TrackListEntry.header(disc)); + rows.add( + _TrackListEntry.track( + i, + isFirst: multiDisc ? disc != previousDisc : i == 0, + isLast: multiDisc ? disc != nextDisc : i == items.length - 1, + ), + ); + } + return rows; + } + + Widget _buildTrackList() { + final tk = tokens(context); + final rows = _rowModels(); + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + sliver: SliverList.builder( + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + final disc = row.discNumber; + if (disc != null) { + return Padding( + padding: EdgeInsets.fromLTRB(16, index == 0 ? 0 : 16, 16, 8), + child: Text( + t.music.discNumber(n: disc), + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tk.textMuted, fontWeight: .w600), + ), + ); + } + final trackIndex = row.trackIndex!; + final item = items[trackIndex]; + return Padding( + padding: EdgeInsets.only(top: row.isFirst ? 0 : tk.groupGap), + child: TrackRow( + key: ValueKey(item.id), + item: item, + isFirst: row.isFirst, + isLast: row.isLast, + focusNode: focusNodeForIndex(trackIndex, firstItemFocusNode, prefix: 'detail_grid_item'), + onNavigateUp: trackIndex == 0 ? navigateToAppBar : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(trackIndex, hasFocus), + onRefresh: updateItem, + onTap: () => unawaited(playTracks(context, tracks: items, startTrack: item, playContext: _playContext)), + ), + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( + controller: scrollController, + child: OverlaySheetHost( + // Host owns sheet + system back: a back with a sheet open closes it; + // otherwise focus the action row first, then pop. + canPop: PlatformDetector.isHandheldIOS(context), + onSystemBack: () { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (handleBackNavigation() && mounted) Navigator.pop(context); + }, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + CustomAppBar(title: Text(widget.album.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + if (hasItems) _buildTrackList(), + ], + ), + ), + ), + ), + ); + } +} + +/// Tappable, focusable artist line under the album title. Focus renders as a +/// background fill (never an outline); SELECT activates like a tap. +class _ArtistLink extends StatefulWidget { + final String name; + final VoidCallback? onTap; + + const _ArtistLink({required this.name, this.onTap}); + + @override + State<_ArtistLink> createState() => _ArtistLinkState(); +} + +class _ArtistLinkState extends State<_ArtistLink> { + final FocusNode _focusNode = FocusNode(debugLabel: 'album_artist_link'); + bool _focused = false; + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .w500); + if (widget.onTap == null) return Text(widget.name, style: style); + + final showFocus = _focused && InputModeTracker.isKeyboardMode(context); + return Focus( + focusNode: _focusNode, + onFocusChange: (hasFocus) => setState(() => _focused = hasFocus), + onKeyEvent: dpadKeyHandler(onSelect: widget.onTap), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: FocusTheme.getAnimationDuration(context), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus), + child: Text(widget.name, style: style), + ), + ), + ), + ); + } +} diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart new file mode 100644 index 00000000..f720b8c9 --- /dev/null +++ b/lib/screens/music/artist_detail_screen.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../focus/focusable_action_bar.dart'; +import '../../focus/key_event_utils.dart'; +import '../../i18n/strings.g.dart'; +import '../../media/ids.dart'; +import '../../media/media_item.dart'; +import '../../mixins/grid_focus_node_mixin.dart'; +import '../../services/music/music_playback_service.dart'; +import '../../theme/mono_tokens.dart'; +import '../../utils/formatters.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'; +import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; +import '../../widgets/music/music_actions.dart'; +import '../../widgets/optimized_media_image.dart'; +import '../../widgets/overlay_sheet.dart'; +import '../base_media_list_detail_screen.dart'; +import '../focusable_detail_screen_mixin.dart'; + +/// Detail screen for a music artist: circular artist image, genres and +/// collapsible bio, Play/Shuffle/Instant Mix action row, and the artist's +/// albums as a square-card grid (album tap → album detail). +class ArtistDetailScreen extends StatefulWidget { + final MediaItem artist; + + const ArtistDetailScreen({super.key, required this.artist}); + + @override + State createState() => _ArtistDetailScreenState(); +} + +class _ArtistDetailScreenState extends BaseMediaListDetailScreen + with + GridFocusNodeMixin, + FocusableDetailScreenMixin, + StandardItemLoader { + final FocusNode _bioFocusNode = FocusNode(debugLabel: 'artist_bio'); + + @override + Object get mediaItem => widget.artist; + + @override + String get title => widget.artist.displayTitle; + + @override + String get emptyMessage => t.messages.noItemsAvailable; + + @override + bool get hasItems => items.isNotEmpty; + + @override + Future> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist.id); + + @override + String getLoadErrorMessage(Object error) => t.messages.errorLoading(error: error.toString()); + + @override + Future loadItems() async { + await super.loadItems(); + autoFocusFirstItemAfterLoad(); + } + + @override + void dispose() { + _bioFocusNode.dispose(); + disposeFocusResources(); + super.dispose(); + } + + /// Plays the artist's full track list. The tracks aren't part of the album + /// listing this screen loads, so this costs one extra server round-trip — + /// gated on playback availability first so the stub never fetches. + Future _playAll({bool shuffle = false}) async { + if (!ensureMusicPlaybackAvailable(context)) return; + List tracks; + try { + tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); + } catch (e) { + if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + return; + } + if (!mounted) return; + if (tracks.isEmpty) { + showAppSnackBar(context, emptyMessage); + return; + } + await playTracks( + context, + tracks: tracks, + playContext: MusicPlayContext( + id: widget.artist.id, + title: widget.artist.displayTitle, + kind: MusicPlayContextKind.artist, + ), + shuffle: shuffle, + ); + } + + @override + List getAppBarActions() { + final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.artist.serverId)); + return buildMusicActions( + onPlay: () => unawaited(_playAll()), + onShuffle: () => unawaited(_playAll(shuffle: true)), + onInstantMix: (client?.capabilities.instantMix ?? false) + ? () => unawaited(playInstantMix(context, widget.artist)) + : null, + ); + } + + Widget _buildHeader() { + final tk = tokens(context); + final textTheme = Theme.of(context).textTheme; + final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.artist.serverId)); + final genres = widget.artist.genres ?? const []; + final summary = widget.artist.summary; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + children: [ + ClipOval( + child: OptimizedMediaImage( + client: client, + imagePath: widget.artist.thumbPath, + imageType: ImageType.square, + width: 140, + height: 140, + fallbackIcon: Symbols.artist_rounded, + ), + ), + const SizedBox(height: 12), + Text(widget.artist.displayTitle, style: textTheme.titleLarge, textAlign: TextAlign.center), + if (genres.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + toBulletedString(genres), + style: textTheme.bodyMedium?.copyWith(color: tk.textMuted), + textAlign: TextAlign.center, + ), + ], + if (summary != null && summary.isNotEmpty) ...[ + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: CollapsibleText( + text: summary, + maxLines: 3, + style: textTheme.bodyMedium?.copyWith(color: tk.textMuted), + focusNode: _bioFocusNode, + skipTraversal: false, + ), + ), + ], + const SizedBox(height: 16), + FocusableActionBar( + key: actionBarKey, + spacing: 4, + actions: getAppBarActions(), + onNavigateDown: navigateToGrid, + onBack: () => Navigator.pop(context), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( + controller: scrollController, + child: OverlaySheetHost( + // Host owns sheet + system back: a back with a sheet open closes it; + // otherwise focus the action row first, then pop. + canPop: PlatformDetector.isHandheldIOS(context), + onSystemBack: () { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (handleBackNavigation() && mounted) Navigator.pop(context); + }, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + CustomAppBar(title: Text(widget.artist.displayTitle)), + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/music/music_playback_service.dart b/lib/services/music/music_playback_service.dart new file mode 100644 index 00000000..516a3426 --- /dev/null +++ b/lib/services/music/music_playback_service.dart @@ -0,0 +1,216 @@ +import 'package:flutter/foundation.dart'; + +import '../../media/lyrics.dart'; +import '../../media/media_item.dart'; + +/// Repeat behavior of the music queue. +enum MusicRepeatMode { off, all, one } + +/// Coarse playback state of the music session. +enum MusicPlaybackStatus { idle, loading, playing, paused, error } + +/// What kind of container playback was started from — drives the +/// "Playing from …" line in the player UI. +enum MusicPlayContextKind { album, artist, playlist, mix, tracks } + +/// Provenance of the current queue (album/artist/playlist/instant mix). +class MusicPlayContext { + /// Backend id of the source container, when it has one (instant mixes + /// don't). + final String? id; + + /// Display title ("Playing from {title}"). + final String title; + + final MusicPlayContextKind kind; + + const MusicPlayContext({this.id, required this.title, required this.kind}); +} + +/// Backend-neutral music playback session: owns the audio `Player`, the +/// queue (shuffle/repeat), OS media-session feed, and progress reporting. +/// +/// UI consumes this via `context.watch()` — it is +/// registered per profile session (see `profile_session_screen.dart`) so a +/// profile switch tears the session down. [notifyListeners] fires only on +/// discrete changes (track, status, queue shape, modes) — progress bars +/// subscribe to [positionStream] instead. +/// +/// [StubMusicPlaybackService] is registered until the playback engine lands; +/// UI gates transport affordances on [isAvailable]. +abstract class MusicPlaybackService extends ChangeNotifier { + /// False on the stub — playback affordances should render disabled or + /// fall back to a "not supported yet" notice. + bool get isAvailable; + + MediaItem? get currentTrack; + MusicPlaybackStatus get status; + bool get isPlaying => status == MusicPlaybackStatus.playing; + + Duration? get duration; + Duration get position; + Stream get positionStream; + + /// Full queue in playback order (shuffle already applied). + List get queue; + + /// Index of [currentTrack] within [queue]; -1 when idle. + int get currentIndex; + + MusicPlayContext? get playContext; + bool get shuffled; + MusicRepeatMode get repeatMode; + + /// Playback failures the UI should surface (snackbar); the service already + /// handles recovery (skip / stop) itself. + Stream get errors; + + /// Start a new queue from [tracks], optionally at [startTrack] (defaults + /// to the first track). [shuffle] shuffles with the start track anchored + /// first. + Future playFromList({ + required List tracks, + MediaItem? startTrack, + required MusicPlayContext playContext, + bool shuffle = false, + }); + + /// Fetch an instant mix seeded from [seed] and play it. + Future playInstantMix(MediaItem seed); + + Future play(); + Future pause(); + Future togglePlayPause(); + + /// Advance to the next queue entry (respecting repeat mode). + Future next(); + + /// Restart the current track when more than a few seconds in, otherwise + /// step to the previous queue entry. + Future previous(); + + Future seek(Duration position); + + void setRepeatMode(MusicRepeatMode mode); + void toggleShuffle(); + + /// Jump playback to queue index [index]. + Future jumpTo(int index); + + void removeAt(int index); + void reorder(int from, int to); + + /// Insert after the current track. + void addNext(List tracks); + void addToEnd(List tracks); + + /// Drop everything after the current track. + void clearUpcoming(); + + /// Stop playback and clear the session (mini-player disappears). + Future stop(); + + /// Lyrics for [track] (defaults to the current track's backend). Delegates + /// to `MediaServerClient.fetchLyrics`; null = none available. + Future fetchLyrics(MediaItem track); +} + +/// No-op placeholder bound while the playback engine is not wired yet (or +/// on platforms where it failed to initialize). Keeps every UI consumer +/// null-safe without per-call-site feature checks. +class StubMusicPlaybackService extends MusicPlaybackService { + @override + bool get isAvailable => false; + + @override + MediaItem? get currentTrack => null; + + @override + MusicPlaybackStatus get status => MusicPlaybackStatus.idle; + + @override + Duration? get duration => null; + + @override + Duration get position => Duration.zero; + + @override + Stream get positionStream => const Stream.empty(); + + @override + List get queue => const []; + + @override + int get currentIndex => -1; + + @override + MusicPlayContext? get playContext => null; + + @override + bool get shuffled => false; + + @override + MusicRepeatMode get repeatMode => MusicRepeatMode.off; + + @override + Stream get errors => const Stream.empty(); + + @override + Future playFromList({ + required List tracks, + MediaItem? startTrack, + required MusicPlayContext playContext, + bool shuffle = false, + }) async {} + + @override + Future playInstantMix(MediaItem seed) async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future togglePlayPause() async {} + + @override + Future next() async {} + + @override + Future previous() async {} + + @override + Future seek(Duration position) async {} + + @override + void setRepeatMode(MusicRepeatMode mode) {} + + @override + void toggleShuffle() {} + + @override + Future jumpTo(int index) async {} + + @override + void removeAt(int index) {} + + @override + void reorder(int from, int to) {} + + @override + void addNext(List tracks) {} + + @override + void addToEnd(List tracks) {} + + @override + void clearUpcoming() {} + + @override + Future stop() async {} + + @override + Future fetchLyrics(MediaItem track) async => null; +} diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 69d8765e..ebfc3ba3 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -47,6 +47,14 @@ class GridLayoutConstants { static const double episodeGridCellAspectRatio = 1.4; + /// 1:1 music artwork (albums/artists/tracks). Also the full-card image + /// ratio for square items, mirroring [fullCardPosterAspectRatio]. + static const double squareAspectRatio = 1 / 1; + + /// Square grid cell: 1:1 image plus the same text band the poster cell + /// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator). + static const double squareGridCellAspectRatio = 2 / 2.3; + static const double crossAxisSpacing = 0; static const double mainAxisSpacing = 0; diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index bc94aef3..fdc68134 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -11,6 +11,7 @@ enum ImageType { thumb, // 16:9 episode thumbnails logo, // Variable ratio clear logos avatar, // Square-ish user avatars + square, // 1:1 music artwork (albums, artists, tracks) } /// Backend-neutral image URL helper. @@ -116,6 +117,7 @@ class MediaImageHelper { return roundDimensions(thumbWidth, thumbHeight); case ImageType.avatar: + case ImageType.square: final size = min(targetWidth, targetHeight); return roundDimensions(size, size); @@ -232,6 +234,10 @@ class MediaImageHelper { // the tile budget on low-RAM hardware. ImageType.poster when DevicePerformance.isReduced => (480, 720), ImageType.poster => (720, 1080), + // Square music artwork fills the same grid cells as posters, so both + // axes cap at the poster width budget. + ImageType.square when DevicePerformance.isReduced => (480, 480), + ImageType.square => (720, 720), ImageType.thumb when DevicePerformance.isReduced => (640, 360), ImageType.thumb => (960, 540), ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight), diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index b4ed25ea..16ed6b57 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -11,6 +11,7 @@ import '../screens/media_detail_screen.dart'; import '../screens/playlist/playlist_detail_screen.dart'; import '../services/settings_service.dart'; import '../utils/global_key_utils.dart'; +import 'music_navigation.dart'; import 'plex_library_section_helpers.dart'; import 'video_player_navigation.dart'; @@ -22,7 +23,7 @@ enum MediaNavigationResult { /// Navigation completed, parent list should be refreshed (e.g., collection deleted) listRefreshNeeded, - /// Item type not supported (e.g., music content) + /// Item type not supported (e.g., photos) unsupported, /// Item is a library section — navigated to that library @@ -134,7 +135,8 @@ bool shouldOpenEpisodeDetailsForActivation({ /// For playlists, navigates to playlist detail screen. /// For collections, navigates to collection detail screen. /// For other types (shows), navigates to media detail screen. -/// For music types (artist, album, track), returns [MediaNavigationResult.unsupported]. +/// For artists/albums, navigates to the music detail screens; tracks start +/// playback in their album queue. /// /// The [onRefresh] callback is invoked with the item's id after returning from /// the detail screen, allowing the caller to refresh state. @@ -200,10 +202,18 @@ Future navigateToMediaItem( return MediaNavigationResult.navigated; case MediaKind.artist: + await navigateToArtist(context, mi); + return MediaNavigationResult.navigated; + case MediaKind.album: + await navigateToAlbum(context, mi); + return MediaNavigationResult.navigated; + case MediaKind.track: - // Music types not supported - return MediaNavigationResult.unsupported; + // Tracks start playback in their album queue instead of opening a + // detail surface. + await playTrackWithAlbumContext(context, mi); + return MediaNavigationResult.navigated; case MediaKind.clip: case MediaKind.episode: diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart new file mode 100644 index 00000000..deb6b19b --- /dev/null +++ b/lib/utils/music_navigation.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../screens/music/album_detail_screen.dart'; +import '../screens/music/artist_detail_screen.dart'; +import '../services/music/music_playback_service.dart'; +import 'app_logger.dart'; +import 'provider_extensions.dart'; +import 'snackbar_helper.dart'; + +/// Push the artist detail screen for [artist] on the nearest navigator. +Future navigateToArtist(BuildContext context, MediaItem artist) async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistDetailScreen(artist: artist))); +} + +/// Push the album detail screen for [album] on the nearest navigator. +Future navigateToAlbum(BuildContext context, MediaItem album) async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumDetailScreen(album: album))); +} + +/// True when a real music playback engine is bound. On the stub this shows +/// the standard "not supported yet" notice and returns false — check it +/// BEFORE fetching tracks so the stub never costs a server round-trip. +bool ensureMusicPlaybackAvailable(BuildContext context) { + if (context.read().isAvailable) return true; + showAppSnackBar(context, t.messages.musicNotSupported); + return false; +} + +/// Start playback of [tracks] via the session [MusicPlaybackService], +/// surfacing the "not supported yet" notice while the stub is bound. +Future playTracks( + BuildContext context, { + required List tracks, + MediaItem? startTrack, + required MusicPlayContext playContext, + bool shuffle = false, +}) async { + if (!ensureMusicPlaybackAvailable(context)) return; + await context.read().playFromList( + tracks: tracks, + startTrack: startTrack, + playContext: playContext, + shuffle: shuffle, + ); +} + +/// Play [track] within its album queue: fetch the album's tracks and start +/// at [track]. Falls back to single-track playback when the track has no +/// album, isn't found in it, or the album fetch fails. +Future playTrackWithAlbumContext(BuildContext context, MediaItem track) async { + if (!ensureMusicPlaybackAvailable(context)) return; + + final albumId = track.parentId; + final client = context.getMediaClientForItemOrNull(track); + if (albumId != null && client != null) { + try { + final tracks = await client.fetchAlbumTracks(albumId); + final startIndex = tracks.indexWhere((item) => item.id == track.id); + if (!context.mounted) return; + if (startIndex != -1) { + await playTracks( + context, + tracks: tracks, + startTrack: tracks[startIndex], + playContext: MusicPlayContext(id: albumId, title: track.albumTitle ?? '', kind: MusicPlayContextKind.album), + ); + return; + } + } catch (e) { + appLogger.w('Failed to fetch album context for track ${track.id}; playing single track', error: e); + if (!context.mounted) return; + } + } + + await playTracks( + context, + tracks: [track], + playContext: MusicPlayContext(title: track.title ?? '', kind: MusicPlayContextKind.tracks), + ); +} + +/// Fetch and play an instant mix seeded from [seed] (track/album/artist). +/// Only call when the seed's server advertises +/// `ServerCapabilities.instantMix`. +Future playInstantMix(BuildContext context, MediaItem seed) async { + if (!ensureMusicPlaybackAvailable(context)) return; + await context.read().playInstantMix(seed); +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index fd78ecf1..24348f5e 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -492,6 +492,11 @@ class HubSectionState extends State with MountedSetStateMixin, Skele final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + // Music hubs render square album/artist artwork + final isSquareHub = + widget.hub.items.isNotEmpty && + widget.hub.items.every((item) => item.cardShape(episodePosterMode) == CardShape.square); + // Card dimensions based on hub type const wideCardMultiplier = 1.5; final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; @@ -499,6 +504,8 @@ class HubSectionState extends State with MountedSetStateMixin, Skele final posterHeight = useWideLayout ? posterWidth * (9 / 16) // 16:9 for wide layout + : isSquareHub + ? posterWidth // 1:1 for music artwork : posterWidth * 1.5; // 2:3 for poster layout final containerHeight = posterHeight + (isTv ? 48 : 33); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index a0999c81..4595484d 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -19,6 +19,7 @@ import '../services/settings_service.dart'; import 'settings_builder.dart'; import 'watched_indicator.dart'; import '../utils/content_utils.dart'; +import '../utils/media_image_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/formatters.dart'; @@ -131,10 +132,27 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin - MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: _usesWideAspectRatio()); + double _posterWidth() => MediaCardListLayout.posterWidth(density: density, shape: _cardShape()); - double _posterHeight() => - MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: _usesWideAspectRatio()); + double _posterHeight() => MediaCardListLayout.posterHeight(density: density, shape: _cardShape()); double get _titleFontSize => 13 + LibraryDensity.factor(density) * 3; // 13–16 @@ -523,6 +541,10 @@ class _MediaCardList extends StatelessWidget { } else if (item is MediaItem) { final mi = item as MediaItem; + // Music: a track's parentIndex/index are disc/track numbers, not S#E#. + if (mi.kind == MediaKind.album) return mi.albumArtistTitle; + if (mi.kind == MediaKind.track) return mi.trackArtistTitle; + if (mi.parentIndex != null && mi.index != null) { final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards); return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}'; @@ -603,9 +625,10 @@ class _MediaCardList extends StatelessWidget { height: _posterHeight(), child: Stack( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: _buildPosterImage( + _clipPosterImage( + context, + item, + _buildPosterImage( context, item, isOffline: isOffline, @@ -613,7 +636,8 @@ class _MediaCardList extends StatelessWidget { episodePosterModeOverride: episodePosterModeOverride, ), ), - if (item is MediaItem) WatchedIndicator(item: item as MediaItem), + if (item is MediaItem && _showsWatchedIndicator(item as MediaItem)) + WatchedIndicator(item: item as MediaItem), ], ), ), @@ -723,10 +747,37 @@ Widget _buildPosterLoadingPlaceholder(BuildContext context, String _) { } IconData _mediaPosterFallbackIcon(MediaItem item) { + if (item.kind == MediaKind.artist) return Symbols.artist_rounded; + if (item.kind == MediaKind.album) return Symbols.album_rounded; + if (item.kind == MediaKind.track) return Symbols.music_note_rounded; if (item.isShow || item.isSeason || item.isEpisode) return Symbols.tv_rounded; return Symbols.movie_rounded; } +/// Oversized radius for circular focus borders: [CardFocusBorder] paints a +/// BoxDecoration border whose corner radii are clamped to the box, so on a +/// square image area this renders a ring hugging the circular artist artwork. +const double _circularFocusRadius = 9999; + +bool _isArtist(Object item) => item is MediaItem && item.kind == MediaKind.artist; + +/// Artist artwork clips to a circle; everything else keeps the standard +/// rounded rect. +Widget _clipPosterImage(BuildContext context, Object item, Widget image) { + if (_isArtist(item)) return ClipOval(child: image); + return ClipRRect(borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: image); +} + +/// Focus border radius matching [_clipPosterImage]'s clip shape. +double _posterFocusRadius(BuildContext context, Object item) => + _isArtist(item) ? _circularFocusRadius : tokens(context).radiusSm; + +/// Watched/progress overlays are suppressed for artists: a corner checkmark +/// sits outside the circular artwork and play-state on an artist is noise. +/// Albums/tracks keep the standard treatment (albums have no in-progress +/// state to draw; tracks can show watched/resume state). +bool _showsWatchedIndicator(MediaItem item) => item.kind != MediaKind.artist; + Widget _buildPosterImage( BuildContext context, Object item, { @@ -766,8 +817,21 @@ Widget _buildPosterImage( Widget image; - // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) - if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { + // Square 1:1 artwork for music (artists/albums/tracks) + if (item.kind.isMusic) { + image = OptimizedMediaImage( + client: mediaClient, + imagePath: posterUrl, + width: knownWidth ?? double.infinity, + height: knownHeight ?? double.infinity, + fit: BoxFit.cover, + placeholder: _buildPosterLoadingPlaceholder, + fallbackIcon: fallbackIcon, + imageType: ImageType.square, + localFilePath: localPosterPath, + ); + } else if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { + // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) image = OptimizedMediaImage.thumb( client: mediaClient, imagePath: posterUrl, @@ -852,6 +916,19 @@ class _MediaCardHelpers { } } + // For albums, show the album artist + if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) { + return Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle); + } + + // For tracks, show "Artist • duration" + if (mi.kind == MediaKind.track) { + final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)]; + if (parts.isNotEmpty) { + return Text(parts.join(' • '), maxLines: 1, overflow: .ellipsis, style: subtitleStyle); + } + } + // For episodes, show "S# · Episode Title" with clickable season link if (mi.isEpisode && mi.parentIndex != null) { final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; diff --git a/lib/widgets/media_card_list_layout.dart b/lib/widgets/media_card_list_layout.dart index 8e65471b..eb536229 100644 --- a/lib/widgets/media_card_list_layout.dart +++ b/lib/widgets/media_card_list_layout.dart @@ -1,3 +1,4 @@ +import '../media/media_item.dart' show CardShape; import '../services/settings_service.dart' show LibraryDensity; /// Shared sizing math for media cards rendered in list mode. @@ -8,18 +9,26 @@ class MediaCardListLayout { return 70 + LibraryDensity.factor(density) * 50; } - static double posterWidth({required int density, required bool usesWideAspectRatio}) { + /// [shape] wins over [usesWideAspectRatio] when provided. + static double posterWidth({required int density, bool usesWideAspectRatio = false, CardShape? shape}) { final base = basePosterWidth(density); - return usesWideAspectRatio ? base * 1.6 : base; + return _resolveShape(shape, usesWideAspectRatio) == CardShape.wide ? base * 1.6 : base; } - static double posterHeight({required int density, required bool usesWideAspectRatio}) { + static double posterHeight({required int density, bool usesWideAspectRatio = false, CardShape? shape}) { final base = basePosterWidth(density); - return usesWideAspectRatio ? base * 0.9 : base * 1.5; + return switch (_resolveShape(shape, usesWideAspectRatio)) { + CardShape.wide => base * 0.9, + CardShape.square => base, + CardShape.poster => base * 1.5, + }; } - static double estimatedRowHeight({required int density, required bool usesWideAspectRatio}) { - final poster = posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio); + static double estimatedRowHeight({required int density, bool usesWideAspectRatio = false, CardShape? shape}) { + final poster = posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio, shape: shape); return poster + padding * 2; } + + static CardShape _resolveShape(CardShape? shape, bool usesWideAspectRatio) => + shape ?? (usesWideAspectRatio ? CardShape.wide : CardShape.poster); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 0e3701d7..dbf8752e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { _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()?.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()?.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()?.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 { 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 { 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> _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 _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 _handleMusicEnqueue(BuildContext context, {required bool playNext}) async { + final service = context.read(); + // 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 diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index c20be11b..d07ce530 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/media_item.dart' show CardShape; import '../utils/grid_size_calculator.dart'; import '../utils/layout_constants.dart'; @@ -10,6 +11,8 @@ class MediaGridDelegate { /// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default. /// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead. /// Set [useWideAspectRatio] to true to use 16:9 aspect ratio for episode thumbnails. + /// Pass [shape] to select the cell silhouette directly — it wins over + /// [useWideAspectRatio]; square cells keep the poster max extent. /// Set [fullBleedImage] to true when the card is image-only and should not reserve text height. /// Pass [maxCrossAxisExtentOverride] to bypass the calculator and the wide-aspect multiplier — /// the caller is then responsible for providing a fully-resolved per-cell width. @@ -20,9 +23,14 @@ class MediaGridDelegate { double horizontalPadding = 16, bool useWideAspectRatio = false, bool fullBleedImage = false, + CardShape? shape, double? maxCrossAxisExtentOverride, }) { - final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage); + final aspectRatio = aspectRatioFor( + useWideAspectRatio: useWideAspectRatio, + fullBleedImage: fullBleedImage, + shape: shape, + ); final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage); final maxCrossAxisExtent = @@ -33,6 +41,7 @@ class MediaGridDelegate { usePaddingAware: usePaddingAware, horizontalPadding: horizontalPadding, useWideAspectRatio: useWideAspectRatio, + shape: shape, ); return SliverGridDelegateWithMaxCrossAxisExtent( @@ -43,14 +52,21 @@ class MediaGridDelegate { ); } + /// Resolves the shape from the optional [shape] parameter, falling back to + /// the legacy wide-vs-poster bool so existing call sites are byte-identical. + static CardShape _resolveShape(CardShape? shape, bool useWideAspectRatio) => + shape ?? (useWideAspectRatio ? CardShape.wide : CardShape.poster); + /// Resolves the max cross-axis extent the way [createDelegate] does, - /// including the 1.8x widening for 16:9 episode thumbnails. + /// including the 1.8x widening for 16:9 episode thumbnails. Square cells + /// keep the poster extent so column counts match the poster grid. static double _maxCrossAxisExtentFor({ required BuildContext context, required int density, required bool usePaddingAware, required double horizontalPadding, required bool useWideAspectRatio, + CardShape? shape, }) { var maxCrossAxisExtent = usePaddingAware ? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) @@ -58,7 +74,7 @@ class MediaGridDelegate { // For wide aspect ratio (16:9), increase max extent so items are larger // and there are fewer per row (roughly 1.8x wider to maintain similar visual area) - if (useWideAspectRatio) { + if (_resolveShape(shape, useWideAspectRatio) == CardShape.wide) { maxCrossAxisExtent *= 1.8; } return maxCrossAxisExtent; @@ -69,14 +85,21 @@ class MediaGridDelegate { return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); } - static double aspectRatioFor({bool useWideAspectRatio = false, bool fullBleedImage = false}) { + static double aspectRatioFor({bool useWideAspectRatio = false, bool fullBleedImage = false, CardShape? shape}) { + final resolved = _resolveShape(shape, useWideAspectRatio); if (fullBleedImage) { - return useWideAspectRatio - ? GridLayoutConstants.episodeThumbnailAspectRatio - : GridLayoutConstants.fullCardPosterAspectRatio; + return switch (resolved) { + CardShape.wide => GridLayoutConstants.episodeThumbnailAspectRatio, + CardShape.square => GridLayoutConstants.squareAspectRatio, + CardShape.poster => GridLayoutConstants.fullCardPosterAspectRatio, + }; } - return useWideAspectRatio ? GridLayoutConstants.episodeGridCellAspectRatio : GridLayoutConstants.posterAspectRatio; + return switch (resolved) { + CardShape.wide => GridLayoutConstants.episodeGridCellAspectRatio, + CardShape.square => GridLayoutConstants.squareGridCellAspectRatio, + CardShape.poster => GridLayoutConstants.posterAspectRatio, + }; } } @@ -119,11 +142,13 @@ class MediaGridGeometry { double horizontalPadding = 16, bool useWideAspectRatio = false, bool fullBleedImage = false, + CardShape? shape, }) { final spacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullBleedImage); final aspectRatio = MediaGridDelegate.aspectRatioFor( useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage, + shape: shape, ); final maxCrossAxisExtent = MediaGridDelegate._maxCrossAxisExtentFor( context: context, @@ -131,6 +156,7 @@ class MediaGridGeometry { usePaddingAware: usePaddingAware, horizontalPadding: horizontalPadding, useWideAspectRatio: useWideAspectRatio, + shape: shape, ); final columnCount = GridSizeCalculator.getColumnCount( diff --git a/lib/widgets/music/music_actions.dart b/lib/widgets/music/music_actions.dart new file mode 100644 index 00000000..99846e28 --- /dev/null +++ b/lib/widgets/music/music_actions.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../focus/focusable_action_bar.dart'; +import '../../i18n/strings.g.dart'; +import '../app_icon.dart'; + +/// Standard action list for the music detail screens (album/artist): +/// a labeled Play pill, a shuffle icon, an optional Instant Mix icon (pass +/// null when the server lacks the capability), and an optional [trailing] +/// action (the album overflow ⋮). Render inside a [FocusableActionBar] — +/// icon actions get the bar's default focus-background treatment. +List buildMusicActions({ + required VoidCallback onPlay, + required VoidCallback onShuffle, + VoidCallback? onInstantMix, + FocusableAction? trailing, +}) { + return [ + FocusableAction( + debugLabel: 'music_play', + onPressed: onPlay, + builder: (context, state) => _MusicPlayButton(onPressed: onPlay, showFocus: state.showFocus), + ), + FocusableAction( + debugLabel: 'music_shuffle', + icon: Symbols.shuffle_rounded, + tooltip: t.common.shuffle, + onPressed: onShuffle, + ), + if (onInstantMix != null) + FocusableAction( + debugLabel: 'music_instant_mix', + icon: Symbols.instant_mix_rounded, + tooltip: t.music.instantMix, + onPressed: onInstantMix, + ), + ?trailing, + ]; +} + +/// Labeled Play pill. D-pad focus swaps the background to the inverse +/// surface (no outline), mirroring the media detail action row. +class _MusicPlayButton extends StatelessWidget { + final VoidCallback onPressed; + final bool showFocus; + + const _MusicPlayButton({required this.onPressed, required this.showFocus}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return FilledButton.icon( + onPressed: onPressed, + style: showFocus + ? FilledButton.styleFrom( + backgroundColor: colorScheme.inverseSurface, + foregroundColor: colorScheme.onInverseSurface, + ) + : null, + icon: const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 20), + label: Text(t.common.play, style: const TextStyle(fontWeight: .w700)), + ); + } +} diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart new file mode 100644 index 00000000..3cad3f71 --- /dev/null +++ b/lib/widgets/music/track_row.dart @@ -0,0 +1,364 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../focus/focusable_tile_mixin.dart'; +import '../../focus/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; +import '../../media/media_item.dart'; +import '../../mixins/context_menu_tap_mixin.dart'; +import '../../services/device_performance.dart'; +import '../../services/music/music_playback_service.dart'; +import '../../theme/mono_tokens.dart'; +import '../../utils/formatters.dart'; +import '../app_icon.dart'; +import '../media_context_menu.dart'; + +/// List row for a music track: +/// `[track # | equalizer] [title + optional artist] [duration] [⋮]`. +/// +/// Rows sit inside the M3E grouped-card idiom (see `SettingsGroup`): +/// [isFirst]/[isLast] pick large outer / small inner corner radii, and the +/// hosting list inserts `tokens.groupGap` between adjacent rows. +/// +/// D-pad model: one focus node with two columns — column 0 is the row itself +/// (SELECT = [onTap]), RIGHT moves the highlight to the ⋮ button (LEFT +/// returns; SELECT there opens the same context menu as long-press / +/// right-click). Focus is rendered as a background fill, never an outline. +class TrackRow extends StatefulWidget { + /// Fixed row height, sized for title + optional subtitle. + static const double height = 56; + + final MediaItem item; + final VoidCallback? onTap; + final void Function(String itemId)? onRefresh; + + /// Grouped-card corner shaping (see class doc). + final bool isFirst; + final bool isLast; + + /// Always show the performing-artist subtitle — for surfaces outside an + /// album context. Within an album the subtitle only appears when the track + /// artist differs from the album artist (compilations). + final bool showArtist; + + /// Optional external focus node for programmatic focus control. + final FocusNode? focusNode; + + /// Called on UP from the row (wired by the host on the first row only, so + /// the list edge escapes to the action bar). + final VoidCallback? onNavigateUp; + + /// Called on BACK while the row is focused. + final VoidCallback? onBack; + + final ValueChanged? onFocusChange; + + const TrackRow({ + super.key, + required this.item, + this.onTap, + this.onRefresh, + this.isFirst = false, + this.isLast = false, + this.showArtist = false, + this.focusNode, + this.onNavigateUp, + this.onBack, + this.onFocusChange, + }); + + @override + State createState() => _TrackRowState(); +} + +class _TrackRowState extends State with ContextMenuTapMixin, FocusableTileStateMixin { + /// 0 = row (SELECT plays), 1 = ⋮ button (SELECT opens the context menu). + int _focusedColumn = 0; + bool _hasFocus = false; + + @override + FocusNode? get widgetFocusNode => widget.focusNode; + + @override + void initState() { + super.initState(); + initFocusNode(); + } + + @override + void didUpdateWidget(TrackRow oldWidget) { + super.didUpdateWidget(oldWidget); + updateFocusNode(oldWidget.focusNode); + } + + @override + void dispose() { + disposeFocusNode(); + super.dispose(); + } + + void _handleFocusChange(bool hasFocus) { + setState(() { + _hasFocus = hasFocus; + if (!hasFocus) _focusedColumn = 0; + }); + widget.onFocusChange?.call(hasFocus); + } + + void _activateFocusedColumn() { + if (_focusedColumn == 0) { + widget.onTap?.call(); + } else { + // Keyboard/gamepad activation — the menu centers on the row. + showContextMenu(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) return backResult; + } + return dpadKeyHandler( + onSelect: _activateFocusedColumn, + onUp: widget.onNavigateUp, + onLeft: _focusedColumn == 1 ? () => setState(() => _focusedColumn = 0) : null, + onRight: _focusedColumn == 0 ? () => setState(() => _focusedColumn = 1) : null, + // Detail screens have nothing beside the list — keep focus on the row. + trapHorizontalEdges: true, + )(node, event); + } + + void _showMenuAt(BuildContext buttonContext) { + final box = buttonContext.findRenderObject() as RenderBox?; + Offset? position; + if (box != null) position = box.localToGlobal(box.size.center(Offset.zero)); + contextMenuKey.currentState?.showContextMenu(context, position: position); + } + + String? get _subtitle { + final trackArtist = widget.item.trackArtistTitle; + if (trackArtist == null || trackArtist.isEmpty) return null; + if (widget.showArtist) return trackArtist; + return trackArtist != widget.item.albumArtistTitle ? trackArtist : null; + } + + @override + Widget build(BuildContext context) { + final tk = tokens(context); + final colorScheme = Theme.of(context).colorScheme; + + // Both selects run unconditionally every build (provider contract). + final isCurrent = context.select((s) => s.currentTrack?.id == widget.item.id); + final serviceIsPlaying = context.select((s) => s.isPlaying); + + final showFocus = _hasFocus && InputModeTracker.isKeyboardMode(context); + final radii = BorderRadius.vertical( + top: Radius.circular(widget.isFirst ? tk.radiusLg : tk.radiusXs), + bottom: Radius.circular(widget.isLast ? tk.radiusLg : tk.radiusXs), + ); + + final subtitle = _subtitle; + final durationMs = widget.item.durationMs; + + return MediaContextMenu( + key: contextMenuKey, + item: widget.item, + onRefresh: widget.onRefresh, + onTap: widget.onTap, + child: Focus( + focusNode: effectiveFocusNode, + descendantsAreFocusable: false, + onKeyEvent: _handleKeyEvent, + onFocusChange: _handleFocusChange, + child: Material( + color: tk.surface, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: radii), + child: InkWell( + mouseCursor: SystemMouseCursors.click, + onTap: widget.onTap, + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, + child: Container( + height: TrackRow.height, + // Text-based fill (mono theme focusColor convention) — the + // white-based FocusTheme fill is invisible on the light row + // surface. + decoration: BoxDecoration( + borderRadius: radii, + color: showFocus && _focusedColumn == 0 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, + ), + padding: const EdgeInsets.only(left: 12, right: 4), + child: Row( + children: [ + SizedBox( + width: 32, + child: Center( + child: isCurrent + ? _EqualizerIcon(animate: serviceIsPlaying, color: colorScheme.primary) + : Text( + '${widget.item.trackNumber ?? ''}', + style: TextStyle(fontSize: 13, color: tk.textMuted), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [ + Text( + widget.item.title ?? '', + maxLines: 1, + overflow: .ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, + color: isCurrent ? tk.text : null, + ), + ), + if (subtitle != null) + Text( + subtitle, + maxLines: 1, + overflow: .ellipsis, + style: TextStyle(fontSize: 12, color: tk.textMuted), + ), + ], + ), + ), + const SizedBox(width: 8), + if (durationMs != null) + Text( + formatDurationTimestamp(Duration(milliseconds: durationMs)), + style: TextStyle(fontSize: 13, color: tk.textMuted), + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: showFocus && _focusedColumn == 1 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, + ), + child: Builder( + builder: (buttonContext) => IconButton( + icon: AppIcon(Symbols.more_vert_rounded, fill: 1, size: 20, color: tk.textMuted), + onPressed: () => _showMenuAt(buttonContext), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Small 3-bar "now playing" indicator. Bars animate while [animate] is true; +/// on the reduced visual-effects tier they render static regardless (each +/// animation frame re-rasterizes the row on weak TV GPUs). +class _EqualizerIcon extends StatefulWidget { + final bool animate; + final Color color; + + const _EqualizerIcon({required this.animate, required this.color}); + + @override + State<_EqualizerIcon> createState() => _EqualizerIconState(); +} + +class _EqualizerIconState extends State<_EqualizerIcon> with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + bool get _shouldAnimate => widget.animate && !DevicePerformance.isReduced; + + @override + void initState() { + super.initState(); + _syncAnimation(); + } + + @override + void didUpdateWidget(_EqualizerIcon oldWidget) { + super.didUpdateWidget(oldWidget); + _syncAnimation(); + } + + void _syncAnimation() { + if (_shouldAnimate) { + if (!_controller.isAnimating) _controller.repeat(); + } else { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 16, + height: 14, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => CustomPaint( + painter: _EqualizerPainter(t: _controller.value, color: widget.color, animate: _shouldAnimate), + ), + ), + ); + } +} + +class _EqualizerPainter extends CustomPainter { + final double t; + final Color color; + final bool animate; + + /// Static bar heights (fraction of full height) for the paused/reduced look. + static const List _staticHeights = [0.55, 0.9, 0.4]; + + /// Per-bar phase offsets so the animated bars move out of step. + static const List _phases = [0.0, 0.35, 0.7]; + + const _EqualizerPainter({required this.t, required this.color, required this.animate}); + + @override + void paint(Canvas canvas, Size size) { + const barCount = 3; + const gap = 2.5; + final barWidth = (size.width - gap * (barCount - 1)) / barCount; + final paint = Paint()..color = color; + + for (var i = 0; i < barCount; i++) { + final fraction = animate ? 0.3 + 0.7 * (0.5 + 0.5 * math.sin(2 * math.pi * (t + _phases[i]))) : _staticHeights[i]; + final barHeight = size.height * fraction; + final left = i * (barWidth + gap); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, size.height - barHeight, barWidth, barHeight), + const Radius.circular(1.5), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_EqualizerPainter oldDelegate) => + oldDelegate.t != t || oldDelegate.color != color || oldDelegate.animate != animate; +} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 3464160b..0959646e 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -142,6 +142,11 @@ class TvBrowseRailLayout { final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); final isMixedHub = hasWide && hasTall; final useWideLayout = hasWide && (!hasTall || episodePosterMode == EpisodePosterMode.episodeThumbnail); + // Music hubs render square album/artist artwork (person hubs are already square). + final isSquareHub = + !isPersonHub && + hub.items.isNotEmpty && + hub.items.every((item) => item.cardShape(episodePosterMode) == CardShape.square); final baseCardWidth = cardWidthFor( availableWidth: availableWidth, density: density, @@ -152,7 +157,9 @@ class TvBrowseRailLayout { ); final cardWidth = baseCardWidth * (useWideLayout ? widePosterScale : tallPosterScale); final posterWidth = fullCardLayout ? cardWidth : cardWidth - (6 * scale); - final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); + final posterHeight = (isPersonHub || isSquareHub) + ? posterWidth + : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); final labelHeight = fullCardLayout ? 0.0 : ((isPersonHub ? 58 : 42) * scale); final containerHeight = (posterHeight + labelHeight).ceilToDouble(); final height = containerHeight + focusExtra + (14 * scale); diff --git a/test/screens/libraries/library_browse_grouping_test.dart b/test/screens/libraries/library_browse_grouping_test.dart index 9335e83d..de5d8a5e 100644 --- a/test/screens/libraries/library_browse_grouping_test.dart +++ b/test/screens/libraries/library_browse_grouping_test.dart @@ -38,6 +38,22 @@ void main() { ]); }); + test('music libraries group by artists, albums, and tracks', () { + final library = _library(kind: MediaKind.artist); + + expect(libraryBrowseGroupingOptions(library, canGroupByFolders: false), const [ + browseGroupingArtists, + browseGroupingAlbums, + browseGroupingTracks, + ]); + expect(libraryBrowseGroupingOptions(library, canGroupByFolders: true), const [ + browseGroupingArtists, + browseGroupingAlbums, + browseGroupingTracks, + browseGroupingFolders, + ]); + }); + test('shared libraries expose all video groupings and never folders', () { final library = _library(kind: MediaKind.movie, isShared: true); @@ -70,6 +86,16 @@ void main() { ); }); + test('music libraries default to artists and keep a saved music grouping', () { + final library = _library(kind: MediaKind.artist); + + expect(normalizeLibraryBrowseGrouping(library, null, canGroupByFolders: false), browseGroupingArtists); + expect( + normalizeLibraryBrowseGrouping(library, browseGroupingTracks, canGroupByFolders: false), + browseGroupingTracks, + ); + }); + test('shared libraries default to all', () { final library = _library(kind: MediaKind.movie, isShared: true); diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart new file mode 100644 index 00000000..2996d459 --- /dev/null +++ b/test/screens/music/album_detail_screen_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.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/album_detail_screen.dart'; +import 'package:plezy/services/data_aggregation_service.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/music/track_row.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + testWidgets('renders header and track rows with disc headers for a multi-disc album', (tester) async { + final harness = await _createHarness(_multiDiscTracks()); + + await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album))); + await tester.pumpAndSettle(); + + // Header: album title (app bar + header), tappable artist line, metadata. + expect(find.text('Test Album'), findsWidgets); + expect(find.text('Test Artist'), findsOneWidget); + expect(find.textContaining('2001'), findsOneWidget); + expect(find.textContaining(t.music.trackCount(n: 3)), findsOneWidget); + + // Track rows, grouped under per-disc headers. + expect(find.byType(TrackRow), findsNWidgets(3)); + expect(find.text(t.music.discNumber(n: 1)), findsOneWidget); + expect(find.text(t.music.discNumber(n: 2)), findsOneWidget); + expect(find.text('Track One'), findsOneWidget); + expect(find.text('Track Two'), findsOneWidget); + expect(find.text('Track Three'), findsOneWidget); + + // Track numbers restart per disc. + expect(find.text('1'), findsNWidgets(2)); + }); + + testWidgets('tapping a track on the stub service shows the not-supported notice', (tester) async { + final harness = await _createHarness(_multiDiscTracks()); + + await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Track One')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text(t.messages.musicNotSupported), findsOneWidget); + }); +} + +const _album = MediaItem.plex( + id: 'album_1', + kind: MediaKind.album, + title: 'Test Album', + parentId: 'artist_1', + parentTitle: 'Test Artist', + year: 2001, + serverId: 'server_1', + serverName: 'Server', +); + +List _multiDiscTracks() { + MediaItem track({required String id, required String title, required int disc, required int number}) { + return MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.track, + title: title, + parentId: 'album_1', + parentTitle: 'Test Album', + grandparentId: 'artist_1', + grandparentTitle: 'Test Artist', + parentIndex: disc, + index: number, + durationMs: 200000, + serverId: 'server_1', + serverName: 'Server', + ); + } + + return [ + track(id: 'track_1', title: 'Track One', disc: 1, number: 1), + track(id: 'track_2', title: 'Track Two', disc: 1, number: 2), + track(id: 'track_3', title: 'Track Three', disc: 2, number: 1), + ]; +} + +Future<_AlbumHarness> _createHarness(List tracks) async { + await SettingsService.getInstance(); + + final client = _FakeMusicClient(tracks); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + + addTearDown(multiServerProvider.dispose); + + return _AlbumHarness(client: client, multiServerProvider: multiServerProvider); +} + +class _AlbumHarness { + final _FakeMusicClient client; + final MultiServerProvider multiServerProvider; + + const _AlbumHarness({required this.client, required this.multiServerProvider}); + + Widget wrap(Widget child) { + return TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider(create: (_) => StubMusicPlaybackService()), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: child), + ), + ), + ); + } +} + +class _FakeMusicClient implements MediaServerClient { + final List tracks; + final List fetchedAlbumIds = []; + + _FakeMusicClient(this.tracks); + + @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> fetchAlbumTracks(String albumId) async { + fetchedAlbumIds.add(albumId); + return tracks; + } + + @override + String thumbnailUrl(String? path, {int? width, int? height}) => ''; + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart new file mode 100644 index 00000000..4cc08c82 --- /dev/null +++ b/test/widgets/media_card_square_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/layout_constants.dart'; +import 'package:plezy/utils/media_image_helper.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:plezy/widgets/media_card_list_layout.dart'; +import 'package:plezy/widgets/media_grid_delegate.dart'; +import 'package:plezy/widgets/optimized_media_image.dart'; +import 'package:plezy/widgets/watched_indicator.dart'; + +import '../test_helpers/prefs.dart'; + +MediaItem _item(MediaKind kind, {String? parentTitle, int? durationMs}) => MediaItem( + id: '${kind.id}_1', + backend: MediaBackend.plex, + kind: kind, + title: 'Test ${kind.id}', + parentTitle: parentTitle, + durationMs: durationMs, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + test('music items resolve to the square card shape', () { + for (final kind in [MediaKind.artist, MediaKind.album, MediaKind.track]) { + expect(_item(kind).cardShape(EpisodePosterMode.seriesPoster), CardShape.square); + expect(_item(kind).cardShape(EpisodePosterMode.episodeThumbnail), CardShape.square); + } + expect(_item(MediaKind.movie).cardShape(EpisodePosterMode.seriesPoster), CardShape.poster); + expect(_item(MediaKind.episode).cardShape(EpisodePosterMode.episodeThumbnail), CardShape.wide); + }); + + test('square grid delegates use square aspect ratios, defaults unchanged', () { + expect(MediaGridDelegate.aspectRatioFor(shape: CardShape.square), GridLayoutConstants.squareGridCellAspectRatio); + expect( + MediaGridDelegate.aspectRatioFor(shape: CardShape.square, fullBleedImage: true), + GridLayoutConstants.squareAspectRatio, + ); + // Shape wins over the legacy bool when both are provided. + expect( + MediaGridDelegate.aspectRatioFor(shape: CardShape.square, useWideAspectRatio: true), + GridLayoutConstants.squareGridCellAspectRatio, + ); + // Existing behavior is untouched when shape isn't passed. + expect(MediaGridDelegate.aspectRatioFor(), GridLayoutConstants.posterAspectRatio); + expect(MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true), GridLayoutConstants.episodeGridCellAspectRatio); + }); + + test('list layout sizes square cards 1:1', () { + final base = MediaCardListLayout.basePosterWidth(LibraryDensity.defaultValue); + expect(MediaCardListLayout.posterWidth(density: LibraryDensity.defaultValue, shape: CardShape.square), base); + expect(MediaCardListLayout.posterHeight(density: LibraryDensity.defaultValue, shape: CardShape.square), base); + // Legacy bool call sites are untouched. + expect( + MediaCardListLayout.posterHeight(density: LibraryDensity.defaultValue, usesWideAspectRatio: false), + base * 1.5, + ); + }); + + testWidgets('album grid card renders a square rounded image with square image type', (tester) async { + // Hub-style explicit dimensions: cardWidth 200 -> posterWidth 194, square height 194. + await tester.pumpWidget( + _TestApp( + child: MediaCard( + item: _item(MediaKind.album, parentTitle: 'Album Artist'), + width: 200, + height: 194, + forceGridMode: true, + isOffline: true, + ), + ), + ); + + final clip = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)); + expect(tester.getSize(clip.first), const Size(194, 194)); + expect(find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipOval)), findsNothing); + expect(tester.widget(find.byType(OptimizedMediaImage)).imageType, ImageType.square); + // Albums keep the watched overlay; subtitle shows the album artist. + expect(find.byType(WatchedIndicator), findsOneWidget); + expect(find.text('Album Artist'), findsOneWidget); + }); + + testWidgets('artist grid card clips to a circle and skips the watched overlay', (tester) async { + await tester.pumpWidget( + _TestApp( + child: MediaCard(item: _item(MediaKind.artist), width: 200, height: 194, forceGridMode: true, isOffline: true), + ), + ); + + final oval = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipOval)); + expect(tester.getSize(oval), const Size(194, 194)); + expect(tester.widget(find.byType(OptimizedMediaImage)).imageType, ImageType.square); + expect(find.byType(WatchedIndicator), findsNothing); + }); + + testWidgets('movie grid card still renders the 2:3 poster', (tester) async { + await tester.pumpWidget( + _TestApp( + child: MediaCard(item: _item(MediaKind.movie), width: 200, height: 291, forceGridMode: true, isOffline: true), + ), + ); + + final clip = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)); + expect(tester.getSize(clip.first), const Size(194, 291)); + expect(find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipOval)), findsNothing); + expect(tester.widget(find.byType(OptimizedMediaImage)).imageType, ImageType.poster); + }); + + testWidgets('track list card uses a square image area', (tester) async { + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 420, + height: 160, + child: MediaCard( + item: _item(MediaKind.track, parentTitle: 'Album', durationMs: 200000), + forceListMode: true, + isOffline: true, + ), + ), + ), + ); + + final base = MediaCardListLayout.basePosterWidth(LibraryDensity.defaultValue); + final imageBox = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first; + expect(tester.getSize(imageBox), Size(base, base)); + }); +} + +class _TestApp extends StatelessWidget { + final Widget child; + + const _TestApp({required this.child}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold(body: Center(child: child)), + ); + } +}