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 _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 imagePaths; final List 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 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 createState() => _CyclingMediaBackdropState(); } class _CyclingMediaBackdropState extends State with WidgetsBindingObserver { Timer? _rotationTimer; late List _rotationPaths; late List _fallbackPaths; final Set _failedPaths = {}; final Set _pendingProviderFailures = {}; 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 _uniquePaths(List paths) { if (paths.isEmpty) return const []; final unique = []; 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 && _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 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? opacity}) { final incoming = opacity != null; return Image( key: incoming ? ValueKey(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)), ), ], ); } }