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.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 = {}; 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; } }