Files
plezy/lib/utils/media_image_helper.dart
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00

362 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';
import 'tone_mapped_logo_image.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 art caps: backdrops at ~720p, masked by the gradient scrims
/// drawn over them. Tiles (posters/thumbs/squares) deliberately keep full
/// resolution — capping them reads as blur on large TV panels (#2020) —
/// while backdrops are the largest RGBA decodes on screen.
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 (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,
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=')) {
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) 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 (!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,
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, _widthRoundingFactor);
final bucketedHeight = _bucketUp(displayHeight, _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. On the reduced tier the factor
// is pinned to 1.0, so tiles keep the fixed 1080p baseline there; only
// art gets an explicit smaller pair.
final budget = DevicePerformance.displayBudgetFactor();
int scaled(int cap) => (cap * budget).round();
final (int maxW, int maxH) = switch (imageType) {
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 => (scaled(720), scaled(720)),
ImageType.thumb => (scaled(960), scaled(540)),
// Reduced-tier backdrops match the ~720p fetch cap so oversized
// originals (failed transcodes, external images) can't decode past
// the low-RAM art budget.
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.
///
/// [logoToneTarget] wraps the decode in a [ToneMappedLogoImage] that
/// recolors light-toned channel logos toward the given theme foreground so
/// they stay legible on light surfaces. It participates only in the memory
/// cache key; the disk cache keeps serving the original bytes.
/// [logoToneRemapMixed] forwards the [ToneMappedLogoImage.remapMixed]
/// policy.
static ImageProvider serverArtworkProvider({
required String imageUrl,
required int memWidth,
required int memHeight,
Color? logoToneTarget,
bool logoToneRemapMixed = true,
}) {
final provider = CachedNetworkImageProvider(
imageUrl,
cacheKey: _serverArtworkCacheKey(imageUrl),
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
);
final bounded = boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
if (logoToneTarget == null) return bounded;
return ToneMappedLogoImage(bounded, target: logoToneTarget, remapMixed: logoToneRemapMixed);
}
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;
}
}