fix(artwork): preserve clear-logo aspect ratios
This commit is contained in:
@@ -473,13 +473,23 @@ abstract class MediaServerClient {
|
||||
/// When [width]/[height] are provided, the implementation should request
|
||||
/// a server-side resize: Plex builds a `/photo/:/transcode` URL; Jellyfin
|
||||
/// appends `MaxWidth`/`MaxHeight` to the image endpoint.
|
||||
String thumbnailUrl(String? path, {int? width, int? height});
|
||||
///
|
||||
/// [cover] picks how the requested box is interpreted. The default sizes the
|
||||
/// image to *cover* the box — every pixel of a poster/backdrop slot is
|
||||
/// filled, at the cost of overshooting on the long axis. Pass `false` for
|
||||
/// artwork drawn with [BoxFit.contain] (clear logos), where the overshoot is
|
||||
/// decoded and thrown away: a 4313×1035 logo asked for at 1200×360 comes back
|
||||
/// 1500×360 covering versus 1200×288 fitting, for ~20-30% more bytes and no
|
||||
/// extra rendered detail. Neither mode crops or changes the aspect ratio.
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true});
|
||||
|
||||
/// Proxy an absolute external image URL through the server's transcoder
|
||||
/// (Plex `/photo/:/transcode?url=...`). Backends without a proxy endpoint
|
||||
/// (Jellyfin) should return the URL unchanged. Used for EPG provider art
|
||||
/// and other off-server images that benefit from re-encoding.
|
||||
String externalImageUrl(String url, {int? width, int? height});
|
||||
///
|
||||
/// [cover] carries the same meaning as in [thumbnailUrl].
|
||||
String externalImageUrl(String url, {int? width, int? height, bool cover = true});
|
||||
|
||||
/// Headers that must be attached when the player fetches a direct-play
|
||||
/// URL from this server. Plex requires `X-Plex-Token` (and identity
|
||||
|
||||
@@ -12,9 +12,7 @@ import '../focus/hub_vertical_navigation.dart';
|
||||
import '../focus/locked_hub_controller.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'package:cached_network_image_ce/cached_network_image.dart';
|
||||
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
@@ -22,7 +20,7 @@ import '../media/media_hub.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../widgets/cycling_media_backdrop.dart';
|
||||
import '../widgets/optimized_media_image.dart' show blurArtwork;
|
||||
import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork;
|
||||
import '../widgets/rasterized_gradient.dart';
|
||||
import '../providers/discover_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
@@ -1481,67 +1479,20 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Show logo or name/title
|
||||
if (heroItem.clearLogoPath != null)
|
||||
SizedBox(
|
||||
height: heroLogoHeight,
|
||||
width: heroLogoWidth,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final targetWidth = (heroLogoWidth * dpr).round();
|
||||
final targetHeight = (heroLogoHeight * dpr).round();
|
||||
final (memCacheWidth, memCacheHeight) = MediaImageHelper.getMemCacheDimensions(
|
||||
displayWidth: targetWidth,
|
||||
displayHeight: targetHeight,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
final logoUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: heroClient,
|
||||
thumbPath: heroItem.clearLogoPath,
|
||||
maxWidth: heroLogoWidth,
|
||||
maxHeight: heroLogoHeight,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: memCacheWidth,
|
||||
memCacheHeight: memCacheHeight,
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback to text if logo fails to load
|
||||
return FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
);
|
||||
},
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: heroLogoHeight,
|
||||
width: heroLogoWidth,
|
||||
child: FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
),
|
||||
// Show logo, falling back to the name/title
|
||||
ClearLogoImage(
|
||||
client: heroClient,
|
||||
logoPath: heroItem.clearLogoPath,
|
||||
width: heroLogoWidth,
|
||||
height: heroLogoHeight,
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
fallbackBuilder: (context) => FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
|
||||
@@ -3668,40 +3668,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
if (localArtwork != null) return localArtwork;
|
||||
|
||||
final client = _getArtworkMediaClient(context);
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final targetWidth = (width * dpr).round();
|
||||
final targetHeight = (height * dpr).round();
|
||||
final (memCacheWidth, memCacheHeight) = MediaImageHelper.getMemCacheDimensions(
|
||||
displayWidth: targetWidth,
|
||||
displayHeight: targetHeight,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
final logoUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: metadata.clearLogoPath,
|
||||
maxWidth: width,
|
||||
maxHeight: height,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
|
||||
if (logoUrl.isEmpty) return titleFallback(context);
|
||||
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
alignment: .centerLeft,
|
||||
memCacheWidth: memCacheWidth,
|
||||
memCacheHeight: memCacheHeight,
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) => titleFallback(context),
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
return ClearLogoImage(
|
||||
client: _getArtworkMediaClient(context),
|
||||
logoPath: metadata.clearLogoPath,
|
||||
width: width,
|
||||
height: height,
|
||||
fallbackBuilder: titleFallback,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -35,8 +35,11 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
});
|
||||
String _withApiKey(String urlOrPath);
|
||||
|
||||
/// [cover] is accepted for interface parity and ignored: `maxWidth`/
|
||||
/// `maxHeight` already scale the long axis to fit inside the box, so
|
||||
/// Jellyfin never overshoots the way Plex's `minSize=1` transcode does.
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height}) {
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
|
||||
if (path == null || path.isEmpty) return '';
|
||||
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path);
|
||||
final params = Map<String, String>.from(uri.queryParameters);
|
||||
@@ -53,7 +56,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
/// Jellyfin doesn't expose an external-URL proxy endpoint comparable to
|
||||
/// Plex's `/photo/:/transcode?url=...`. External URLs pass through.
|
||||
@override
|
||||
String externalImageUrl(String url, {int? width, int? height}) => url;
|
||||
String externalImageUrl(String url, {int? width, int? height, bool cover = true}) => url;
|
||||
|
||||
@override
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
||||
|
||||
@@ -3652,8 +3652,21 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
/// `minSize`/`upscale` are how Plex's photo transcoder picks the scale
|
||||
/// factor. `minSize=1` scales until the *smaller* axis reaches the request
|
||||
/// (cover) and `upscale=1` lets it enlarge past the original; both return
|
||||
/// the whole image — Plex never crops or distorts. `minSize=0&upscale=0`
|
||||
/// scales the *larger* axis to fit inside the box instead, which is what
|
||||
/// `BoxFit.contain` artwork wants: the covering overshoot is decoded and
|
||||
/// then thrown away by the fit-policy decode bounds.
|
||||
List<String> _transcodeSizeParams({int? width, int? height, required bool cover}) => [
|
||||
if (width != null) 'width=$width',
|
||||
if (height != null) 'height=$height',
|
||||
if (cover) ...['minSize=1', 'upscale=1'] else ...['minSize=0', 'upscale=0'],
|
||||
];
|
||||
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height}) {
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
|
||||
if (path == null || path.isEmpty) return '';
|
||||
// No sizing requested, or already-processed/external URL — passthrough.
|
||||
if (width == null && height == null) return getThumbnailUrl(path);
|
||||
@@ -3666,10 +3679,7 @@ class PlexClient
|
||||
if (token == null) return getThumbnailUrl(path);
|
||||
final encoded = Uri.encodeComponent(path.withPlexToken(token));
|
||||
final parts = <String>[
|
||||
if (width != null) 'width=$width',
|
||||
if (height != null) 'height=$height',
|
||||
'minSize=1',
|
||||
'upscale=1',
|
||||
..._transcodeSizeParams(width: width, height: height, cover: cover),
|
||||
'url=$encoded',
|
||||
'X-Plex-Token=$token',
|
||||
];
|
||||
@@ -3677,15 +3687,12 @@ class PlexClient
|
||||
}
|
||||
|
||||
@override
|
||||
String externalImageUrl(String url, {int? width, int? height}) {
|
||||
String externalImageUrl(String url, {int? width, int? height, bool cover = true}) {
|
||||
final token = config.token;
|
||||
if (token == null || (width == null && height == null)) return url;
|
||||
final encoded = Uri.encodeComponent(url);
|
||||
final parts = <String>[
|
||||
if (width != null) 'width=$width',
|
||||
if (height != null) 'height=$height',
|
||||
'minSize=1',
|
||||
'upscale=1',
|
||||
..._transcodeSizeParams(width: width, height: height, cover: cover),
|
||||
'url=$encoded',
|
||||
'X-Plex-Token=$token',
|
||||
];
|
||||
|
||||
@@ -138,6 +138,16 @@ class MediaImageHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [type] fills its slot ([BoxFit.cover]) or sits inside it
|
||||
/// ([BoxFit.contain]), which is what [MediaServerClient.thumbnailUrl]'s
|
||||
/// `cover` flag selects. Logos are the contain case: asking Plex to cover
|
||||
/// their slot overshoots the long axis by 20-30% in bytes, and the decode
|
||||
/// bounds throw those pixels away again.
|
||||
static bool _coversSlot(ImageType type) => switch (type) {
|
||||
ImageType.logo || ImageType.heroLogo => false,
|
||||
ImageType.art || ImageType.thumb || ImageType.poster || ImageType.avatar || ImageType.square => true,
|
||||
};
|
||||
|
||||
/// Creates an optimized image URL.
|
||||
///
|
||||
/// Falls back to the raw [thumbPath] when the path is empty, when no
|
||||
@@ -190,7 +200,7 @@ class MediaImageHelper {
|
||||
devicePixelRatio: devicePixelRatio,
|
||||
imageType: imageType,
|
||||
);
|
||||
return client.externalImageUrl(basePath, width: width, height: height);
|
||||
return client.externalImageUrl(basePath, width: width, height: height, cover: _coversSlot(imageType));
|
||||
}
|
||||
|
||||
// Relative path — let the client build the sized URL using its native
|
||||
@@ -217,7 +227,7 @@ class MediaImageHelper {
|
||||
// hands the full original to the decoder, and a multi-megapixel
|
||||
// original behind a 40px avatar is exactly the decode spike that OOMs
|
||||
// low-RAM devices. The floor is 160×240 via [roundDimensions].
|
||||
return client.thumbnailUrl(basePath, width: width, height: height);
|
||||
return client.thumbnailUrl(basePath, width: width, height: height, cover: _coversSlot(imageType));
|
||||
}
|
||||
|
||||
/// Generates cache-friendly dimensions for memory caching.
|
||||
|
||||
@@ -304,6 +304,12 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (imageUrl.isEmpty) {
|
||||
// An unresolvable URL (no client, offline, suppressed transcode) is a
|
||||
// load failure from the caller's point of view, so honour its own
|
||||
// failure UI rather than the generic broken-image tile.
|
||||
if (errorWidget != null) {
|
||||
return errorWidget!(context, imagePath ?? '', StateError('No image URL could be built for $imagePath'));
|
||||
}
|
||||
return _buildFallback(context);
|
||||
}
|
||||
|
||||
@@ -397,6 +403,69 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
_surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded);
|
||||
}
|
||||
|
||||
/// Clear-logo artwork for hero and detail headers.
|
||||
///
|
||||
/// Logos are the one artwork type whose source aspect never matches its slot,
|
||||
/// so the decode has to preserve the source ratio: [OptimizedMediaImage] goes
|
||||
/// through [MediaImageHelper.boundedDecode], which bounds both axes under
|
||||
/// `ResizeImagePolicy.fit`. Handing both mem-cache dimensions to a raw
|
||||
/// `CachedNetworkImage` instead decodes under `ResizeImagePolicy.exact`, which
|
||||
/// pins the logo to whatever ratio those two bounds happen to have.
|
||||
///
|
||||
/// Plex serves logos with `minSize=1&upscale=1`, so the transcode covers the
|
||||
/// requested box on both axes (a 4313×1035 logo asked for at 1200×360 comes
|
||||
/// back 1500×360, aspect intact). `exact` then clamps neither axis down to the
|
||||
/// source and squashes the logo to the bound ratio: on a phone at DPR 3 the
|
||||
/// 400×120 hero slot decodes to exactly 1000×360 — the width capped by
|
||||
/// [MediaImageHelper.getMemCacheDimensions] — turning a 4.17∶1 logo into
|
||||
/// 2.78∶1.
|
||||
///
|
||||
/// [fallbackBuilder] renders the title in place of the logo when the path is
|
||||
/// missing, the URL can't be built, or the image fails to load.
|
||||
class ClearLogoImage extends StatelessWidget {
|
||||
const ClearLogoImage({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.logoPath,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.fallbackBuilder,
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.fadeInDuration = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
final MediaServerClient? client;
|
||||
final String? logoPath;
|
||||
final double width;
|
||||
final double height;
|
||||
final WidgetBuilder fallbackBuilder;
|
||||
final Alignment alignment;
|
||||
final Duration fadeInDuration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = logoPath;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: path == null || path.isEmpty
|
||||
? fallbackBuilder(context)
|
||||
: OptimizedMediaImage(
|
||||
client: client,
|
||||
imagePath: path,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.contain,
|
||||
alignment: alignment,
|
||||
imageType: ImageType.heroLogo,
|
||||
fadeInDuration: fadeInDuration,
|
||||
placeholder: (context, _) => const SizedBox.shrink(),
|
||||
errorWidget: (context, _, _) => fallbackBuilder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades a network image in by animating the image paint's alpha
|
||||
/// (`Image.opacity` → `RawImage`), not by wrapping it in an opacity widget:
|
||||
/// a widget-opacity fade is a tile-sized saveLayer per in-flight image, and
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image_ce/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -10,7 +9,6 @@ import '../media/media_item_types.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../providers/watch_state_store.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
@@ -21,7 +19,7 @@ import 'cycling_media_backdrop.dart';
|
||||
import 'fitting_title_text.dart';
|
||||
import 'settings_builder.dart';
|
||||
import 'media_rating_badge.dart';
|
||||
import 'optimized_media_image.dart' show blurArtwork;
|
||||
import 'optimized_media_image.dart' show ClearLogoImage, blurArtwork;
|
||||
import 'rasterized_gradient.dart';
|
||||
|
||||
class TvSpotlightBackground extends StatelessWidget {
|
||||
@@ -270,35 +268,13 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
return ClearLogoImage(
|
||||
client: client,
|
||||
thumbPath: logoPath,
|
||||
maxWidth: logoWidth,
|
||||
maxHeight: logoHeight,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
if (imageUrl.isEmpty) return _buildTitle(context, title);
|
||||
|
||||
return SizedBox(
|
||||
logoPath: logoPath,
|
||||
width: logoWidth,
|
||||
height: logoHeight,
|
||||
child: blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.contain,
|
||||
alignment: .centerLeft,
|
||||
memCacheWidth: logoMemWidth,
|
||||
memCacheHeight: logoMemHeight,
|
||||
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
),
|
||||
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
fallbackBuilder: (context) => _buildTitle(context, title),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user