Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
338 lines
14 KiB
Dart
338 lines
14 KiB
Dart
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;
|
||
|
||
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:
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
// 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 => (720, 720),
|
||
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.heroLogo => (1000, 500),
|
||
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);
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|