The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which sizes the request, bounds the decode and namespaces the disk key. Five places escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache. - Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a 10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps the same provider in `ResizeImage`, so it decodes again under a different cache key. The comment already said the intent was only to warm the disk cache, so it now does exactly that through the cache manager and resolves no image stream. - `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every 10 s forever, with no `DevicePerformance` term in `_canRotate` even though its own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is 3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the viewer sits still and evicting the rail posters behind it. The fade is already zero-duration there, so the rotation bought variety and nothing else. - Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/ `backdropFor` already existed for this; the sites now use them. - The season strip, the Live TV programme sheet and the profile avatar built `CachedNetworkImage` directly, keeping the sized request but losing the `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and re-downloaded on detail open. The season strip also discarded its height bound. Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back to the base URL.
490 lines
15 KiB
Dart
490 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../media/media_server_client.dart';
|
|
import '../services/device_performance.dart';
|
|
import '../utils/media_image_helper.dart';
|
|
import 'optimized_media_image.dart';
|
|
|
|
Future<bool> _defaultLocalFileExists(File file) => file.exists();
|
|
|
|
/// Displays server artwork and rotates through multiple backdrops in order.
|
|
///
|
|
/// The settled image remains visible until the incoming provider produces a
|
|
/// frame. Failed candidates are skipped without flashing an empty frame.
|
|
class CyclingMediaBackdrop extends StatefulWidget {
|
|
const CyclingMediaBackdrop({
|
|
super.key,
|
|
required this.mediaKey,
|
|
required this.imagePaths,
|
|
required this.client,
|
|
required this.width,
|
|
required this.height,
|
|
required this.fallbackColor,
|
|
this.fallbackImagePaths = const [],
|
|
this.localArtworkPathResolver,
|
|
this.imageProviderResolver,
|
|
this.allowNetwork = true,
|
|
this.localFileExists = _defaultLocalFileExists,
|
|
this.active = true,
|
|
this.fit = BoxFit.cover,
|
|
this.alignment = Alignment.center,
|
|
this.rotationInterval = const Duration(seconds: 10),
|
|
this.fadeDuration = const Duration(milliseconds: 280),
|
|
});
|
|
|
|
final Object? mediaKey;
|
|
final List<String> imagePaths;
|
|
final List<String> fallbackImagePaths;
|
|
final MediaServerClient? client;
|
|
final String? Function(String artworkPath)? localArtworkPathResolver;
|
|
|
|
/// Overrides provider construction for deterministic widget tests.
|
|
@visibleForTesting
|
|
final ImageProvider? Function(String artworkPath)? imageProviderResolver;
|
|
|
|
/// Overrides local file checks for deterministic widget tests.
|
|
@visibleForTesting
|
|
final Future<bool> Function(File file) localFileExists;
|
|
final bool allowNetwork;
|
|
final bool active;
|
|
final double width;
|
|
final double height;
|
|
final BoxFit fit;
|
|
final Alignment alignment;
|
|
final Color fallbackColor;
|
|
final Duration rotationInterval;
|
|
final Duration fadeDuration;
|
|
|
|
@override
|
|
State<CyclingMediaBackdrop> createState() => _CyclingMediaBackdropState();
|
|
}
|
|
|
|
class _CyclingMediaBackdropState extends State<CyclingMediaBackdrop> with WidgetsBindingObserver {
|
|
Timer? _rotationTimer;
|
|
late List<String> _rotationPaths;
|
|
late List<String> _fallbackPaths;
|
|
final Set<String> _failedPaths = <String>{};
|
|
final Set<String> _pendingProviderFailures = <String>{};
|
|
int _rotationIndex = 0;
|
|
int _fallbackIndex = 0;
|
|
bool _lifecycleResumed = true;
|
|
bool _tickerEnabled = true;
|
|
bool _disableAnimations = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_lifecycleResumed = switch (WidgetsBinding.instance.lifecycleState) {
|
|
null || AppLifecycleState.resumed => true,
|
|
_ => false,
|
|
};
|
|
_replacePaths();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_tickerEnabled = TickerMode.valuesOf(context).enabled;
|
|
final disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
|
if (_disableAnimations != disableAnimations) {
|
|
_disableAnimations = disableAnimations;
|
|
}
|
|
_restartRotationTimer();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant CyclingMediaBackdrop oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
final pathsChanged =
|
|
widget.mediaKey != oldWidget.mediaKey ||
|
|
!listEquals(widget.imagePaths, oldWidget.imagePaths) ||
|
|
!listEquals(widget.fallbackImagePaths, oldWidget.fallbackImagePaths);
|
|
if (pathsChanged) {
|
|
_replacePaths();
|
|
}
|
|
if (pathsChanged || widget.active != oldWidget.active || widget.rotationInterval != oldWidget.rotationInterval) {
|
|
_restartRotationTimer();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
_lifecycleResumed = state == AppLifecycleState.resumed;
|
|
if (_lifecycleResumed) {
|
|
_restartRotationTimer();
|
|
} else {
|
|
_rotationTimer?.cancel();
|
|
_rotationTimer = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_rotationTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _replacePaths() {
|
|
_rotationPaths = _uniquePaths(widget.imagePaths);
|
|
final rotating = _rotationPaths.toSet();
|
|
_fallbackPaths = _uniquePaths(widget.fallbackImagePaths).where((path) => !rotating.contains(path)).toList();
|
|
_failedPaths.clear();
|
|
_pendingProviderFailures.clear();
|
|
_rotationIndex = 0;
|
|
_fallbackIndex = 0;
|
|
}
|
|
|
|
static List<String> _uniquePaths(List<String> paths) {
|
|
if (paths.isEmpty) return const [];
|
|
final unique = <String>[];
|
|
for (final path in paths) {
|
|
if (path.isEmpty || unique.contains(path)) continue;
|
|
unique.add(path);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
int get _usableRotationCount => _rotationPaths.where((path) => !_failedPaths.contains(path)).length;
|
|
|
|
bool get _canRotate =>
|
|
widget.active &&
|
|
!DevicePerformance.isReduced &&
|
|
_lifecycleResumed &&
|
|
_tickerEnabled &&
|
|
!_disableAnimations &&
|
|
_usableRotationCount > 1;
|
|
|
|
void _restartRotationTimer() {
|
|
_rotationTimer?.cancel();
|
|
_rotationTimer = null;
|
|
if (!_canRotate) return;
|
|
_rotationTimer = Timer(widget.rotationInterval, _handleRotationTimer);
|
|
}
|
|
|
|
void _handleRotationTimer() {
|
|
_rotationTimer = null;
|
|
if (!mounted) return;
|
|
if (!_canRotate) return;
|
|
_advanceRotation();
|
|
_restartRotationTimer();
|
|
}
|
|
|
|
void _advanceRotation() {
|
|
if (_rotationPaths.isEmpty) return;
|
|
for (var offset = 1; offset <= _rotationPaths.length; offset++) {
|
|
final next = (_rotationIndex + offset) % _rotationPaths.length;
|
|
if (_failedPaths.contains(_rotationPaths[next])) continue;
|
|
if (next == _rotationIndex) return;
|
|
setState(() => _rotationIndex = next);
|
|
return;
|
|
}
|
|
}
|
|
|
|
String? get _currentPath {
|
|
if (_rotationPaths.isNotEmpty && !_failedPaths.contains(_rotationPaths[_rotationIndex])) {
|
|
return _rotationPaths[_rotationIndex];
|
|
}
|
|
for (var offset = 0; offset < _rotationPaths.length; offset++) {
|
|
final index = (_rotationIndex + offset) % _rotationPaths.length;
|
|
if (!_failedPaths.contains(_rotationPaths[index])) return _rotationPaths[index];
|
|
}
|
|
if (_fallbackPaths.isNotEmpty && !_failedPaths.contains(_fallbackPaths[_fallbackIndex])) {
|
|
return _fallbackPaths[_fallbackIndex];
|
|
}
|
|
for (var offset = 0; offset < _fallbackPaths.length; offset++) {
|
|
final index = (_fallbackIndex + offset) % _fallbackPaths.length;
|
|
if (!_failedPaths.contains(_fallbackPaths[index])) return _fallbackPaths[index];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _handleImageError(Object? key) {
|
|
final path = key is String ? key : null;
|
|
if (!mounted || path == null || _failedPaths.contains(path)) return;
|
|
setState(() {
|
|
_failedPaths.add(path);
|
|
_pendingProviderFailures.remove(path);
|
|
final rotationPosition = _rotationPaths.indexOf(path);
|
|
if (rotationPosition >= 0) {
|
|
for (var offset = 1; offset <= _rotationPaths.length; offset++) {
|
|
final next = (rotationPosition + offset) % _rotationPaths.length;
|
|
if (_failedPaths.contains(_rotationPaths[next])) continue;
|
|
_rotationIndex = next;
|
|
break;
|
|
}
|
|
} else {
|
|
final fallbackPosition = _fallbackPaths.indexOf(path);
|
|
if (fallbackPosition >= 0) {
|
|
for (var offset = 1; offset <= _fallbackPaths.length; offset++) {
|
|
final next = (fallbackPosition + offset) % _fallbackPaths.length;
|
|
if (_failedPaths.contains(_fallbackPaths[next])) continue;
|
|
_fallbackIndex = next;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
_restartRotationTimer();
|
|
}
|
|
|
|
ImageProvider? _providerFor(BuildContext context, String path, File? localFile) {
|
|
final providerOverride = widget.imageProviderResolver;
|
|
if (providerOverride != null) return providerOverride(path);
|
|
|
|
final size = MediaQuery.sizeOf(context);
|
|
final width = widget.width.isFinite && widget.width > 0 ? widget.width : size.width;
|
|
final height = widget.height.isFinite && widget.height > 0 ? widget.height : size.height;
|
|
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
|
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
|
displayWidth: (width * dpr).round(),
|
|
displayHeight: (height * dpr).round(),
|
|
imageType: ImageType.art,
|
|
);
|
|
|
|
if (localFile != null) {
|
|
return MediaImageHelper.boundedDecode(FileImage(localFile), memWidth: memWidth, memHeight: memHeight);
|
|
}
|
|
if (!widget.allowNetwork) return null;
|
|
|
|
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
|
client: widget.client,
|
|
thumbPath: path,
|
|
maxWidth: width,
|
|
maxHeight: height,
|
|
devicePixelRatio: dpr,
|
|
imageType: ImageType.art,
|
|
);
|
|
if (imageUrl.isEmpty) return null;
|
|
return MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: memWidth, memHeight: memHeight);
|
|
}
|
|
|
|
void _reportMissingProvider(String path) {
|
|
if (!_pendingProviderFailures.add(path)) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _handleImageError(path);
|
|
});
|
|
}
|
|
|
|
Widget _buildCrossfade(
|
|
BuildContext context,
|
|
String? path, {
|
|
required LocalFileResolution localResolution,
|
|
File? localFile,
|
|
}) {
|
|
final pending = localResolution == LocalFileResolution.pending;
|
|
final provider = path == null || pending ? null : _providerFor(context, path, localFile);
|
|
if (path != null && provider == null && !pending) _reportMissingProvider(path);
|
|
final fadeDuration = _disableAnimations ? Duration.zero : DevicePerformance.reducedDuration(widget.fadeDuration);
|
|
|
|
return _BackdropArtworkCrossfade(
|
|
artworkKey: (widget.mediaKey, path),
|
|
imageErrorKey: path,
|
|
image: provider,
|
|
pending: pending,
|
|
duration: fadeDuration,
|
|
fit: widget.fit,
|
|
alignment: widget.alignment,
|
|
fallbackColor: widget.fallbackColor,
|
|
onImageError: _handleImageError,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final path = _currentPath;
|
|
if (path == null) {
|
|
return _buildCrossfade(context, null, localResolution: LocalFileResolution.missing);
|
|
}
|
|
|
|
final localPath = widget.localArtworkPathResolver?.call(path);
|
|
if (localPath == null) {
|
|
return _buildCrossfade(context, path, localResolution: LocalFileResolution.missing);
|
|
}
|
|
|
|
return ResolvedLocalFile(
|
|
path: localPath,
|
|
cacheMissing: true,
|
|
fileExists: widget.localFileExists,
|
|
builder: (context, resolution, file) =>
|
|
_buildCrossfade(context, path, localResolution: resolution, localFile: file),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BackdropArtworkCrossfade extends StatefulWidget {
|
|
const _BackdropArtworkCrossfade({
|
|
required this.artworkKey,
|
|
required this.imageErrorKey,
|
|
required this.image,
|
|
required this.pending,
|
|
required this.duration,
|
|
required this.fit,
|
|
required this.alignment,
|
|
required this.fallbackColor,
|
|
required this.onImageError,
|
|
});
|
|
|
|
final Object? artworkKey;
|
|
final Object? imageErrorKey;
|
|
final ImageProvider? image;
|
|
final bool pending;
|
|
final Duration duration;
|
|
final BoxFit fit;
|
|
final Alignment alignment;
|
|
final Color fallbackColor;
|
|
final ValueChanged<Object?> onImageError;
|
|
|
|
@override
|
|
State<_BackdropArtworkCrossfade> createState() => _BackdropArtworkCrossfadeState();
|
|
}
|
|
|
|
class _BackdropArtworkCrossfadeState extends State<_BackdropArtworkCrossfade> with SingleTickerProviderStateMixin {
|
|
late final AnimationController _fade;
|
|
late Object? _currentKey = widget.artworkKey;
|
|
late ImageProvider? _base = widget.pending ? null : widget.image;
|
|
late Object? _baseErrorKey = widget.imageErrorKey;
|
|
ImageProvider? _incoming;
|
|
Object? _incomingErrorKey;
|
|
bool _incomingIsColor = false;
|
|
bool _fadeStarted = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fade = AnimationController(vsync: this, duration: widget.duration);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _BackdropArtworkCrossfade oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_fade.duration = widget.duration;
|
|
if (widget.artworkKey == _currentKey) {
|
|
if (widget.pending) return;
|
|
if (oldWidget.pending) {
|
|
_transitionToIncoming();
|
|
return;
|
|
}
|
|
if (widget.image != null && widget.image != _base && _incoming == null) {
|
|
_base = widget.image;
|
|
_baseErrorKey = widget.imageErrorKey;
|
|
}
|
|
return;
|
|
}
|
|
|
|
_currentKey = widget.artworkKey;
|
|
if (widget.pending) {
|
|
if (_base == null && _incoming != null && _fade.value == 1) {
|
|
_base = _incoming;
|
|
_baseErrorKey = _incomingErrorKey;
|
|
}
|
|
setState(() {
|
|
_fade.stop();
|
|
_dropIncoming();
|
|
});
|
|
return;
|
|
}
|
|
if (widget.image != null && widget.image == _base) {
|
|
_baseErrorKey = widget.imageErrorKey;
|
|
_dropIncoming();
|
|
return;
|
|
}
|
|
|
|
_transitionToIncoming();
|
|
}
|
|
|
|
void _transitionToIncoming() {
|
|
setState(() {
|
|
_fade.stop();
|
|
_fade.value = 0;
|
|
_fadeStarted = false;
|
|
_incoming = widget.image;
|
|
_incomingErrorKey = widget.imageErrorKey;
|
|
_incomingIsColor = widget.image == null;
|
|
if (_incoming == null) _startFade();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fade.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startFade() {
|
|
if (_fadeStarted) return;
|
|
_fadeStarted = true;
|
|
_fade.forward().whenComplete(_promoteIncoming);
|
|
}
|
|
|
|
void _promoteIncoming() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_base = _incoming;
|
|
_baseErrorKey = _incomingErrorKey;
|
|
_dropIncoming();
|
|
});
|
|
}
|
|
|
|
void _dropIncoming() {
|
|
_incoming = null;
|
|
_incomingErrorKey = null;
|
|
_incomingIsColor = false;
|
|
_fadeStarted = false;
|
|
_fade.value = 0;
|
|
}
|
|
|
|
void _reportError(Object? imageErrorKey) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) widget.onImageError(imageErrorKey);
|
|
});
|
|
}
|
|
|
|
Widget _image(ImageProvider provider, Object? imageErrorKey, {Animation<double>? opacity}) {
|
|
final incoming = opacity != null;
|
|
return Image(
|
|
key: incoming ? ValueKey<ImageProvider>(provider) : null,
|
|
image: provider,
|
|
fit: widget.fit,
|
|
alignment: widget.alignment,
|
|
excludeFromSemantics: true,
|
|
gaplessPlayback: true,
|
|
opacity: opacity,
|
|
frameBuilder: !incoming
|
|
? null
|
|
: (context, child, frame, wasSynchronouslyLoaded) {
|
|
if (frame != null || wasSynchronouslyLoaded) {
|
|
_startFade();
|
|
}
|
|
return child;
|
|
},
|
|
errorBuilder: (context, error, stackTrace) {
|
|
_reportError(imageErrorKey);
|
|
return incoming ? const SizedBox.shrink() : ColoredBox(color: widget.fallbackColor);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (_base != null) _image(_base!, _baseErrorKey) else ColoredBox(color: widget.fallbackColor),
|
|
if (_incoming != null) _image(_incoming!, _incomingErrorKey, opacity: _fade),
|
|
if (_incomingIsColor)
|
|
AnimatedBuilder(
|
|
animation: _fade,
|
|
builder: (context, _) =>
|
|
ColoredBox(color: widget.fallbackColor.withValues(alpha: widget.fallbackColor.a * _fade.value)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|