Files
plezy/lib/utils/media_image_helper.dart
edde746 f13f5af6e2 fix(images): scale artwork budgets to the physical display
Every artwork budget in the image pipeline was tuned for 1080p surfaces:
the transcode request clamp (1920x1080), the per-type decode caps
(poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV
image-cache bytes. Those numbers are exact on phones and on the many TV
boxes that composite the app at 1080p, but a TV compositing at 4K
renders every capped image below its slot and GPU-upscales the result:
hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x,
shelf posters by ~1.13x - the softness reported against the official
Plex client in #1697, and the class #860's min-2x-DPR fix could not
reach.

DevicePerformance now latches a display budget factor - the display's
shortest physical axis over 1080, capped at 2x - whenever the image
cache budget is applied (startup, post-mount, effects-setting changes).
The transcode clamp, the full-tier decode caps and the TV cache bytes
all scale by it, so a 4K surface fetches and decodes 4K backdrops and
proportionally larger cards. The reduced tier stays pinned to 1.0, and
sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art
(~33MB per decode) cannot starve mid-RAM boxes; latching once per
session keeps transcode URLs - and with them the disk cache keys -
stable across rotation and rebuilds.

Whether a given TV composites at 1080p or 4K decides whether any of
this can help, and logs never recorded it: the startup banner and the
log-upload header now carry a display line (physical, logical, DPR,
latched budget) so uploaded logs answer that question directly.

The two pre-existing Windows-host test failures (automotive auto-PiP
gate, backdrop temp-dir teardown lock) reproduce unchanged on the base
commit.
2026-07-30 01:40:24 +02:00

358 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:math';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../services/device_performance.dart';
import '../services/image_cache_service.dart';
import '../services/settings_service.dart' show EpisodePosterMode;
import 'platform_detector.dart';
/// Image types for different transcoding strategies
enum ImageType {
poster, // 2:3 ratio posters
art, // Wide background art
thumb, // 16:9 episode thumbnails
logo, // Variable ratio clear logos
heroLogo, // Large hero clear logos
avatar, // Small square-ish avatars (user profiles, inline person headers)
square, // 1:1 grid-cell artwork (albums, artists, tracks, cast cards)
}
/// Backend-neutral image URL helper.
///
/// Builds optimally-sized image URLs that go through the right server-side
/// transcode path:
/// - **Plex**: `/photo/:/transcode?width=W&height=H&url=...&X-Plex-Token=...`
/// constructed by [MediaServerClient.thumbnailUrl] (PlexClient impl).
/// - **Jellyfin**: `/Items/{id}/Images/{type}?MaxWidth=W&MaxHeight=H&api_key=...`
/// constructed by [MediaServerClient.thumbnailUrl] (JellyfinClient impl).
///
/// Self-contained absolute URLs (Jellyfin items pre-absolutized at the
/// model layer) get sized via query-param append so they pick up the same
/// DPR scaling and cache-bucket rounding as Plex.
///
/// External URLs (EPG provider images, etc.) that the local server doesn't
/// host get proxied through Plex's photo transcoder when a Plex client is
/// available; otherwise they pass through unchanged.
class MediaImageHelper {
static const int _widthRoundingFactor = 40;
static const int _heightRoundingFactor = 60;
/// 1080p baseline; scaled by [DevicePerformance.displayBudgetFactor] so
/// 4K-surface displays can fetch up to 3840×2160 instead of upscaling.
static const int _maxTranscodedWidth = 1920;
static const int _maxTranscodedHeight = 1080;
static const int _minTranscodedWidth = 160;
static const int _minTranscodedHeight = 240;
/// Minimum DPR for TV to ensure sharp artwork on large screens
static const double _tvMinDpr = 2.0;
/// Reduced tier caps: tiles at 1.5× DPR, backdrops at ~720p. Smaller
/// transcodes mean fewer bytes fetched AND cheaper decodes on weak 32-bit
/// hardware; the art cap is masked by the gradient scrims drawn over it.
static const double _reducedMaxDpr = 1.5;
static const int _reducedMaxArtWidth = 1280;
static const int _reducedMaxArtHeight = 720;
/// Rounds a value up to the next multiple of [factor]. Shared between the
/// URL dimension rounding (transcode bucket) and the mem-cache dimension
/// rounding (decode bucket) so both snap to the same grid.
static int _bucketUp(num value, int factor) => (value / factor).ceil() * factor;
/// Rounds dimensions to cache-friendly values to increase cache hit rate
static (int width, int height) roundDimensions(double width, double height) {
final budget = DevicePerformance.displayBudgetFactor();
return (
_bucketUp(width, _widthRoundingFactor).clamp(_minTranscodedWidth, (_maxTranscodedWidth * budget).round()),
_bucketUp(height, _heightRoundingFactor).clamp(_minTranscodedHeight, (_maxTranscodedHeight * budget).round()),
);
}
/// Computes an effective device pixel ratio that accounts for displays where
/// the platform-reported DPR doesn't reflect the true physical density
/// (common on Linux X11 with compositor scaling).
static double effectiveDevicePixelRatio(BuildContext context) {
final reportedDpr = MediaQuery.devicePixelRatioOf(context);
double dpr;
try {
final displayWidth = View.of(context).display.size.width;
// Scale quality with display resolution: 1920px = baseline (1.0x)
final displayBasedDpr = (displayWidth / 1920).clamp(1.0, 3.0);
dpr = max(reportedDpr, displayBasedDpr);
} catch (_) {
dpr = reportedDpr;
}
if (DevicePerformance.isReduced) return min(dpr, _reducedMaxDpr);
if (PlatformDetector.isTV()) dpr = max(dpr, _tvMinDpr);
return dpr;
}
/// Calculates optimal image dimensions based on image type and constraints
static (int width, int height) calculateOptimalDimensions({
required double maxWidth,
required double maxHeight,
required double devicePixelRatio,
ImageType imageType = ImageType.poster,
}) {
final targetWidth = maxWidth.isFinite ? maxWidth * devicePixelRatio : 300 * devicePixelRatio;
final targetHeight = maxHeight.isFinite ? maxHeight * devicePixelRatio : 450 * devicePixelRatio;
switch (imageType) {
case ImageType.art:
if (DevicePerformance.isReduced) {
// No 1.1× cover overshoot, capped at ~720p.
return roundDimensions(
min(targetWidth, _reducedMaxArtWidth.toDouble()),
min(targetHeight, _reducedMaxArtHeight.toDouble()),
);
}
final coverWidth = targetWidth * 1.1;
final coverHeight = targetHeight * 1.1;
return roundDimensions(coverWidth, coverHeight);
case ImageType.logo:
case ImageType.heroLogo:
final logoWidth = targetWidth;
final logoHeight = targetHeight;
return roundDimensions(logoWidth, logoHeight);
case ImageType.thumb:
final thumbHeight = targetHeight;
final thumbWidth = min(targetWidth, thumbHeight * (16 / 9));
return roundDimensions(thumbWidth, thumbHeight);
case ImageType.avatar:
case ImageType.square:
final size = min(targetWidth, targetHeight);
return roundDimensions(size, size);
case ImageType.poster:
final calculatedWidth = min(targetWidth, targetHeight * (2 / 3));
final calculatedHeight = calculatedWidth * (3 / 2);
return roundDimensions(calculatedWidth, calculatedHeight);
}
}
/// 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
/// client is available (offline mode), or when transcoding is suppressed
/// for this path.
static String getOptimizedImageUrl({
MediaServerClient? client,
required String? thumbPath,
required double maxWidth,
required double maxHeight,
required double devicePixelRatio,
bool enableTranscoding = true,
ImageType imageType = ImageType.poster,
}) {
if (thumbPath == null || thumbPath.isEmpty) return '';
final basePath = thumbPath;
if (basePath.startsWith('http://') || basePath.startsWith('https://')) {
// Self-contained Jellyfin URLs already carry their own auth
// (`api_key=...`). Append `maxWidth/maxHeight` so we still get DPR
// scaling and cache-bucket rounding — Jellyfin's image endpoint
// honours those query params.
if (basePath.contains('api_key=')) {
if (!enableTranscoding) return basePath;
final (width, height) = calculateOptimalDimensions(
maxWidth: maxWidth,
maxHeight: maxHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
final uri = Uri.parse(basePath);
final params = Map<String, String>.from(uri.queryParameters);
final lowerKeys = params.keys.map((k) => k.toLowerCase()).toSet();
if (!lowerKeys.contains('maxwidth') && !lowerKeys.contains('width')) {
params['maxWidth'] = '$width';
}
if (!lowerKeys.contains('maxheight') && !lowerKeys.contains('height')) {
params['maxHeight'] = '$height';
}
return uri.replace(queryParameters: params).toString();
}
// EPG / external URL — proxy through the server's transcoder. Plex
// implements [externalImageUrl] via `/photo/:/transcode?url=...`;
// backends without a comparable endpoint return the URL unchanged.
if (client == null || !enableTranscoding) return basePath;
final (width, height) = calculateOptimalDimensions(
maxWidth: maxWidth,
maxHeight: maxHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
return client.externalImageUrl(basePath, width: width, height: height, cover: _coversSlot(imageType));
}
// Relative path — let the client build the sized URL using its native
// size-hint params (`/photo/:/transcode` for Plex, `MaxWidth/MaxHeight`
// for Jellyfin). The interface guarantees both honour width/height.
if (client == null) {
// Offline + relative path: the cached entry already exists under the
// URL originally fetched, so returning '' matches pre-refactor behaviour.
return '';
}
if (!enableTranscoding || !shouldTranscode(basePath)) {
return client.thumbnailUrl(basePath);
}
final (width, height) = calculateOptimalDimensions(
maxWidth: maxWidth,
maxHeight: maxHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
// Always request a sized transcode — even tiny slots. An unsized URL
// 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, cover: _coversSlot(imageType));
}
/// Generates cache-friendly dimensions for memory caching.
///
/// Max bounds are type-aware so large originals (e.g. failed server
/// transcodes or external EPG images) are capped at a resolution
/// appropriate for the display context.
static (int memWidth, int memHeight) getMemCacheDimensions({
required int displayWidth,
required int displayHeight,
double scaleFactor = 1.0,
ImageType imageType = ImageType.poster,
}) {
// Bucket to match roundDimensions() so the mem-cache key and CNIP
// maxHeight stay stable across sub-bucket resize deltas. Without this,
// LayoutBuilder rebuilds during window resize churn the cache key on
// every pixel and evict valid entries from Flutter's image cache.
final bucketedWidth = _bucketUp(displayWidth * scaleFactor, _widthRoundingFactor);
final bucketedHeight = _bucketUp(displayHeight * scaleFactor, _heightRoundingFactor);
// Full-tier caps are a 1080p baseline scaled to the display, so slots on
// a 4K surface decode at the resolution they render at instead of being
// GPU-upscaled from phone-sized budgets. Reduced-tier caps stay fixed
// (the factor is pinned to 1.0 there, and the explicit pairs keep the
// low-RAM budget independent of display probing).
final budget = DevicePerformance.displayBudgetFactor();
int scaled(int cap) => (cap * budget).round();
final (int maxW, int maxH) = switch (imageType) {
// Reduced-tier caps match the smaller fetch sizes so oversized
// originals (failed transcodes, external images) can't decode past
// the tile budget on low-RAM hardware.
ImageType.poster when DevicePerformance.isReduced => (480, 720),
ImageType.poster => (scaled(720), scaled(1080)),
// Square music artwork fills the same grid cells as posters, so both
// axes cap at the poster width budget.
ImageType.square when DevicePerformance.isReduced => (480, 480),
ImageType.square => (scaled(720), scaled(720)),
ImageType.thumb when DevicePerformance.isReduced => (640, 360),
ImageType.thumb => (scaled(960), scaled(540)),
ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight),
ImageType.art => (scaled(1920), scaled(1080)),
ImageType.logo => (scaled(600), scaled(300)),
ImageType.heroLogo => (scaled(1000), scaled(500)),
ImageType.avatar => (scaled(300), scaled(300)),
};
return (bucketedWidth.clamp(120, maxW), bucketedHeight.clamp(180, maxH));
}
/// Wraps [provider] so the decode is bounded on **both** axes.
///
/// `fit` policy keeps aspect ratio and never upscales, so an over-generous
/// bound is harmless — but an oversized original (failed server transcode,
/// local artwork file, ultra-wide banner) can no longer decode past the
/// display budget the way a single-axis bound allows.
static ImageProvider boundedDecode(ImageProvider provider, {required int memWidth, required int memHeight}) {
final width = memWidth > 0 ? memWidth : null;
final height = memHeight > 0 ? memHeight : null;
if (width == null && height == null) return provider;
return ResizeImage(provider, width: width, height: height, policy: ResizeImagePolicy.fit);
}
/// Selects the decode/transcode shape used by media cards and their
/// prefetchers. Keeping this derived from [MediaItem.cardShape] prevents the
/// renderer and prefetch pipeline from assigning different cache budgets.
static ImageType cardImageType(MediaItem item, EpisodePosterMode episodePosterMode, {bool mixedHubContext = false}) {
return switch (item.cardShape(episodePosterMode, mixedHubContext: mixedHubContext)) {
CardShape.square => ImageType.square,
CardShape.wide => ImageType.thumb,
CardShape.poster => ImageType.poster,
};
}
/// Creates the final provider for server-hosted artwork.
///
/// The disk key deliberately depends only on the fully bucketed URL. Decode
/// dimensions belong to Flutter's memory-cache key and must not fragment the
/// shared disk cache during small layout changes.
static ImageProvider serverArtworkProvider({
required String imageUrl,
required int memWidth,
required int memHeight,
String? cacheKey,
}) {
final provider = CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey ?? _serverArtworkCacheKey(imageUrl),
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
);
return boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
}
static final _serverArtworkCacheKeys = <String, String>{};
static const _serverArtworkCacheKeyLimit = 512;
static String _serverArtworkCacheKey(String imageUrl) {
final cached = _serverArtworkCacheKeys.remove(imageUrl);
if (cached != null) {
_serverArtworkCacheKeys[imageUrl] = cached;
return cached;
}
final key = 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}';
if (_serverArtworkCacheKeys.length >= _serverArtworkCacheKeyLimit) {
_serverArtworkCacheKeys.remove(_serverArtworkCacheKeys.keys.first);
}
_serverArtworkCacheKeys[imageUrl] = key;
return key;
}
/// Determines if an image path is suitable for transcoding
static bool shouldTranscode(String? imagePath) {
if (imagePath == null || imagePath.isEmpty) return false;
if (imagePath.contains('/photo/:/transcode') ||
imagePath.startsWith('http://') ||
imagePath.startsWith('https://')) {
return false;
}
return true;
}
}