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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class _FakeMusicClient implements MediaServerClient {
|
||||
}
|
||||
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height}) => '';
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) => '';
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
import 'package:plezy/utils/media_image_helper.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late PlexClient client;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
client = testPlexClient();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
client.close();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// Verified against PMS 1.43: `minSize=1&upscale=1` scales until the smaller
|
||||
// axis reaches the request and returns the whole image (a 4313x1035 logo
|
||||
// asked for at 1200x360 comes back 1500x360), while `minSize=0&upscale=0`
|
||||
// fits the longer axis inside the box (1200x288). Neither crops, so this is
|
||||
// purely about not fetching pixels that BoxFit.contain will discard.
|
||||
group('PlexClient transcode sizing flags', () {
|
||||
test('slot-filling artwork covers the requested box', () {
|
||||
final url = client.thumbnailUrl('/library/metadata/1/thumb/2', width: 400, height: 600);
|
||||
|
||||
expect(url, contains('minSize=1'));
|
||||
expect(url, contains('upscale=1'));
|
||||
});
|
||||
|
||||
test('contain artwork fits inside the requested box', () {
|
||||
final url = client.thumbnailUrl('/library/metadata/1/clearLogo', width: 1200, height: 360, cover: false);
|
||||
|
||||
expect(url, contains('minSize=0'));
|
||||
expect(url, contains('upscale=0'));
|
||||
expect(url, contains('width=1200'));
|
||||
expect(url, contains('height=360'));
|
||||
});
|
||||
|
||||
test('proxied external images honour the same flag', () {
|
||||
final covering = client.externalImageUrl('https://epg.example/logo.png', width: 200, height: 200);
|
||||
final fitting = client.externalImageUrl('https://epg.example/logo.png', width: 200, height: 200, cover: false);
|
||||
|
||||
expect(covering, contains('minSize=1'));
|
||||
expect(fitting, contains('minSize=0'));
|
||||
});
|
||||
|
||||
test('logo slots reach the client as fitting requests', () {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: '/library/metadata/1/clearLogo',
|
||||
maxWidth: 400,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: 3,
|
||||
imageType: ImageType.heroLogo,
|
||||
);
|
||||
|
||||
expect(url, contains('minSize=0'));
|
||||
expect(url, contains('upscale=0'));
|
||||
});
|
||||
|
||||
test('poster slots stay covering requests', () {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: '/library/metadata/1/thumb/2',
|
||||
maxWidth: 200,
|
||||
maxHeight: 300,
|
||||
devicePixelRatio: 2,
|
||||
imageType: ImageType.poster,
|
||||
);
|
||||
|
||||
expect(url, contains('minSize=1'));
|
||||
expect(url, contains('upscale=1'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class _ShelfClient implements MediaServerClient {
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height}) {
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
|
||||
if (throwOnThumbnail) throw StateError('conversion failed');
|
||||
expect(width, 640);
|
||||
expect(height, 360);
|
||||
|
||||
@@ -13,8 +13,8 @@ import '../test_helpers/media_items.dart';
|
||||
/// Only [thumbnailUrl] is exercised; everything else throws via noSuchMethod.
|
||||
class _SizedUrlFakeClient implements MediaServerClient {
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height}) =>
|
||||
(width == null && height == null) ? 'unsized:$path' : 'sized:$path?w=$width&h=$height';
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) =>
|
||||
(width == null && height == null) ? 'unsized:$path' : 'sized:$path?w=$width&h=$height&cover=$cover';
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
@@ -110,7 +110,39 @@ void main() {
|
||||
devicePixelRatio: 2,
|
||||
);
|
||||
|
||||
expect(url, 'sized:/library/metadata/1/thumb/2?w=400&h=600');
|
||||
expect(url, 'sized:/library/metadata/1/thumb/2?w=400&h=600&cover=true');
|
||||
});
|
||||
|
||||
test('logos ask the server to fit inside the slot, not cover it', () {
|
||||
// Logos paint with BoxFit.contain, so a covering transcode overshoots
|
||||
// the long axis and the fit-policy decode bounds discard the extra.
|
||||
for (final type in [ImageType.logo, ImageType.heroLogo]) {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: '/library/metadata/1/clearLogo',
|
||||
maxWidth: 400,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: 3,
|
||||
imageType: type,
|
||||
);
|
||||
|
||||
expect(url, contains('cover=false'), reason: '$type should fit inside its slot');
|
||||
}
|
||||
});
|
||||
|
||||
test('slot-filling artwork keeps covering the requested box', () {
|
||||
for (final type in [ImageType.poster, ImageType.art, ImageType.thumb, ImageType.square, ImageType.avatar]) {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: '/library/metadata/1/thumb/2',
|
||||
maxWidth: 200,
|
||||
maxHeight: 300,
|
||||
devicePixelRatio: 2,
|
||||
imageType: type,
|
||||
);
|
||||
|
||||
expect(url, contains('cover=true'), reason: '$type should fill its slot');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/utils/layout_constants.dart';
|
||||
import 'package:plezy/widgets/optimized_media_image.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildLogo({String? logoPath, double width = 400, double height = 120, double devicePixelRatio = 3}) {
|
||||
return MaterialApp(
|
||||
home: MediaQuery(
|
||||
// DPR 3 is where the phone hero logo slot (400×120) asks for a
|
||||
// 1200-wide decode and runs into the 1000px heroLogo width cap.
|
||||
data: MediaQueryData(size: const Size(390, 844), devicePixelRatio: devicePixelRatio, disableAnimations: true),
|
||||
child: Center(
|
||||
child: ClearLogoImage(
|
||||
client: null,
|
||||
logoPath: logoPath,
|
||||
width: width,
|
||||
height: height,
|
||||
fallbackBuilder: (context) => const Text('Fallback Title'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('decodes with the aspect-preserving fit policy', (tester) async {
|
||||
await tester.pumpWidget(buildLogo(logoPath: 'https://example.com/logo.png'));
|
||||
|
||||
final provider = tester.widget<Image>(find.byType(Image)).image;
|
||||
|
||||
// Both axes stay bounded so an oversized original can't blow the decode
|
||||
// budget, but `fit` keeps the source ratio: capping only the width under
|
||||
// the default `exact` policy is what squashed hero logos on phones.
|
||||
expect(
|
||||
provider,
|
||||
isA<ResizeImage>()
|
||||
.having((r) => r.policy, 'policy', ResizeImagePolicy.fit)
|
||||
.having((r) => r.width, 'width', isNotNull)
|
||||
.having((r) => r.height, 'height', isNotNull),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('bounds the TV spotlight slot without pinning it to the slot ratio', (tester) async {
|
||||
// The TV slot (520×150 at the 2.0 TV DPR floor) asks for 1040×300 and is
|
||||
// capped to 1000×300 — the width clamps, the height doesn't. Under the old
|
||||
// `exact` decode that pinned every TV logo to 3.33∶1: a 4313×1035 source
|
||||
// served at 1250×300 rendered as 1000×300, 1.25x too tall (checked against
|
||||
// PMS 1.43). `fit` scales it to 1000×240 and keeps 4.17∶1.
|
||||
await tester.pumpWidget(
|
||||
buildLogo(
|
||||
logoPath: 'https://example.com/logo.png',
|
||||
width: TvLayoutConstants.heroLogoWidth,
|
||||
height: TvLayoutConstants.heroLogoHeight,
|
||||
devicePixelRatio: 2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.widget<Image>(find.byType(Image)).image,
|
||||
isA<ResizeImage>()
|
||||
.having((r) => r.policy, 'policy', ResizeImagePolicy.fit)
|
||||
.having((r) => r.width, 'width', 1000)
|
||||
.having((r) => r.height, 'height', 300),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('falls back to the title when there is no logo path', (tester) async {
|
||||
await tester.pumpWidget(buildLogo(logoPath: null));
|
||||
|
||||
expect(find.text('Fallback Title'), findsOneWidget);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('falls back to the title when no URL can be built', (tester) async {
|
||||
// Relative path with no client (offline) resolves to an empty URL.
|
||||
await tester.pumpWidget(buildLogo(logoPath: '/library/metadata/1/clearLogo'));
|
||||
|
||||
expect(find.text('Fallback Title'), findsOneWidget);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sizes itself to the requested logo slot', (tester) async {
|
||||
await tester.pumpWidget(buildLogo(logoPath: null, width: 520, height: 150));
|
||||
|
||||
expect(tester.getSize(find.byType(ClearLogoImage)), const Size(520, 150));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user