fix: fall back to wide hero art
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user