diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 89d0f931..8ac9894c 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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 diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 952e2ef7..d5aebd88 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 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) ...[ diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 16e3b26a..46e8456c 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3668,40 +3668,12 @@ class _MediaDetailScreenState extends State ); 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, ); }, ), diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 5d8ea676..5035a055 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -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.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 resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9ded3135..79541d74 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 _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 = [ - 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 = [ - 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', ]; diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index b430d4da..822dc064 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -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. diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 3e4e1e0d..fd6744ff 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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 diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 6caba3dc..5e18908d 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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), ); } diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 83e4480b..4c735f5c 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -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() {} diff --git a/test/services/plex_image_url_test.dart b/test/services/plex_image_url_test.dart new file mode 100644 index 00000000..98534a17 --- /dev/null +++ b/test/services/plex_image_url_test.dart @@ -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')); + }); + }); +} diff --git a/test/services/system_shelf_service_test.dart b/test/services/system_shelf_service_test.dart index ef2ea0a5..274e2c14 100644 --- a/test/services/system_shelf_service_test.dart +++ b/test/services/system_shelf_service_test.dart @@ -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); diff --git a/test/utils/media_image_helper_test.dart b/test/utils/media_image_helper_test.dart index bb18404b..be9d188a 100644 --- a/test/utils/media_image_helper_test.dart +++ b/test/utils/media_image_helper_test.dart @@ -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'); + } }); }); diff --git a/test/widgets/clear_logo_image_test.dart b/test/widgets/clear_logo_image_test.dart new file mode 100644 index 00000000..cf94d4ea --- /dev/null +++ b/test/widgets/clear_logo_image_test.dart @@ -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(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() + .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(find.byType(Image)).image, + isA() + .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)); + }); +}