fix(music): keep collection and playlist artwork square
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user