379 lines
13 KiB
Dart
379 lines
13 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:ui';
|
|
|
|
import 'package:cached_network_image_ce/cached_network_image.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
|
|
import '../media/media_server_client.dart';
|
|
import '../services/device_performance.dart';
|
|
import '../services/image_cache_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/media_image_helper.dart';
|
|
import '../utils/obfuscation_utils.dart';
|
|
|
|
/// Tracks recent image load failures to log a periodic summary instead of
|
|
/// spamming per-image. Resets after [_logInterval] so recurring issues
|
|
/// remain visible.
|
|
int _imageFailureCount = 0;
|
|
DateTime _lastFailureLog = DateTime.now();
|
|
const _logInterval = Duration(seconds: 10);
|
|
|
|
Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) {
|
|
if (!kBlurArtwork) return child;
|
|
final filtered = ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
|
child: child,
|
|
);
|
|
return clip ? ClipRect(child: filtered) : filtered;
|
|
}
|
|
|
|
class OptimizedMediaImage extends StatelessWidget {
|
|
final MediaServerClient? 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;
|
|
final String? localFilePath;
|
|
|
|
const OptimizedMediaImage._({
|
|
super.key,
|
|
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,
|
|
this.localFilePath,
|
|
});
|
|
|
|
/// Generic constructor for optimized images.
|
|
const factory OptimizedMediaImage({
|
|
Key? key,
|
|
MediaServerClient? client,
|
|
required String? imagePath,
|
|
double? width,
|
|
double? height,
|
|
BoxFit fit,
|
|
FilterQuality filterQuality,
|
|
Widget Function(BuildContext, String)? placeholder,
|
|
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
|
Duration fadeInDuration,
|
|
bool enableTranscoding,
|
|
String? cacheKey,
|
|
Alignment alignment,
|
|
IconData? fallbackIcon,
|
|
ImageType imageType,
|
|
String? localFilePath,
|
|
}) = OptimizedMediaImage._;
|
|
|
|
/// Named constructor for poster images with default fallback icon.
|
|
const OptimizedMediaImage.poster({
|
|
Key? key,
|
|
MediaServerClient? client,
|
|
required String? imagePath,
|
|
double? width,
|
|
double? height,
|
|
BoxFit fit = BoxFit.cover,
|
|
FilterQuality filterQuality = FilterQuality.medium,
|
|
Widget Function(BuildContext, String)? placeholder,
|
|
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
|
Duration fadeInDuration = const Duration(milliseconds: 300),
|
|
bool enableTranscoding = true,
|
|
String? cacheKey,
|
|
Alignment alignment = Alignment.center,
|
|
IconData? fallbackIcon,
|
|
String? localFilePath,
|
|
}) : this._(
|
|
key: key,
|
|
client: client,
|
|
imagePath: imagePath,
|
|
width: width,
|
|
height: height,
|
|
fit: fit,
|
|
filterQuality: filterQuality,
|
|
placeholder: placeholder,
|
|
errorWidget: errorWidget,
|
|
fadeInDuration: fadeInDuration,
|
|
enableTranscoding: enableTranscoding,
|
|
cacheKey: cacheKey,
|
|
alignment: alignment,
|
|
fallbackIcon: fallbackIcon ?? Symbols.movie_rounded,
|
|
imageType: ImageType.poster,
|
|
localFilePath: localFilePath,
|
|
);
|
|
|
|
/// Named constructor for episode thumbnails.
|
|
const OptimizedMediaImage.thumb({
|
|
Key? key,
|
|
MediaServerClient? client,
|
|
required String? imagePath,
|
|
double? width,
|
|
double? height,
|
|
BoxFit fit = BoxFit.cover,
|
|
FilterQuality filterQuality = FilterQuality.medium,
|
|
Widget Function(BuildContext, String)? placeholder,
|
|
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
|
Duration fadeInDuration = const Duration(milliseconds: 300),
|
|
bool enableTranscoding = true,
|
|
String? cacheKey,
|
|
Alignment alignment = Alignment.center,
|
|
IconData? fallbackIcon,
|
|
String? localFilePath,
|
|
}) : this._(
|
|
key: key,
|
|
client: client,
|
|
imagePath: imagePath,
|
|
width: width,
|
|
height: height,
|
|
fit: fit,
|
|
filterQuality: filterQuality,
|
|
placeholder: placeholder,
|
|
errorWidget: errorWidget,
|
|
fadeInDuration: fadeInDuration,
|
|
enableTranscoding: enableTranscoding,
|
|
cacheKey: cacheKey,
|
|
alignment: alignment,
|
|
fallbackIcon: fallbackIcon ?? Symbols.video_library_rounded,
|
|
imageType: ImageType.thumb,
|
|
localFilePath: localFilePath,
|
|
);
|
|
|
|
/// Named constructor for playlist images.
|
|
const OptimizedMediaImage.playlist({
|
|
Key? key,
|
|
MediaServerClient? client,
|
|
required String? imagePath,
|
|
double? width,
|
|
double? height,
|
|
BoxFit fit = BoxFit.cover,
|
|
FilterQuality filterQuality = FilterQuality.medium,
|
|
Widget Function(BuildContext, String)? placeholder,
|
|
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
|
Duration fadeInDuration = const Duration(milliseconds: 300),
|
|
bool enableTranscoding = true,
|
|
String? cacheKey,
|
|
Alignment alignment = Alignment.center,
|
|
String? localFilePath,
|
|
}) : this._(
|
|
key: key,
|
|
client: client,
|
|
imagePath: imagePath,
|
|
width: width,
|
|
height: height,
|
|
fit: fit,
|
|
filterQuality: filterQuality,
|
|
placeholder: placeholder,
|
|
errorWidget: errorWidget,
|
|
fadeInDuration: fadeInDuration,
|
|
enableTranscoding: enableTranscoding,
|
|
cacheKey: cacheKey,
|
|
alignment: alignment,
|
|
fallbackIcon: Symbols.playlist_play_rounded,
|
|
imageType: ImageType.poster,
|
|
localFilePath: localFilePath,
|
|
);
|
|
|
|
/// Whether both width and height are explicitly set to finite positive values,
|
|
/// meaning we can skip the LayoutBuilder.
|
|
bool get _hasKnownDimensions =>
|
|
width != null && width!.isFinite && width! > 0 && height != null && height!.isFinite && height! > 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localFile = localFilePath != null ? File(localFilePath!) : null;
|
|
final hasLocal = localFile != null && localFile.existsSync();
|
|
|
|
// No local file and no network path → fallback
|
|
if (!hasLocal && (imagePath == null || imagePath!.isEmpty)) {
|
|
return _buildFallback(context);
|
|
}
|
|
|
|
// Fast path: skip LayoutBuilder when both dimensions are explicitly known
|
|
if (_hasKnownDimensions) {
|
|
return blurArtwork(
|
|
hasLocal
|
|
? _buildLocalFileImage(context, localFile, width!, height!)
|
|
: _buildCachedImage(context, width!, height!),
|
|
);
|
|
}
|
|
|
|
return blurArtwork(
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0);
|
|
final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0);
|
|
return hasLocal
|
|
? _buildLocalFileImage(context, localFile, effectiveWidth, effectiveHeight)
|
|
: _buildCachedImage(context, effectiveWidth, effectiveHeight);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLocalFileImage(BuildContext context, File file, double effectiveWidth, double effectiveHeight) {
|
|
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
|
final scaledWidth = effectiveWidth * dpr;
|
|
final scaledHeight = effectiveHeight * dpr;
|
|
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
|
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
|
|
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
|
|
imageType: imageType,
|
|
);
|
|
|
|
return Image.file(
|
|
file,
|
|
width: width,
|
|
height: height,
|
|
// Only cacheHeight: leaving cacheWidth null preserves decode aspect
|
|
// ratio, mirroring the network branch's ResizeImage wrapper.
|
|
cacheHeight: memHeight > 0 ? memHeight : null,
|
|
fit: fit,
|
|
filterQuality: filterQuality,
|
|
alignment: alignment,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
if (errorWidget != null) {
|
|
return errorWidget!(context, file.path, error);
|
|
}
|
|
return _buildErrorWidget(context, error);
|
|
},
|
|
);
|
|
}
|
|
|
|
static 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.
|
|
if (explicit == null || explicit.isNaN || explicit.isInfinite || explicit <= 0) {
|
|
if (constraintMax.isFinite && constraintMax > 0) {
|
|
return constraintMax;
|
|
}
|
|
return fallback;
|
|
}
|
|
return explicit;
|
|
}
|
|
|
|
Widget _buildCachedImage(BuildContext context, double effectiveWidth, double effectiveHeight) {
|
|
final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context);
|
|
|
|
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
|
client: client,
|
|
thumbPath: imagePath,
|
|
maxWidth: effectiveWidth,
|
|
maxHeight: effectiveHeight,
|
|
devicePixelRatio: devicePixelRatio,
|
|
enableTranscoding: enableTranscoding,
|
|
imageType: imageType,
|
|
);
|
|
|
|
if (imageUrl.isEmpty) {
|
|
return _buildFallback(context);
|
|
}
|
|
|
|
final scaledWidth = effectiveWidth * devicePixelRatio;
|
|
final scaledHeight = effectiveHeight * devicePixelRatio;
|
|
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
|
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
|
|
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
|
|
imageType: imageType,
|
|
);
|
|
|
|
final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl);
|
|
|
|
final provider = CachedNetworkImageProvider(
|
|
imageUrl,
|
|
cacheKey: effectiveCacheKey,
|
|
cacheManager: PlexImageCacheManager.instance,
|
|
headers: const {'User-Agent': 'Plezy'},
|
|
);
|
|
|
|
return Image(
|
|
image: ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider),
|
|
width: width,
|
|
height: height,
|
|
fit: fit,
|
|
filterQuality: filterQuality,
|
|
alignment: alignment,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
_imageFailureCount++;
|
|
final now = DateTime.now();
|
|
if (now.difference(_lastFailureLog) >= _logInterval) {
|
|
appLogger.w('Image load failed ($_imageFailureCount since last log): $error');
|
|
_imageFailureCount = 0;
|
|
_lastFailureLog = now;
|
|
}
|
|
if (errorWidget != null) {
|
|
return errorWidget!(context, imageUrl, error);
|
|
}
|
|
return _buildErrorWidget(context, error);
|
|
},
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
|
if (wasSynchronouslyLoaded) return child;
|
|
// Reduced tier: swap in directly — each in-flight fade is a tile-sized
|
|
// saveLayer, and grid scrolling runs many of them concurrently.
|
|
if (DevicePerformance.isReduced) {
|
|
return frame != null ? child : _buildPlaceholder(context, imageUrl);
|
|
}
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 300),
|
|
child: frame != null ? child : _buildPlaceholder(context, imageUrl),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) {
|
|
final theme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
width: fillParent ? null : width,
|
|
height: fillParent ? null : height,
|
|
color: theme.surfaceContainerHighest,
|
|
child: icon == null
|
|
? null
|
|
: Center(child: AppIcon(icon, fill: 1, size: 40, color: iconColor ?? theme.onSurfaceVariant)),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaceholder(BuildContext context, String imageUrl) {
|
|
if (placeholder != null) return placeholder!(context, imageUrl);
|
|
return _surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54);
|
|
}
|
|
|
|
Widget _buildErrorWidget(BuildContext context, dynamic _) => _surfacePlaceholder(
|
|
context,
|
|
icon: fallbackIcon ?? Symbols.broken_image_rounded,
|
|
fillParent: !_hasKnownDimensions,
|
|
);
|
|
|
|
Widget _buildFallback(BuildContext context) =>
|
|
_surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded);
|
|
|
|
String _generateCacheKey(String imageUrl) {
|
|
// URL already encodes bucketed transcode dimensions via roundDimensions,
|
|
// so the URL hash alone uniquely identifies the bytes on disk. Including
|
|
// mem-cache dimensions here would re-introduce churn on every pixel of
|
|
// window resize and defeat getMemCacheDimensions' bucketing.
|
|
return 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}';
|
|
}
|
|
}
|