fix: fall back to wide hero art

This commit is contained in:
edde746
2026-05-01 04:52:48 +02:00
parent f6aad1b8fc
commit 117d1b8882
3 changed files with 141 additions and 57 deletions
+15 -3
View File
@@ -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<String> 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 = <String>[];
for (final path in preferred) {
if (path == null || path.isEmpty || candidates.contains(path)) continue;
candidates.add(path);
}
return artPath;
return candidates;
}
MediaItem copyWith({
+99 -54
View File
@@ -1227,7 +1227,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
Widget? _buildOfflineArtworkIfAvailable(
BuildContext context, {
required String? artworkPath,
required Iterable<String?> artworkPaths,
required BoxFit fit,
required ImageType imageType,
Alignment alignment = Alignment.center,
@@ -1235,17 +1235,71 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}) {
if (!widget.isOffline || _metadata.serverId == null) return null;
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(_metadata.serverId!, artworkPath);
if (localPath == null || !File(localPath).existsSync()) return null;
final downloadProvider = context.read<DownloadProvider>();
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<String> 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<MediaDetailScreen>
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<MediaDetailScreen>
builder: (context) {
final localArtwork = _buildOfflineArtworkIfAvailable(
context,
artworkPath: metadata.clearLogoPath,
artworkPaths: [metadata.clearLogoPath],
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
imageType: ImageType.logo,
+27
View File
@@ -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(