fix(music): keep collection and playlist artwork square

This commit is contained in:
edde746
2026-07-16 12:37:02 +02:00
parent d253450e9d
commit 517c3f57ce
13 changed files with 586 additions and 40 deletions
@@ -50,6 +50,11 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
@override
bool get hasItems => totalSize > 0;
CardShape? get _contentShape {
final loaded = loadedItems.values;
return loaded.isNotEmpty && loaded.every((item) => item.kind.isMusic) ? CardShape.square : null;
}
@override
void dispose() {
disposePagination();
@@ -240,6 +245,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize),
collectionId: widget.collection.id,
onListRefresh: loadItems,
shape: _contentShape,
),
],
);
+10 -4
View File
@@ -177,13 +177,14 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: items.length,
density: libraryDensity,
padding: const EdgeInsets.all(8),
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
shape: shape,
itemBuilder: (context, position) {
final index = position.index;
@@ -198,7 +199,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout && position.isGrid,
fullBleedImage: useFullCardLayout && position.isGrid,
cardShapeOverride: shape,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -220,6 +222,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
void Function(int index)? onSkeletonVisible,
String? collectionId,
VoidCallback? onListRefresh,
CardShape? shape,
}) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
@@ -228,6 +231,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) {
final item = itemAt(index);
@@ -244,7 +248,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout && !disableScale,
fullBleedImage: useFullCardLayout && !disableScale,
cardShapeOverride: shape,
onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -256,7 +261,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
itemCount: totalItems,
density: libraryDensity,
padding: const EdgeInsets.all(8),
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
shape: shape,
itemBuilder: (context, position) =>
buildTile(position.index, inFirstRow: position.isFirstRow, disableScale: position.disableScale),
);
@@ -137,15 +137,21 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
return base.copyWith(top: base.top + _focusDecorationPadding);
}
bool get _usesSquareCards => widget.library.kind.isMusic;
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = _usesSquareCards ? CardShape.square : null;
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
fullBleedImage: fullCardLayout,
listEpoch: (ViewMode.list, totalSize, density),
gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density),
fullBleedImage: useFullCardLayout,
shape: shape,
listEpoch: (ViewMode.list, totalSize, density, shape),
gridEpochBuilder: (geometry) =>
(ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape),
itemBuilder: (context, position) {
final index = position.index;
final item = loadedItems[index];
@@ -179,7 +185,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
),
);
},
@@ -205,6 +211,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
cardShapeOverride: _usesSquareCards ? CardShape.square : null,
onListRefresh: loadItems,
onNavigateUp: isFirstRow ? widget.onBack : null,
onBack: widget.onBack,
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart';
import '../../../media/media_item.dart';
import '../../../media/media_kind.dart';
import '../../../media/media_playlist.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
@@ -142,15 +143,21 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
return base.copyWith(top: base.top + _focusDecorationPadding);
}
bool get _usesSquareCards => widget.library.kind.isMusic;
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = _usesSquareCards ? CardShape.square : null;
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
fullBleedImage: fullCardLayout,
listEpoch: (ViewMode.list, totalSize, density),
gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density),
fullBleedImage: useFullCardLayout,
shape: shape,
listEpoch: (ViewMode.list, totalSize, density, shape),
gridEpochBuilder: (geometry) =>
(ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape),
itemBuilder: (context, position) {
final index = position.index;
final playlist = loadedItems[index];
@@ -184,7 +191,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
),
);
},
@@ -210,6 +217,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
cardShapeOverride: _usesSquareCards ? CardShape.square : null,
onListRefresh: loadItems,
onNavigateUp: isFirstRow ? widget.onBack : null,
onBack: widget.onBack,
@@ -779,7 +779,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
if (_isReadOnly)
// Smart playlists / Jellyfin playlists: focusable grid view
// (read-only, no reordering or removal)
buildFocusableGrid(items: items, onRefresh: updateItem)
buildFocusableGrid(items: items, onRefresh: updateItem, shape: _isAudioPlaylist ? CardShape.square : null)
else
// Plex regular playlists: sliver reorderable list
_buildReorderableList(isKeyboardMode),
+39 -21
View File
@@ -8,6 +8,7 @@ import '../../mixins/context_menu_tap_mixin.dart';
import '../../providers/watch_state_store.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/media_context_menu.dart';
@@ -190,40 +191,57 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
Widget _buildPosterImage(BuildContext context, MediaItem item) {
final posterUrl = item.posterThumb();
final isSquare = item.kind.isMusic;
final imageSize = isSquare ? const Size.square(60) : const Size(60, 90);
return SizedBox(
width: 60,
height: 90,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: OptimizedMediaImage.poster(
// Backend-neutral lookup so Jellyfin items render via their own
// image transcoder; null falls through to the placeholder below.
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
width: 60,
height: 90,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
child: Center(
child: SizedBox.fromSize(
size: imageSize,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: OptimizedMediaImage(
// Backend-neutral lookup so Jellyfin items render via their own
// image transcoder; null falls through to the placeholder below.
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
width: imageSize.width,
height: imageSize.height,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(imageSize, item),
errorWidget: (context, url, error) => _buildPlaceholder(imageSize, item),
fallbackIcon: _fallbackIcon(item),
imageType: isSquare ? ImageType.square : ImageType.poster,
),
),
WatchedIndicator(item: item, size: WatchedIndicatorSize.compact),
],
),
WatchedIndicator(item: item, size: WatchedIndicatorSize.compact),
],
),
),
);
}
Widget _buildPlaceholder() {
Widget _buildPlaceholder(Size size, MediaItem item) {
return Container(
width: 60,
height: 90,
width: size.width,
height: size.height,
decoration: BoxDecoration(color: Colors.grey[850], borderRadius: const BorderRadius.all(Radius.circular(6))),
child: const AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.grey, size: 24),
child: AppIcon(_fallbackIcon(item), fill: 1, color: Colors.grey, size: 24),
);
}
IconData _fallbackIcon(MediaItem item) => switch (item.kind) {
MediaKind.artist => Symbols.artist_rounded,
MediaKind.album => Symbols.album_rounded,
MediaKind.track => Symbols.music_note_rounded,
MediaKind.show || MediaKind.season || MediaKind.episode => Symbols.tv_rounded,
_ => Symbols.movie_rounded,
};
String _buildSubtitle(MediaItem item) {
final kind = item.kind;
+6
View File
@@ -36,6 +36,10 @@ class FocusableMediaCard extends StatefulWidget {
/// Render grid cards as image-only full-bleed cards.
final bool fullBleedImage;
/// Overrides the silhouette inferred from [item]. Used for container types
/// such as music collections and playlists.
final CardShape? cardShapeOverride;
/// Show server name in list view (multi-server)
final bool showServerName;
@@ -87,6 +91,7 @@ class FocusableMediaCard extends StatefulWidget {
this.isOffline = false,
this.mixedHubContext = false,
this.fullBleedImage = false,
this.cardShapeOverride,
this.showServerName = false,
this.disableScale = false,
this.focusNode,
@@ -142,6 +147,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
isOffline: widget.isOffline,
mixedHubContext: widget.mixedHubContext,
fullBleedImage: widget.fullBleedImage,
cardShapeOverride: widget.cardShapeOverride,
showServerName: widget.showServerName,
),
);
+34 -1
View File
@@ -140,6 +140,11 @@ class MediaCard extends StatefulWidget {
final EpisodePosterMode? episodePosterModeOverride;
final bool fullBleedImage;
/// Overrides the silhouette inferred from the item itself. Collection and
/// playlist records do not encode the media-library shape, so their owning
/// surface supplies this for music libraries.
final CardShape? cardShapeOverride;
const MediaCard({
super.key,
required this.item,
@@ -160,6 +165,7 @@ class MediaCard extends StatefulWidget {
this.showServerName = false,
this.episodePosterModeOverride,
this.fullBleedImage = false,
this.cardShapeOverride,
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
@@ -309,6 +315,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
localPosterPath: localPosterPath,
showServerName: widget.showServerName,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
enableDetailLinks: widget.onTap == null,
);
@@ -391,6 +398,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
knownWidth: width,
knownHeight: height,
),
@@ -424,6 +432,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
knownWidth: posterHeight != null ? posterWidth : null,
knownHeight: posterHeight,
),
@@ -500,6 +509,7 @@ class _MediaCardList extends StatelessWidget {
final String? localPosterPath;
final bool showServerName;
final EpisodePosterMode? episodePosterModeOverride;
final CardShape? cardShapeOverride;
final bool enableDetailLinks;
const _MediaCardList({
@@ -515,10 +525,12 @@ class _MediaCardList extends StatelessWidget {
this.localPosterPath,
this.showServerName = false,
this.episodePosterModeOverride,
this.cardShapeOverride,
required this.enableDetailLinks,
});
CardShape _cardShape() {
if (cardShapeOverride case final shape?) return shape;
if (item is! MediaItem) return CardShape.poster;
final EpisodePosterMode mode =
episodePosterModeOverride ?? SettingsService.instance.read(SettingsService.episodePosterMode);
@@ -701,6 +713,7 @@ class _MediaCardList extends StatelessWidget {
isOffline: isOffline,
localPosterPath: localPosterPath,
episodePosterModeOverride: episodePosterModeOverride,
cardShapeOverride: cardShapeOverride,
),
),
if (item is MediaItem && _showsWatchedIndicator(item as MediaItem))
@@ -852,6 +865,7 @@ Widget _buildPosterImage(
String? localPosterPath,
bool mixedHubContext = false,
EpisodePosterMode? episodePosterModeOverride,
CardShape? cardShapeOverride,
double? knownWidth,
double? knownHeight,
}) {
@@ -860,6 +874,20 @@ Widget _buildPosterImage(
if (item is MediaPlaylist) {
posterUrl = item.displayImagePath;
if (cardShapeOverride == CardShape.square) {
return OptimizedMediaImage(
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: Symbols.playlist_play_rounded,
imageType: ImageType.square,
localFilePath: localPosterPath,
);
}
return OptimizedMediaImage.playlist(
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
@@ -881,7 +909,12 @@ Widget _buildPosterImage(
posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId));
final fallbackIcon = _mediaPosterFallbackIcon(item);
final imageType = MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext);
final imageType = switch (cardShapeOverride) {
CardShape.square => ImageType.square,
CardShape.wide => ImageType.thumb,
CardShape.poster => ImageType.poster,
null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext),
};
Widget image;