import 'dart:io'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; class CachedCoverImage extends StatelessWidget { static const int _defaultMinCacheExtent = 64; static const int _defaultMaxCacheExtent = 512; final String imageUrl; final double? width; final double? height; final BoxFit fit; final Alignment alignment; final int? memCacheWidth; final int? memCacheHeight; final Widget Function(BuildContext, String, Object)? errorWidget; final Widget Function(BuildContext, String)? placeholder; final BorderRadius? borderRadius; final bool resizeDiskCache; final Duration fadeInDuration; final Duration fadeOutDuration; const CachedCoverImage({ super.key, required this.imageUrl, this.width, this.height, this.fit = BoxFit.cover, this.alignment = Alignment.center, this.memCacheWidth, this.memCacheHeight, this.errorWidget, this.placeholder, this.borderRadius, this.resizeDiskCache = false, this.fadeInDuration = Duration.zero, this.fadeOutDuration = Duration.zero, }); @override Widget build(BuildContext context) { final autoMemCacheWidth = memCacheWidth ?? _cacheExtentForLogicalSize(context, width); final autoMemCacheHeight = memCacheHeight ?? _cacheExtentForLogicalSize(context, height); final diskCacheWidth = resizeDiskCache ? autoMemCacheWidth : null; final diskCacheHeight = resizeDiskCache ? autoMemCacheHeight : null; final image = CachedNetworkImage( imageUrl: imageUrl, width: width, height: height, fit: fit, alignment: alignment, memCacheWidth: autoMemCacheWidth, memCacheHeight: autoMemCacheHeight, maxWidthDiskCache: diskCacheWidth, maxHeightDiskCache: diskCacheHeight, cacheManager: CoverCacheManager.instance, fadeInDuration: fadeInDuration, fadeOutDuration: fadeOutDuration, useOldImageOnUrlChange: true, filterQuality: FilterQuality.low, errorWidget: errorWidget, placeholder: placeholder, ); if (borderRadius != null) { return ClipRRect(borderRadius: borderRadius!, child: image); } return image; } static int? _cacheExtentForLogicalSize(BuildContext context, double? size) { if (size == null || !size.isFinite || size <= 0) return null; final dpr = MediaQuery.devicePixelRatioOf( context, ).clamp(1.0, 3.0).toDouble(); return (size * dpr) .round() .clamp(_defaultMinCacheExtent, _defaultMaxCacheExtent) .toInt(); } } /// Renders [url] as a local file (when it's not an http/https URL) or as a /// cached network image otherwise, with a shared [placeholder] used for the /// local error state, the local not-ready frame, and the network /// placeholder/error states alike. class LocalOrNetworkCoverImage extends StatelessWidget { final String url; final double? width; final double? height; final BoxFit fit; final BorderRadius? borderRadius; final int? localCacheWidth; final int? networkCacheWidth; final Duration? fadeInDuration; final Duration fadeOutDuration; final String Function(String)? urlTransform; final Widget Function(BuildContext) placeholder; const LocalOrNetworkCoverImage({ super.key, required this.url, this.width, this.height, this.fit = BoxFit.cover, this.borderRadius, this.localCacheWidth, this.networkCacheWidth, this.fadeInDuration, this.fadeOutDuration = Duration.zero, this.urlTransform, required this.placeholder, }); bool get _isLocal => !url.startsWith('http://') && !url.startsWith('https://'); @override Widget build(BuildContext context) { if (_isLocal) { final image = Image.file( File(url), width: width, height: height, fit: fit, cacheWidth: localCacheWidth, gaplessPlayback: true, filterQuality: FilterQuality.low, frameBuilder: fadeInDuration == null ? null : (context, child, frame, wasSynchronouslyLoaded) { final ready = wasSynchronouslyLoaded || frame != null; if (fadeInDuration == Duration.zero) { return ready ? child : placeholder(context); } return Stack( fit: StackFit.expand, children: [ placeholder(context), AnimatedOpacity( opacity: ready ? 1.0 : 0.0, duration: fadeInDuration!, curve: Curves.easeOutCubic, child: child, ), ], ); }, errorBuilder: (_, _, _) => placeholder(context), ); return borderRadius == null ? image : ClipRRect(borderRadius: borderRadius!, child: image); } return CachedCoverImage( imageUrl: urlTransform?.call(url) ?? url, width: width, height: height, fit: fit, memCacheWidth: networkCacheWidth, borderRadius: borderRadius, fadeInDuration: fadeInDuration ?? Duration.zero, fadeOutDuration: fadeOutDuration, placeholder: (_, _) => placeholder(context), errorWidget: (_, _, _) => placeholder(context), ); } } CachedNetworkImageProvider cachedCoverImageProvider(String url) { return CachedNetworkImageProvider( url, cacheManager: CoverCacheManager.instance, ); } /// Chooses one artwork source for both the Metadata foreground cover and its /// blurred backdrop. Embedded file artwork is authoritative for downloaded /// tracks; local scan artwork is next, with remote metadata only as fallback. String? resolveMetadataArtworkSource({ String? embeddedCoverPath, String? localCoverPath, String? remoteCoverUrl, }) { for (final candidate in [embeddedCoverPath, localCoverPath, remoteCoverUrl]) { final normalized = candidate?.trim(); if (normalized != null && normalized.isNotEmpty) return normalized; } return null; } /// Decode size shared by Track Metadata's blurred backdrop and its prewarm. /// The backdrop is heavily blurred, so a modest square bitmap is sufficient /// and avoids allocating a second full-viewport image beside the Hero cover. int metadataBackdropCacheExtent(BuildContext context) { final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0); final logicalWidth = MediaQuery.sizeOf(context).width; return (logicalWidth * dpr * 0.35).round().clamp(192, 384).toInt(); } /// Warms the exact resized image used by Track Metadata's blurred backdrop. /// Navigation waits for the common memory/disk-cache path, but a bounded budget /// prevents a cold network request from delaying the route for too long. Future precacheMetadataBackdrop( BuildContext context, String? source, ) async { final normalized = source?.trim(); if (normalized == null || normalized.isEmpty) return; final ImageProvider provider; if (normalized.startsWith('http://') || normalized.startsWith('https://')) { provider = cachedCoverImageProvider(normalized); } else if (!normalized.startsWith('content://')) { final filePath = normalized.startsWith('file://') ? Uri.parse(normalized).toFilePath() : normalized; provider = FileImage(File(filePath)); } else { return; } final extent = metadataBackdropCacheExtent(context); try { await precacheImage( ResizeImage(provider, width: extent, height: extent), context, onError: (_, _) {}, ).timeout(const Duration(milliseconds: 200)); } catch (_) {} } /// Pre-warms the cover cache at the metadata-screen display size so the hero /// transition doesn't pop in a low-res frame. Http(s) URLs only. void precacheCoverImage(BuildContext context, String? url) { if (url == null || url.isEmpty) return; if (!url.startsWith('http://') && !url.startsWith('https://')) { return; } final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble(); final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( ResizeImage( cachedCoverImageProvider(url), width: targetSize, height: targetSize, ), context, ); }