From 117d1b8882978ca79ae24f92bd7d9c111b1592e3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 1 May 2026 04:52:48 +0200 Subject: [PATCH] fix: fall back to wide hero art --- lib/media/media_item.dart | 18 +++- lib/screens/media_detail_screen.dart | 153 +++++++++++++++++---------- test/media/media_item_test.dart | 27 +++++ 3 files changed, 141 insertions(+), 57 deletions(-) diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 0943b8c0..632aabe0 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -473,11 +473,23 @@ sealed class MediaItem { /// Returns the best hero art path based on the container's aspect ratio. /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. String? heroArt({required double containerAspectRatio}) { + final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); + if (candidates.isEmpty) return null; + return candidates.first; + } + + /// Returns hero art candidates in display-preference order. + /// Near-square containers prefer square art, then fall back to wide cover art. + List heroArtCandidates({required double containerAspectRatio}) { // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 - if (containerAspectRatio < 1.39 && backgroundSquarePath != null) { - return backgroundSquarePath; + final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath]; + + final candidates = []; + for (final path in preferred) { + if (path == null || path.isEmpty || candidates.contains(path)) continue; + candidates.add(path); } - return artPath; + return candidates; } MediaItem copyWith({ diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 36cbec12..b5b4188e 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1227,7 +1227,7 @@ class _MediaDetailScreenState extends State Widget? _buildOfflineArtworkIfAvailable( BuildContext context, { - required String? artworkPath, + required Iterable artworkPaths, required BoxFit fit, required ImageType imageType, Alignment alignment = Alignment.center, @@ -1235,17 +1235,71 @@ class _MediaDetailScreenState extends State }) { if (!widget.isOffline || _metadata.serverId == null) return null; - final localPath = context.read().getArtworkLocalPath(_metadata.serverId!, artworkPath); - if (localPath == null || !File(localPath).existsSync()) return null; + final downloadProvider = context.read(); + for (final artworkPath in artworkPaths) { + final localPath = downloadProvider.getArtworkLocalPath(_metadata.serverId!, artworkPath); + if (localPath == null || !File(localPath).existsSync()) continue; - return OptimizedMediaImage( - client: null, - imagePath: null, - localFilePath: localPath, - fit: fit, - alignment: alignment, - imageType: imageType, - errorWidget: errorWidget, + return OptimizedMediaImage( + client: null, + imagePath: null, + localFilePath: localPath, + fit: fit, + alignment: alignment, + imageType: imageType, + errorWidget: errorWidget, + ); + } + + return null; + } + + Widget _buildHeroNetworkArtwork( + BuildContext context, { + required MediaServerClient? client, + required List artworkPaths, + required Size mediaSize, + required double dpr, + required int memCacheHeight, + int index = 0, + }) { + if (index >= artworkPaths.length) return const PlaceholderContainer(); + + final imageUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: artworkPaths[index], + maxWidth: mediaSize.width, + maxHeight: mediaSize.height * 0.6, + devicePixelRatio: dpr, + imageType: ImageType.art, + ); + if (imageUrl.isEmpty) { + return _buildHeroNetworkArtwork( + context, + client: client, + artworkPaths: artworkPaths, + mediaSize: mediaSize, + dpr: dpr, + memCacheHeight: memCacheHeight, + index: index + 1, + ); + } + + return CachedNetworkImage( + imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, + fit: BoxFit.cover, + memCacheHeight: memCacheHeight, + placeholder: (context, url) => const PlaceholderContainer(), + errorWidget: (context, url, error) => _buildHeroNetworkArtwork( + context, + client: client, + artworkPaths: artworkPaths, + mediaSize: mediaSize, + dpr: dpr, + memCacheHeight: memCacheHeight, + index: index + 1, + ), ); } @@ -2567,51 +2621,42 @@ class _MediaDetailScreenState extends State SizedBox( height: headerHeight, width: double.infinity, - child: (metadata.artPath != null || metadata.backgroundSquarePath != null) - ? Builder( - builder: (context) { - final containerAspect = size.width / headerHeight; - final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); + child: Builder( + builder: (context) { + final containerAspect = size.width / headerHeight; + final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect); + if (heroArtPaths.isEmpty) return const PlaceholderContainer(); - final localArtwork = _buildOfflineArtworkIfAvailable( - context, - artworkPath: heroArtPath, - fit: BoxFit.cover, - imageType: ImageType.art, - errorWidget: (context, url, error) => const PlaceholderContainer(), - ); - if (localArtwork != null) return localArtwork; + final localArtwork = _buildOfflineArtworkIfAvailable( + context, + artworkPaths: heroArtPaths, + fit: BoxFit.cover, + imageType: ImageType.art, + errorWidget: (context, url, error) => const PlaceholderContainer(), + ); + if (localArtwork != null) return localArtwork; - final client = _getArtworkMediaClient(context); - final mqSize = MediaQuery.sizeOf(context); - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final imageUrl = MediaImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: heroArtPath, - maxWidth: mqSize.width, - maxHeight: mqSize.height * 0.6, - devicePixelRatio: dpr, - imageType: ImageType.art, - ); + final client = _getArtworkMediaClient(context); + final mqSize = MediaQuery.sizeOf(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (mqSize.width * dpr).round(), + displayHeight: (mqSize.height * 0.6 * dpr).round(), + imageType: ImageType.art, + ); - final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( - displayWidth: (mqSize.width * dpr).round(), - displayHeight: (mqSize.height * 0.6 * dpr).round(), - imageType: ImageType.art, - ); - return blurArtwork( - CachedNetworkImage( - imageUrl: imageUrl, - cacheManager: PlexImageCacheManager.instance, - fit: BoxFit.cover, - memCacheHeight: memHeight, - placeholder: (context, url) => const PlaceholderContainer(), - errorWidget: (context, url, error) => const PlaceholderContainer(), - ), - ); - }, - ) - : const PlaceholderContainer(), + return blurArtwork( + _buildHeroNetworkArtwork( + context, + client: client, + artworkPaths: heroArtPaths, + mediaSize: mqSize, + dpr: dpr, + memCacheHeight: memHeight, + ), + ); + }, + ), ), // Gradient overlay @@ -2658,7 +2703,7 @@ class _MediaDetailScreenState extends State builder: (context) { final localArtwork = _buildOfflineArtworkIfAvailable( context, - artworkPath: metadata.clearLogoPath, + artworkPaths: [metadata.clearLogoPath], fit: BoxFit.contain, alignment: Alignment.centerLeft, imageType: ImageType.logo, diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index 5e3797a1..da677fc7 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -16,6 +16,8 @@ MediaItem _movie({ int? viewedLeafCount, int? durationMs, int? viewOffsetMs, + String? artPath, + String? backgroundSquarePath, MediaBackend backend = MediaBackend.plex, }) => MediaItem( id: id, @@ -27,6 +29,8 @@ MediaItem _movie({ viewedLeafCount: viewedLeafCount, durationMs: durationMs, viewOffsetMs: viewOffsetMs, + artPath: artPath, + backgroundSquarePath: backgroundSquarePath, serverId: 's1', ); @@ -72,6 +76,29 @@ void main() { }); }); + group('MediaItem.heroArtCandidates', () { + test('near-square containers prefer square art before wide cover art', () { + final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); + + expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']); + expect(movie.heroArt(containerAspectRatio: 1.0), '/square'); + }); + + test('near-square containers fall back to wide cover art when square art is missing', () { + final movie = _movie(artPath: '/art'); + + expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']); + expect(movie.heroArt(containerAspectRatio: 1.0), '/art'); + }); + + test('wide containers prefer wide cover art before square art', () { + final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); + + expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']); + expect(movie.heroArt(containerAspectRatio: 16 / 9), '/art'); + }); + }); + group('MediaItem.isPartiallyWatched', () { test('show with some leaves watched is partially watched', () { final show = MediaItem(