Files
plezy/test/utils/media_image_helper_test.dart
T
edde746 3fb90a4948 fix(images): fetch full-resolution tile artwork on low-end hardware
Posters and episode thumbnails looked noticeably soft on Fire TV sticks and
similar boxes: the auto-detected reduced tier capped artwork DPR at 1.5 and
tightened tile decode caps, so every tile was fetched at 56% of its pixels
and upscaled twice on large 4K panels.

Drop the reduced-tier DPR cap and the poster/square/thumb decode caps so
tiles fetch and decode at full TV density on every tier; raise the reduced
image-cache budget to the 64MB TV baseline to absorb the larger tiles.
Backdrops keep their scrim-masked ~720p reduced caps, and the display budget
factor stays pinned to 1.0 there, so the low-RAM art ceiling is unchanged.

close #2020
2026-08-19 13:16:36 +02:00

370 lines
14 KiB
Dart

import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_image_helper.dart';
import 'package:plezy/utils/platform_detector.dart';
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, 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);
}
MediaItem _item(MediaKind kind) => testMediaItem(kind: kind);
void main() {
group('MediaImageHelper.getOptimizedImageUrl', () {
test('adds size hints to absolute Jellyfin artwork URLs', () {
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=token',
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
final uri = Uri.parse(url);
expect(uri.queryParameters['tag'], 'abc');
expect(uri.queryParameters['api_key'], 'token');
expect(uri.queryParameters['maxWidth'], '240');
expect(uri.queryParameters['maxHeight'], '360');
});
test('preserves existing Jellyfin size hints and fills missing dimension', () {
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: 'https://jf.example/Items/item-1/Images/Primary?api_key=token&maxWidth=100',
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
final uri = Uri.parse(url);
expect(uri.queryParameters['api_key'], 'token');
expect(uri.queryParameters['maxWidth'], '100');
expect(uri.queryParameters['maxHeight'], '360');
});
test('leaves non-Jellyfin external URLs unchanged without a proxy client', () {
const original = 'https://images.example/poster.jpg';
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: original,
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
expect(url, original);
});
});
group('MediaImageHelper.getOptimizedImageUrl sized transcodes', () {
// Unsized URLs hand the full original to the decoder — a multi-megapixel
// original behind a tiny slot is the decode spike that OOMs low-RAM
// devices, so every card-sized request must carry dimensions.
final client = _SizedUrlFakeClient();
test('tiny slots still request a sized transcode (min bucket)', () {
final url = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: '/library/metadata/1/thumb/2',
maxWidth: 40,
maxHeight: 60,
devicePixelRatio: 1,
);
expect(url, startsWith('sized:'));
expect(url, contains('w=160'));
expect(url, contains('h=240'));
});
test('regular slots request DPR-scaled dimensions', () {
final url = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: '/library/metadata/1/thumb/2',
maxWidth: 200,
maxHeight: 300,
devicePixelRatio: 2,
);
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');
}
});
});
group('MediaImageHelper.getMemCacheDimensions tier caps', () {
tearDown(DevicePerformance.debugReset);
test('full tier caps thumb and poster decodes', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb),
(960, 540),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(720, 1080),
);
});
test('reduced tier keeps full tile decode caps but bounds art (#2020)', () {
DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb),
(960, 540),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(720, 1080),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.square),
(720, 720),
);
// Backdrops stay at the ~720p low-RAM art budget; scrims mask the cap.
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.art),
(1280, 720),
);
});
});
group('MediaImageHelper.effectiveDevicePixelRatio', () {
tearDown(() {
DevicePerformance.debugReset();
TvDetectionService.debugSetAppleTVOverride(null);
});
Future<double> dprFor(WidgetTester tester, {required double mediaQueryDpr}) async {
late double result;
await tester.pumpWidget(
MediaQuery(
data: MediaQueryData(devicePixelRatio: mediaQueryDpr),
child: Builder(
builder: (context) {
result = MediaImageHelper.effectiveDevicePixelRatio(context);
return const SizedBox.shrink();
},
),
),
);
return result;
}
testWidgets('reduced tier no longer caps artwork density (#2020)', (tester) async {
DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto);
expect(await dprFor(tester, mediaQueryDpr: 2.0), 2.0);
});
testWidgets('reduced-tier TV keeps the sharp-artwork DPR floor (#2020)', (tester) async {
DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto);
TvDetectionService.debugSetAppleTVOverride(true);
expect(await dprFor(tester, mediaQueryDpr: 1.0), 2.0);
});
});
group('MediaImageHelper display budget scaling (#1697)', () {
tearDown(DevicePerformance.debugReset);
void latch4kBudget() {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
DevicePerformance.debugDisplayShortestSideOverride = 2160;
DevicePerformance.debugDetectDisplayBudget();
}
test('a 4K display doubles the full-tier decode caps', () {
latch4kBudget();
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(1440, 2160),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb),
(1920, 1080),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.art),
(3840, 2160),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo),
(2000, 1000),
);
});
test('a 4K display raises the transcode clamp to the panel size', () {
latch4kBudget();
// A full-screen 4K backdrop request no longer clamps to 1080p...
expect(MediaImageHelper.roundDimensions(3840, 2160), (3840, 2160));
// ...while sub-cap requests keep their exact buckets.
expect(MediaImageHelper.roundDimensions(400, 600), (400, 600));
});
test('a 4K display leaves the reduced tier at the 1080p baseline', () {
DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto);
DevicePerformance.debugDisplayShortestSideOverride = 2160;
DevicePerformance.debugDetectDisplayBudget();
// The budget factor stays pinned to 1.0: tiles keep the full-tier
// 1080p baseline caps but never scale up with the display.
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(720, 1080),
);
expect(MediaImageHelper.roundDimensions(3840, 2160), (1920, 1080));
});
test('without a latch the 1080p clamps still apply', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
expect(MediaImageHelper.roundDimensions(3840, 2160), (1920, 1080));
});
});
group('MediaImageHelper image type budgets', () {
tearDown(DevicePerformance.debugReset);
test('hero logos get a larger budget without changing ordinary logos', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.logo),
(600, 300),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo),
(1000, 500),
);
});
test('square keeps a grid-cell decode budget while avatar stays small', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
// Cast cards fill poster-width grid cells (issue #1591): a retina cell
// needs well over the avatar cap, so they use the square budget.
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.square),
(720, 720),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.avatar),
(300, 300),
);
});
test('card artwork follows square, wide, and poster media shapes', () {
for (final kind in [MediaKind.artist, MediaKind.album, MediaKind.track]) {
expect(
MediaImageHelper.cardImageType(_item(kind), EpisodePosterMode.episodeThumbnail),
ImageType.square,
reason: '${kind.id} artwork must keep its square music cache budget',
);
}
expect(
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.episodeThumbnail),
ImageType.thumb,
);
expect(
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.seriesPoster),
ImageType.poster,
);
expect(
MediaImageHelper.cardImageType(_item(MediaKind.movie), EpisodePosterMode.episodeThumbnail),
ImageType.poster,
);
});
});
group('MediaImageHelper.serverArtworkProvider', () {
test('reuses cache identity and disk key while bounding both decode axes', () {
const url = 'https://example.invalid/library/poster.jpg';
final first = MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
final second =
MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
final firstCached = first.imageProvider as CachedNetworkImageProvider;
final secondCached = second.imageProvider as CachedNetworkImageProvider;
expect(firstCached, secondCached);
expect(first.width, second.width);
expect(first.width, 640);
expect(first.height, second.height);
expect(first.height, 360);
expect(first.policy, second.policy);
expect(first.policy, ResizeImagePolicy.fit);
expect(first.allowUpscaling, second.allowUpscaling);
expect(first.allowUpscaling, isFalse);
expect(firstCached.url, secondCached.url);
expect(firstCached.url, url);
expect(firstCached.headers, secondCached.headers);
expect(firstCached.headers, const {'User-Agent': 'Plezy'});
expect(firstCached.cacheKey, secondCached.cacheKey);
expect(firstCached.cacheKey, isNotNull);
expect(firstCached.maxWidth, secondCached.maxWidth);
expect(firstCached.maxWidth, isNull);
expect(firstCached.maxHeight, secondCached.maxHeight);
expect(firstCached.maxHeight, isNull);
});
});
group('MediaImageHelper.boundedDecode', () {
test('bounds both axes with fit policy (no distortion, no upscale)', () {
const base = NetworkImage('https://example/img');
final bounded = MediaImageHelper.boundedDecode(base, memWidth: 640, memHeight: 360);
expect(bounded, isA<ResizeImage>());
final resize = bounded as ResizeImage;
expect(resize.width, 640);
expect(resize.height, 360);
expect(resize.policy, ResizeImagePolicy.fit);
expect(resize.allowUpscaling, isFalse);
});
test('passes the provider through when no bound is known', () {
const base = NetworkImage('https://example/img');
expect(MediaImageHelper.boundedDecode(base, memWidth: 0, memHeight: 0), same(base));
});
});
}