Files
plezy/lib/widgets/cycling_media_backdrop.dart
edde746 5d83d4833c fix(images): stop decoding artwork nothing ever displays
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.
2026-08-23 17:51:27 +02:00

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)),
),
],
);
}
}