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.
|
/// 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.
|
/// Uses backgroundSquare when the container is closer to 1:1 than 16:9.
|
||||||
String? heroArt({required double containerAspectRatio}) {
|
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
|
// Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39
|
||||||
if (containerAspectRatio < 1.39 && backgroundSquarePath != null) {
|
final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath];
|
||||||
return 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({
|
MediaItem copyWith({
|
||||||
|
|||||||
@@ -1227,7 +1227,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
|
|
||||||
Widget? _buildOfflineArtworkIfAvailable(
|
Widget? _buildOfflineArtworkIfAvailable(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String? artworkPath,
|
required Iterable<String?> artworkPaths,
|
||||||
required BoxFit fit,
|
required BoxFit fit,
|
||||||
required ImageType imageType,
|
required ImageType imageType,
|
||||||
Alignment alignment = Alignment.center,
|
Alignment alignment = Alignment.center,
|
||||||
@@ -1235,17 +1235,71 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}) {
|
}) {
|
||||||
if (!widget.isOffline || _metadata.serverId == null) return null;
|
if (!widget.isOffline || _metadata.serverId == null) return null;
|
||||||
|
|
||||||
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(_metadata.serverId!, artworkPath);
|
final downloadProvider = context.read<DownloadProvider>();
|
||||||
if (localPath == null || !File(localPath).existsSync()) return null;
|
for (final artworkPath in artworkPaths) {
|
||||||
|
final localPath = downloadProvider.getArtworkLocalPath(_metadata.serverId!, artworkPath);
|
||||||
|
if (localPath == null || !File(localPath).existsSync()) continue;
|
||||||
|
|
||||||
return OptimizedMediaImage(
|
return OptimizedMediaImage(
|
||||||
client: null,
|
client: null,
|
||||||
imagePath: null,
|
imagePath: null,
|
||||||
localFilePath: localPath,
|
localFilePath: localPath,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
imageType: imageType,
|
imageType: imageType,
|
||||||
errorWidget: errorWidget,
|
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(
|
SizedBox(
|
||||||
height: headerHeight,
|
height: headerHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: (metadata.artPath != null || metadata.backgroundSquarePath != null)
|
child: Builder(
|
||||||
? Builder(
|
builder: (context) {
|
||||||
builder: (context) {
|
final containerAspect = size.width / headerHeight;
|
||||||
final containerAspect = size.width / headerHeight;
|
final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect);
|
||||||
final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect);
|
if (heroArtPaths.isEmpty) return const PlaceholderContainer();
|
||||||
|
|
||||||
final localArtwork = _buildOfflineArtworkIfAvailable(
|
final localArtwork = _buildOfflineArtworkIfAvailable(
|
||||||
context,
|
context,
|
||||||
artworkPath: heroArtPath,
|
artworkPaths: heroArtPaths,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
imageType: ImageType.art,
|
imageType: ImageType.art,
|
||||||
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
||||||
);
|
);
|
||||||
if (localArtwork != null) return localArtwork;
|
if (localArtwork != null) return localArtwork;
|
||||||
|
|
||||||
final client = _getArtworkMediaClient(context);
|
final client = _getArtworkMediaClient(context);
|
||||||
final mqSize = MediaQuery.sizeOf(context);
|
final mqSize = MediaQuery.sizeOf(context);
|
||||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||||
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
||||||
client: client,
|
displayWidth: (mqSize.width * dpr).round(),
|
||||||
thumbPath: heroArtPath,
|
displayHeight: (mqSize.height * 0.6 * dpr).round(),
|
||||||
maxWidth: mqSize.width,
|
imageType: ImageType.art,
|
||||||
maxHeight: mqSize.height * 0.6,
|
);
|
||||||
devicePixelRatio: dpr,
|
|
||||||
imageType: ImageType.art,
|
|
||||||
);
|
|
||||||
|
|
||||||
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
return blurArtwork(
|
||||||
displayWidth: (mqSize.width * dpr).round(),
|
_buildHeroNetworkArtwork(
|
||||||
displayHeight: (mqSize.height * 0.6 * dpr).round(),
|
context,
|
||||||
imageType: ImageType.art,
|
client: client,
|
||||||
);
|
artworkPaths: heroArtPaths,
|
||||||
return blurArtwork(
|
mediaSize: mqSize,
|
||||||
CachedNetworkImage(
|
dpr: dpr,
|
||||||
imageUrl: imageUrl,
|
memCacheHeight: memHeight,
|
||||||
cacheManager: PlexImageCacheManager.instance,
|
),
|
||||||
fit: BoxFit.cover,
|
);
|
||||||
memCacheHeight: memHeight,
|
},
|
||||||
placeholder: (context, url) => const PlaceholderContainer(),
|
),
|
||||||
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: const PlaceholderContainer(),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Gradient overlay
|
// Gradient overlay
|
||||||
@@ -2658,7 +2703,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
final localArtwork = _buildOfflineArtworkIfAvailable(
|
final localArtwork = _buildOfflineArtworkIfAvailable(
|
||||||
context,
|
context,
|
||||||
artworkPath: metadata.clearLogoPath,
|
artworkPaths: [metadata.clearLogoPath],
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
imageType: ImageType.logo,
|
imageType: ImageType.logo,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ MediaItem _movie({
|
|||||||
int? viewedLeafCount,
|
int? viewedLeafCount,
|
||||||
int? durationMs,
|
int? durationMs,
|
||||||
int? viewOffsetMs,
|
int? viewOffsetMs,
|
||||||
|
String? artPath,
|
||||||
|
String? backgroundSquarePath,
|
||||||
MediaBackend backend = MediaBackend.plex,
|
MediaBackend backend = MediaBackend.plex,
|
||||||
}) => MediaItem(
|
}) => MediaItem(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -27,6 +29,8 @@ MediaItem _movie({
|
|||||||
viewedLeafCount: viewedLeafCount,
|
viewedLeafCount: viewedLeafCount,
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
viewOffsetMs: viewOffsetMs,
|
viewOffsetMs: viewOffsetMs,
|
||||||
|
artPath: artPath,
|
||||||
|
backgroundSquarePath: backgroundSquarePath,
|
||||||
serverId: 's1',
|
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', () {
|
group('MediaItem.isPartiallyWatched', () {
|
||||||
test('show with some leaves watched is partially watched', () {
|
test('show with some leaves watched is partially watched', () {
|
||||||
final show = MediaItem(
|
final show = MediaItem(
|
||||||
|
|||||||
Reference in New Issue
Block a user