278 lines
10 KiB
Dart
278 lines
10 KiB
Dart
import 'dart:math';
|
|
import 'package:flutter/widgets.dart';
|
|
import '../media/media_server_client.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;
|
|
|
|
/// 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 (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:
|
|
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,
|
|
);
|
|
|
|
// For very small targets, skip server-side resizing — the cost of the
|
|
// transcode round-trip outweighs the savings.
|
|
if (maxWidth < 80 || maxHeight < 120) {
|
|
return client.thumbnailUrl(basePath);
|
|
}
|
|
if (width <= _minTranscodedWidth * 1.2 && height <= _minTranscodedHeight * 1.2) {
|
|
return client.thumbnailUrl(basePath);
|
|
}
|
|
|
|
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) {
|
|
ImageType.poster => (720, 1080),
|
|
ImageType.thumb => (960, 540),
|
|
ImageType.art => (1920, 1080),
|
|
ImageType.logo => (600, 300),
|
|
ImageType.avatar => (300, 300),
|
|
};
|
|
|
|
return (bucketedWidth.clamp(120, maxW), bucketedHeight.clamp(180, maxH));
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
}
|