fix(artwork): preserve clear-logo aspect ratios

This commit is contained in:
edde746
2026-07-26 04:34:08 +02:00
parent ef183437d4
commit fcaa81bf3c
13 changed files with 347 additions and 147 deletions
+12 -2
View File
@@ -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
+14 -63
View File
@@ -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) ...[
+6 -34
View File
@@ -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 {
+17 -10
View File
@@ -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',
];
+12 -2
View File
@@ -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.
+69
View File
@@ -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.171 logo into
/// 2.781.
///
/// [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
+5 -29
View File
@@ -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() {}
+83
View File
@@ -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'));
});
});
}
+1 -1
View File
@@ -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);
+35 -3
View File
@@ -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');
}
});
});
+87
View File
@@ -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.331: 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.171.
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));
});
}