import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/emby_headers_provider.dart'; import '../../providers/session_provider.dart'; class SmartImage extends ConsumerStatefulWidget { final String? url; final List fallbackUrls; final BoxFit fit; final Alignment alignment; final double? aspectRatio; final double borderRadius; final IconData? fallbackIcon; final VoidCallback? onLoad; final VoidCallback? onError; final Map? httpHeaders; final Map? Function(String url)? resolveHttpHeaders; final int? memCacheWidth; final Widget? placeholder; const SmartImage({ super.key, required this.url, this.fallbackUrls = const [], this.fit = BoxFit.cover, this.alignment = Alignment.center, this.aspectRatio, this.borderRadius = 8, this.fallbackIcon = Icons.image_outlined, this.onLoad, this.onError, this.httpHeaders, this.resolveHttpHeaders, this.memCacheWidth, this.placeholder, }); @override ConsumerState createState() => _SmartImageState(); } class _SmartImageState extends ConsumerState { List _displayedUrls = const []; int _failedCount = 0; String? _pendingPrimaryUrl; int? _lastMemCacheWidth; String? _embyBaseUrl; Map? _embyHeaders; List _resolveAllUrls() { final urls = []; if (widget.url != null) urls.add(widget.url!); urls.addAll(widget.fallbackUrls); return urls; } Map? _headersFor(String url) { if (widget.resolveHttpHeaders != null) { return widget.resolveHttpHeaders!(url); } if (widget.httpHeaders != null) return widget.httpHeaders; final base = _embyBaseUrl; if (base != null && url.startsWith(base)) return _embyHeaders; return null; } @override void initState() { super.initState(); _displayedUrls = _resolveAllUrls(); } @override void didUpdateWidget(covariant SmartImage oldWidget) { super.didUpdateWidget(oldWidget); final newAll = _resolveAllUrls(); final newPrimary = newAll.isEmpty ? null : newAll.first; final currentPrimary = _displayedUrls.isEmpty ? null : _displayedUrls.first; if (newPrimary == null) { if (_displayedUrls.isNotEmpty || _pendingPrimaryUrl != null) { setState(() { _displayedUrls = const []; _failedCount = 0; _pendingPrimaryUrl = null; }); } return; } if (currentPrimary == null) { setState(() { _displayedUrls = newAll; _failedCount = 0; _pendingPrimaryUrl = null; }); return; } if (newPrimary == currentPrimary) { _displayedUrls = newAll; return; } _preloadAndSwap(newPrimary, newAll); } Future _preloadAndSwap(String newUrl, List newAll) async { if (_pendingPrimaryUrl == newUrl) return; _pendingPrimaryUrl = newUrl; final provider = ResizeImage.resizeIfNeeded( _lastMemCacheWidth, null, CachedNetworkImageProvider(newUrl, headers: _headersFor(newUrl)), ); try { await precacheImage(provider, context); } catch (_) { if (_pendingPrimaryUrl == newUrl) _pendingPrimaryUrl = null; return; } if (!mounted) return; if (_pendingPrimaryUrl != newUrl) return; setState(() { _displayedUrls = newAll; _failedCount = 0; _pendingPrimaryUrl = null; }); } @override Widget build(BuildContext context) { if (widget.httpHeaders == null && widget.resolveHttpHeaders == null) { _embyBaseUrl = ref.watch(activeSessionProvider)?.serverUrl; _embyHeaders = ref.watch(embyImageHeadersProvider).value; } else { _embyBaseUrl = null; _embyHeaders = null; } final scheme = Theme.of(context).colorScheme; final isLight = scheme.brightness == Brightness.light; Widget placeholderWidget() => widget.placeholder ?? _ShimmerPlaceholder(isLight: isLight); final urls = _displayedUrls; final effectiveUrl = _failedCount < urls.length ? urls[_failedCount] : null; final fallback = widget.fallbackIcon != null ? Container( color: scheme.surfaceContainerHighest, child: Center( child: Icon( widget.fallbackIcon, size: 32, color: scheme.onSurfaceVariant.withValues(alpha: 0.4), ), ), ) : const SizedBox.shrink(); final Widget child; if (effectiveUrl == null) { child = KeyedSubtree( key: const ValueKey('_smart_image_empty'), child: fallback, ); } else { final effectiveHeaders = _headersFor(effectiveUrl); child = LayoutBuilder( builder: (context, constraints) { final memCacheWidth = widget.memCacheWidth ?? (constraints.maxWidth.isFinite ? (constraints.maxWidth * MediaQuery.devicePixelRatioOf(context)) .ceil() : null); _lastMemCacheWidth = memCacheWidth; return CachedNetworkImage( key: ValueKey(effectiveUrl), imageUrl: effectiveUrl, httpHeaders: effectiveHeaders, fit: widget.fit, alignment: widget.alignment, memCacheWidth: memCacheWidth, fadeInDuration: const Duration(milliseconds: 300), fadeInCurve: Curves.easeOut, placeholder: (_, _) => placeholderWidget(), errorWidget: (_, e, st) { if (_failedCount + 1 < urls.length) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _failedCount++); }); return placeholderWidget(); } WidgetsBinding.instance.addPostFrameCallback((_) { widget.onError?.call(); }); return fallback; }, imageBuilder: (ctx, imageProvider) { widget.onLoad?.call(); return Image( image: imageProvider, fit: widget.fit, alignment: widget.alignment, ); }, ); }, ); } final switcher = AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, layoutBuilder: (currentChild, previousChildren) { return Stack( fit: StackFit.passthrough, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ); }, child: child, ); final clipped = ClipRRect( borderRadius: BorderRadius.circular(widget.borderRadius), child: switcher, ); if (widget.aspectRatio != null) { return AspectRatio(aspectRatio: widget.aspectRatio!, child: clipped); } return clipped; } } class _ShimmerPlaceholder extends StatefulWidget { final bool isLight; const _ShimmerPlaceholder({required this.isLight}); @override State<_ShimmerPlaceholder> createState() => _ShimmerPlaceholderState(); } class _ShimmerPlaceholderState extends State<_ShimmerPlaceholder> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { final base = widget.isLight ? const Color(0xFFF3F4F6) : const Color(0xFF2A2A2A); final highlight = widget.isLight ? const Color(0xFFE5E7EB) : const Color(0xFF3A3A3A); return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment(-1.0 + 2.0 * _controller.value, 0), end: Alignment(-1.0 + 2.0 * _controller.value + 1.0, 0), colors: [base, highlight, base], stops: const [0.0, 0.5, 1.0], ), ), ); }, ); } }