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.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, ); } }