308 lines
12 KiB
Dart
308 lines
12 KiB
Dart
import 'dart:math';
|
||
import 'package:flutter/widgets.dart';
|
||
import '../media/media_server_client.dart';
|
||
import '../services/device_performance.dart';
|
||
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
|
||
avatar, // Square-ish user avatars
|
||
}
|
||
|
||
/// 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;
|
||
|
||
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) {
|
||
return (
|
||
_bucketUp(width, _widthRoundingFactor).clamp(_minTranscodedWidth, _maxTranscodedWidth),
|
||
_bucketUp(height, _heightRoundingFactor).clamp(_minTranscodedHeight, _maxTranscodedHeight),
|
||
);
|
||
}
|
||
|
||
/// 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:
|
||
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:
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
/// 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);
|
||
|
||
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 => (720, 1080),
|
||
ImageType.thumb when DevicePerformance.isReduced => (640, 360),
|
||
ImageType.thumb => (960, 540),
|
||
ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight),
|
||
ImageType.art => (1920, 1080),
|
||
ImageType.logo => (600, 300),
|
||
ImageType.avatar => (300, 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);
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// Optimized URL for hero/background art ([ImageType.art]).
|
||
static String heroArtUrl({
|
||
required MediaServerClient? client,
|
||
required String? thumbPath,
|
||
required BuildContext context,
|
||
required double containerWidth,
|
||
required double containerHeight,
|
||
}) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.art);
|
||
|
||
/// Optimized URL for clear-logo overlays ([ImageType.logo]).
|
||
static String logoUrl({
|
||
required MediaServerClient? client,
|
||
required String? thumbPath,
|
||
required BuildContext context,
|
||
required double containerWidth,
|
||
required double containerHeight,
|
||
}) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo);
|
||
|
||
static String _typedUrl(
|
||
MediaServerClient? client,
|
||
String? thumbPath,
|
||
BuildContext context,
|
||
double containerWidth,
|
||
double containerHeight,
|
||
ImageType type,
|
||
) {
|
||
return getOptimizedImageUrl(
|
||
client: client,
|
||
thumbPath: thumbPath,
|
||
maxWidth: containerWidth,
|
||
maxHeight: containerHeight,
|
||
devicePixelRatio: effectiveDevicePixelRatio(context),
|
||
imageType: type,
|
||
);
|
||
}
|
||
}
|