From a4dccb01988536c6540753ee1bc1b69887b11efb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 4 Dec 2025 06:04:56 +0100 Subject: [PATCH] feat: use plex image transcoding --- lib/screens/discover_screen.dart | 55 ++-- lib/screens/media_detail_screen.dart | 43 ++- lib/screens/season_detail_screen.dart | 46 ++-- lib/utils/plex_image_helper.dart | 251 +++++++++++++++++ lib/widgets/media_card.dart | 56 ++-- lib/widgets/playlist_item_card.dart | 37 +-- lib/widgets/plex_optimized_image.dart | 254 ++++++++++++++++++ .../video_controls/sheets/chapter_sheet.dart | 28 +- 8 files changed, 639 insertions(+), 131 deletions(-) create mode 100644 lib/utils/plex_image_helper.dart create mode 100644 lib/widgets/plex_optimized_image.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 455489eb..e446b0ea 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; +import '../widgets/plex_optimized_image.dart'; +import '../utils/plex_image_helper.dart'; import '../models/plex_metadata.dart'; import '../models/plex_hub.dart'; import '../providers/multi_server_provider.dart'; @@ -1024,21 +1026,27 @@ class _DiscoverScreenState extends State child: Builder( builder: (context) { final client = _getClientForItem(heroItem); + final mediaQuery = MediaQuery.of(context); + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: heroItem.art ?? heroItem.grandparentArt, + maxWidth: mediaQuery.size.width, + maxHeight: mediaQuery.size.height * 0.7, + devicePixelRatio: mediaQuery.devicePixelRatio, + imageType: ImageType.art, + ); + return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - heroItem.art ?? heroItem.grandparentArt, - ), + imageUrl: imageUrl, fit: BoxFit.cover, memCacheWidth: - (MediaQuery.of(context).size.width * - MediaQuery.of(context).devicePixelRatio) + (mediaQuery.size.width * + mediaQuery.devicePixelRatio) .clamp(900, 2400) .round(), memCacheHeight: - (MediaQuery.of(context).size.height * - MediaQuery.of( - context, - ).devicePixelRatio * + (mediaQuery.size.height * + mediaQuery.devicePixelRatio * 0.7) .clamp(600, 1600) .round(), @@ -1103,19 +1111,26 @@ class _DiscoverScreenState extends State child: Builder( builder: (context) { final client = _getClientForItem(heroItem); + final dpr = MediaQuery.of( + context, + ).devicePixelRatio; + final logoUrl = + PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: heroItem.clearLogo, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - heroItem.clearLogo, - ), + imageUrl: logoUrl, filterQuality: FilterQuality.medium, fit: BoxFit.contain, - memCacheWidth: - (400 * - MediaQuery.of( - context, - ).devicePixelRatio) - .clamp(200, 800) - .round(), + memCacheWidth: (400 * dpr) + .clamp(200, 800) + .round(), alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 17848526..2922dd69 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,8 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../i18n/strings.g.dart'; +import '../widgets/plex_optimized_image.dart'; +import '../utils/plex_image_helper.dart'; +import '../widgets/plex_optimized_image.dart'; import '../mixins/keyboard_long_press_mixin.dart'; import '../widgets/focus/focus_indicator.dart'; import '../client/plex_client.dart'; @@ -419,8 +423,18 @@ class _MediaDetailScreenState extends State { Builder( builder: (context) { final client = _getClientForMetadata(context); + final mediaQuery = MediaQuery.of(context); + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.art, + maxWidth: mediaQuery.size.width, + maxHeight: mediaQuery.size.height * 0.6, + devicePixelRatio: mediaQuery.devicePixelRatio, + imageType: ImageType.art, + ); + return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(metadata.art), + imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => Container( color: Theme.of( @@ -480,13 +494,27 @@ class _MediaDetailScreenState extends State { final client = _getClientForMetadata( context, ); + final dpr = MediaQuery.of( + context, + ).devicePixelRatio; + final logoUrl = + PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.clearLogo, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - metadata.clearLogo, - ), + imageUrl: logoUrl, filterQuality: FilterQuality.medium, fit: BoxFit.contain, alignment: Alignment.centerLeft, + memCacheWidth: (400 * dpr) + .clamp(200, 800) + .round(), placeholder: (context, url) => Align( alignment: Alignment.centerLeft, child: Text( @@ -1263,10 +1291,9 @@ class _FocusableSeasonCardState extends State<_FocusableSeasonCard> if (season.thumb != null) ClipRRect( borderRadius: BorderRadius.circular(6), - child: CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - season.thumb, - ), + child: PlexPosterImage( + client: widget.client, + imagePath: season.thumb, width: 80, height: 120, fit: BoxFit.cover, diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 45c0ab94..a4773d71 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; +import '../widgets/plex_optimized_image.dart'; import '../widgets/focus/focus_indicator.dart'; import '../models/plex_metadata.dart'; import '../utils/keyboard_utils.dart'; @@ -299,32 +299,26 @@ class _EpisodeCardState extends State<_EpisodeCard> child: AspectRatio( aspectRatio: 16 / 9, child: episode.thumb != null - ? Builder( - builder: (context) { - return CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - episode.thumb, + ? PlexThumbImage( + client: widget.client, + imagePath: episode.thumb, + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.movie, + size: 32, + ), ), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - placeholder: (context, url) => - Container( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), - errorWidget: (context, url, error) => - Container( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - child: const Icon( - Icons.movie, - size: 32, - ), - ), - ); - }, ) : Container( color: Theme.of( diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/plex_image_helper.dart new file mode 100644 index 00000000..613ab1a3 --- /dev/null +++ b/lib/utils/plex_image_helper.dart @@ -0,0 +1,251 @@ +import 'dart:math'; +import '../client/plex_client.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 +} + +class PlexImageHelper { + 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; + + /// Rounds dimensions to cache-friendly values to increase cache hit rate + static (int width, int height) roundDimensions(double width, double height) { + final roundedWidth = + (width / _widthRoundingFactor).ceil() * _widthRoundingFactor; + final roundedHeight = + (height / _heightRoundingFactor).ceil() * _heightRoundingFactor; + + return ( + roundedWidth.clamp(_minTranscodedWidth, _maxTranscodedWidth), + roundedHeight.clamp(_minTranscodedHeight, _maxTranscodedHeight), + ); + } + + /// 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: + // For art/background images, preserve aspect ratio while covering container + // Calculate dimensions that ensure the image covers the container without stretching + // This mimics BoxFit.cover behavior for the transcoding request + + // Use larger dimensions to ensure coverage while preserving aspect ratio + // This will request a slightly larger image that can be cropped by Flutter's BoxFit.cover + final coverWidth = targetWidth * 1.1; // 10% larger for better coverage + final coverHeight = targetHeight * 1.1; + + return roundDimensions(coverWidth, coverHeight); + + case ImageType.logo: + // For logos, use generous bounds to avoid forcing aspect ratio + // Prefer width-based scaling for most logos + final logoWidth = targetWidth; + final logoHeight = targetHeight; // Allow full height flexibility + return roundDimensions(logoWidth, logoHeight); + + case ImageType.thumb: + // For episode thumbs, optimize for 16:9 but allow flexibility + final thumbHeight = targetHeight; + final thumbWidth = min(targetWidth, thumbHeight * (16 / 9)); + return roundDimensions(thumbWidth, thumbHeight); + + case ImageType.avatar: + // For avatars, use square dimensions based on smaller constraint + final size = min(targetWidth, targetHeight); + return roundDimensions(size, size); + + case ImageType.poster: + // For posters, maintain 2:3 aspect ratio + final calculatedWidth = min(targetWidth, targetHeight / (2 / 3)); + final calculatedHeight = calculatedWidth * (2 / 3); + return roundDimensions(calculatedWidth, calculatedHeight); + } + } + + /// Builds a Plex photo transcode URL with optimized parameters + static String buildTranscodeUrl({ + required PlexClient client, + required String originalPath, + required int width, + int? height, + }) { + final baseUrl = client.config.baseUrl; + final token = client.config.token; + + // URL encode the original path with token + final encodedPath = Uri.encodeComponent( + '$originalPath${originalPath.contains('?') ? '&' : '?'}X-Plex-Token=$token', + ); + + // Build the transcode URL + final transcodeParams = { + 'width': width.toString(), + if (height != null) 'height': height.toString(), + 'minSize': '1', // Ensure minimum size is maintained + 'upscale': '1', // Allow upscaling for better quality + 'url': encodedPath, + 'X-Plex-Token': token, + }; + + final queryString = transcodeParams.entries + .map((e) => '${e.key}=${e.value}') + .join('&'); + + return '$baseUrl/photo/:/transcode?$queryString'; + } + + /// Creates an optimized image URL for Plex content + /// Falls back to original URL if transcoding is not appropriate + static String getOptimizedImageUrl({ + required PlexClient 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 we can't/shouldn't transcode (already a full URL), just return it. + if (basePath.startsWith('http://') || basePath.startsWith('https://')) { + return basePath; + } + + // For art/backgrounds and clear logos, prefer the original image to avoid + // any aspect ratio changes from Plex photo transcoding. + if (imageType == ImageType.art || imageType == ImageType.logo) { + return client.getThumbnailUrl(basePath); + } + + final canTranscode = enableTranscoding && shouldTranscode(basePath); + + // If marked non-transcodable or transcoding disabled, use the direct thumbnail URL. + if (!canTranscode) { + return client.getThumbnailUrl(basePath); + } + + // For very small images use original URL + if (maxWidth < 80 || maxHeight < 120) { + return client.getThumbnailUrl(basePath); + } + + // Calculate optimal dimensions + final (width, height) = calculateOptimalDimensions( + maxWidth: maxWidth, + maxHeight: maxHeight, + devicePixelRatio: devicePixelRatio, + imageType: imageType, + ); + + // For art and logos we only constrain width to preserve native aspect. + final useWidthOnly = + imageType == ImageType.art || imageType == ImageType.logo; + + // For dimensions close to minimum, use original to avoid unnecessary processing + if (width <= _minTranscodedWidth * 1.2 && + height <= _minTranscodedHeight * 1.2) { + return client.getThumbnailUrl(basePath); + } + + try { + return buildTranscodeUrl( + client: client, + originalPath: basePath, + width: width, + height: useWidthOnly ? null : height, + ); + } catch (e) { + // Fallback to original URL on any error + return client.getThumbnailUrl(basePath); + } + } + + /// Generates cache-friendly dimensions for memory caching + static (int memWidth, int memHeight) getMemCacheDimensions({ + required int displayWidth, + required int displayHeight, + double scaleFactor = 1.0, + }) { + final scaledWidth = (displayWidth * scaleFactor).round(); + final scaledHeight = (displayHeight * scaleFactor).round(); + + return (scaledWidth.clamp(120, 1200), scaledHeight.clamp(180, 1800)); + } + + /// Determines if an image path is suitable for transcoding + static bool shouldTranscode(String? imagePath) { + if (imagePath == null || imagePath.isEmpty) return false; + + // Don't transcode already processed images or external URLs + if (imagePath.contains('/photo/:/transcode') || + imagePath.startsWith('http://') || + imagePath.startsWith('https://')) { + return false; + } + + return true; + } + + /// Creates a consistent cache key for rounded dimensions + static String generateCacheKey({ + required String originalPath, + required int width, + required int height, + String? serverId, + }) { + final serverPrefix = serverId != null ? '${serverId}_' : ''; + return '${serverPrefix}transcode_${width}x${height}_${originalPath.hashCode}'; + } + + /// Extract the underlying image URL if the provided path is already a + /// Plex photo transcode URL. This prevents double-transcoding logos/art + /// which can distort their aspect ratios. + static String _extractOriginalFromTranscode(String path) { + if (!path.contains('/photo/:/transcode') || !path.contains('url=')) { + return path; + } + + try { + final uri = Uri.parse( + path.startsWith('http') ? path : 'http://_dummy$path', + ); + final urlParam = uri.queryParameters['url']; + if (urlParam == null || urlParam.isEmpty) { + return path; + } + + return Uri.decodeComponent(urlParam); + } catch (_) { + return path; + } + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 35fd17c5..a51bf16b 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import 'focus/focus_indicator.dart'; import 'hub_navigation_controller.dart'; @@ -22,6 +21,7 @@ import '../screens/collection_detail_screen.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; +import 'plex_optimized_image.dart'; class MediaCard extends StatefulWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist @@ -972,48 +972,30 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { if (item is PlexPlaylist) { posterUrl = item.displayImage; fallbackIcon = Icons.playlist_play; + + return PlexPlaylistImage( + client: _getClientForItem(context, item), + imagePath: posterUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); - } - if (posterUrl != null) { - return LayoutBuilder( - builder: (context, constraints) { - final client = _getClientForItem(context, item); - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - // Fall back to a reasonable size if constraints are unbounded. - final targetWidth = - (constraints.maxWidth.isFinite ? constraints.maxWidth : 160) * - devicePixelRatio; - final targetHeight = - (constraints.maxHeight.isFinite ? constraints.maxHeight : 240) * - devicePixelRatio; - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - // Decode close to the rendered size to keep memory in check when - // many posters load at once. - memCacheWidth: targetWidth.clamp(120, 800).round(), - memCacheHeight: targetHeight.clamp(180, 1200).round(), - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => const SkeletonLoader(), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center(child: Icon(fallbackIcon, size: 40)), - ), - ); - }, - ); - } else { - return SkeletonLoader( - child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)), + return PlexPosterImage( + client: _getClientForItem(context, item), + imagePath: posterUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, ); } + + return SkeletonLoader( + child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)), + ); } /// Overlay widget for poster showing watched indicator and progress bar diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 0d4698fb..b9a0bd85 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; import '../mixins/keyboard_long_press_mixin.dart'; import '../models/plex_metadata.dart'; @@ -8,6 +7,7 @@ import '../utils/provider_extensions.dart'; import '../i18n/strings.g.dart'; import 'focus/focus_indicator.dart'; import 'media_context_menu.dart'; +import 'plex_optimized_image.dart'; /// Custom list item widget for playlist items /// Shows drag handle, poster, title/metadata, duration, and remove button @@ -164,29 +164,18 @@ class _PlaylistItemCardState extends State Widget _buildPosterImage(BuildContext context) { final posterUrl = widget.item.posterThumb(); - if (posterUrl != null) { - return Builder( - builder: (context) { - final client = _getClientForItem(context); - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - - return ClipRRect( - borderRadius: BorderRadius.circular(6), - child: CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), - width: 60, - height: 90, - memCacheWidth: (60 * devicePixelRatio).round(), - memCacheHeight: (90 * devicePixelRatio).round(), - fit: BoxFit.cover, - placeholder: (context, url) => _buildPlaceholder(), - errorWidget: (context, url, error) => _buildPlaceholder(), - ), - ); - }, - ); - } - return _buildPlaceholder(); + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: PlexPosterImage( + client: _getClientForItem(context), + imagePath: posterUrl, + width: 60, + height: 90, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), + ), + ); } Widget _buildPlaceholder() { diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart new file mode 100644 index 00000000..57f550ad --- /dev/null +++ b/lib/widgets/plex_optimized_image.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../utils/plex_image_helper.dart'; +import 'media_card.dart'; + +class PlexOptimizedImage extends StatelessWidget { + final PlexClient client; + final String? imagePath; + final double? width; + final double? height; + final BoxFit fit; + final FilterQuality filterQuality; + final Widget Function(BuildContext, String)? placeholder; + final Widget Function(BuildContext, String, dynamic)? errorWidget; + final Duration fadeInDuration; + final bool enableTranscoding; + final String? cacheKey; + final Alignment alignment; + final IconData? fallbackIcon; + final ImageType imageType; + + const PlexOptimizedImage({ + super.key, + required this.client, + required this.imagePath, + this.width, + this.height, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.medium, + this.placeholder, + this.errorWidget, + this.fadeInDuration = const Duration(milliseconds: 300), + this.enableTranscoding = true, + this.cacheKey, + this.alignment = Alignment.center, + this.fallbackIcon, + this.imageType = ImageType.poster, + }); + + @override + Widget build(BuildContext context) { + double resolvedDimension( + double? explicit, + double constraintMax, + double fallback, + ) { + // Pick the explicit size when it's a finite positive number, otherwise + // fall back to the constraint or a sensible default so we don't end up + // with NaN/Infinity when rounding to ints for caching. + final candidate = + explicit ?? + (constraintMax.isFinite && constraintMax > 0 + ? constraintMax + : fallback); + if (candidate.isNaN || candidate.isInfinite || candidate <= 0) { + return fallback; + } + return candidate; + } + + // Return empty container if no image path + if (imagePath == null || imagePath!.isEmpty) { + return _buildFallback(context); + } + + return LayoutBuilder( + builder: (context, constraints) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + + // Calculate effective constraints with safe fallbacks + final effectiveWidth = resolvedDimension( + width, + constraints.maxWidth, + 300.0, + ); + final effectiveHeight = resolvedDimension( + height, + constraints.maxHeight, + 450.0, + ); + + // Get optimized image URL + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: imagePath, + maxWidth: effectiveWidth, + maxHeight: effectiveHeight, + devicePixelRatio: devicePixelRatio, + enableTranscoding: + enableTranscoding && PlexImageHelper.shouldTranscode(imagePath), + imageType: imageType, + ); + + if (imageUrl.isEmpty) { + return _buildFallback(context); + } + + // Calculate memory cache dimensions + final scaledWidth = effectiveWidth * devicePixelRatio; + final scaledHeight = effectiveHeight * devicePixelRatio; + final (memWidth, memHeight) = PlexImageHelper.getMemCacheDimensions( + displayWidth: scaledWidth.isFinite && scaledWidth > 0 + ? scaledWidth.round() + : 0, + displayHeight: scaledHeight.isFinite && scaledHeight > 0 + ? scaledHeight.round() + : 0, + ); + + // Generate cache key if not provided + final effectiveCacheKey = + cacheKey ?? _generateCacheKey(imageUrl, memWidth, memHeight); + + return CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + filterQuality: filterQuality, + alignment: alignment, + fadeInDuration: fadeInDuration, + memCacheWidth: memWidth, + memCacheHeight: memHeight, + cacheKey: effectiveCacheKey, + placeholder: placeholder != null + ? placeholder! + : (context, url) => _buildPlaceholder(context), + errorWidget: errorWidget != null + ? errorWidget! + : (context, url, error) => _buildErrorWidget(context, error), + httpHeaders: {'User-Agent': 'Plezy Flutter Client'}, + ); + }, + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return SkeletonLoader( + child: fallbackIcon != null + ? Center(child: Icon(fallbackIcon!, size: 40, color: Colors.white54)) + : null, + ); + } + + Widget _buildErrorWidget(BuildContext context, dynamic error) { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + fallbackIcon ?? Icons.broken_image, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildFallback(BuildContext context) { + return Container( + width: width, + height: height, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + fallbackIcon ?? Icons.image_not_supported, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + String _generateCacheKey(String imageUrl, int memWidth, int memHeight) { + final urlHash = imageUrl.hashCode; + return 'plex_optimized_${memWidth}x${memHeight}_$urlHash'; + } +} + +/// Specialized version for posters with default fallback icon +class PlexPosterImage extends PlexOptimizedImage { + const PlexPosterImage({ + super.key, + required super.client, + required super.imagePath, + super.width, + super.height, + super.fit = BoxFit.cover, + super.filterQuality = FilterQuality.medium, + super.placeholder, + super.errorWidget, + super.fadeInDuration = const Duration(milliseconds: 300), + super.enableTranscoding = true, + super.cacheKey, + super.alignment = Alignment.center, + }) : super(fallbackIcon: Icons.movie, imageType: ImageType.poster); +} + +/// Specialized version for art/background images +class PlexArtImage extends PlexOptimizedImage { + const PlexArtImage({ + super.key, + required super.client, + required super.imagePath, + super.width, + super.height, + super.fit = BoxFit.cover, + super.filterQuality = FilterQuality.medium, + super.placeholder, + super.errorWidget, + super.fadeInDuration = const Duration(milliseconds: 300), + super.enableTranscoding = true, + super.cacheKey, + super.alignment = Alignment.center, + }) : super(fallbackIcon: Icons.wallpaper, imageType: ImageType.art); +} + +/// Specialized version for episode thumbnails +class PlexThumbImage extends PlexOptimizedImage { + const PlexThumbImage({ + super.key, + required super.client, + required super.imagePath, + super.width, + super.height, + super.fit = BoxFit.cover, + super.filterQuality = FilterQuality.medium, + super.placeholder, + super.errorWidget, + super.fadeInDuration = const Duration(milliseconds: 300), + super.enableTranscoding = true, + super.cacheKey, + super.alignment = Alignment.center, + }) : super(fallbackIcon: Icons.video_library, imageType: ImageType.thumb); +} + +/// Specialized version for playlist images +class PlexPlaylistImage extends PlexOptimizedImage { + const PlexPlaylistImage({ + super.key, + required super.client, + required super.imagePath, + super.width, + super.height, + super.fit = BoxFit.cover, + super.filterQuality = FilterQuality.medium, + super.placeholder, + super.errorWidget, + super.fadeInDuration = const Duration(milliseconds: 300), + super.enableTranscoding = true, + super.cacheKey, + super.alignment = Alignment.center, + }) : super(fallbackIcon: Icons.playlist_play, imageType: ImageType.poster); +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 85de3835..c2e099e7 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -6,6 +6,7 @@ import '../../../models/plex_media_info.dart'; import '../../../utils/duration_formatter.dart'; import '../../../utils/provider_extensions.dart'; import 'base_video_control_sheet.dart'; +import '../../plex_optimized_image.dart'; /// Bottom sheet for selecting chapters class ChapterSheet extends StatelessWidget { @@ -98,22 +99,17 @@ class ChapterSheet extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: Builder( - builder: (context) { - final client = _getClientForChapters(context); - return Image.network( - client.getThumbnailUrl(chapter.thumb), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), - ); - }, + child: PlexThumbImage( + client: _getClientForChapters(context), + imagePath: chapter.thumb, + width: 60, + height: 34, + fit: BoxFit.cover, + errorWidget: (context, url, error) => const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), ), ), if (isCurrentChapter)