feat: use plex image transcoding
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user