feat: use plex image transcoding

This commit is contained in:
edde746
2025-12-04 06:05:15 +01:00
parent 74b34bcc77
commit a4dccb0198
8 changed files with 639 additions and 131 deletions
+35 -20
View File
@@ -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<DiscoverScreen>
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<DiscoverScreen>
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,
+35 -8
View File
@@ -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<MediaDetailScreen> {
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<MediaDetailScreen> {
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,
+20 -26
View File
@@ -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(
+251
View File
@@ -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;
}
}
}
+19 -37
View File
@@ -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<SettingsProvider>().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
+13 -24
View File
@@ -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<PlaylistItemCard>
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() {
+254
View File
@@ -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);
}
@@ -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)