// // CachedAsyncImage.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI import CryptoKit // MARK: - Image Cache final class ImageCache: @unchecked Sendable { static let shared = ImageCache() private let memory = NSCache() private let directory: URL private let fileManager = FileManager.default private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] directory = caches.appendingPathComponent("MMArtwork", isDirectory: true) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) memory.totalCostLimit = 75 * 1024 * 1024 // 75 MB in-memory } /// Stable SHA-256 hex string used as a filename for disk storage. func cacheKey(for url: URL) -> String { let hash = SHA256.hash(data: Data(url.absoluteString.utf8)) return hash.map { String(format: "%02x", $0) }.joined() } func image(for key: String) -> UIImage? { // 1. Memory if let img = memory.object(forKey: key as NSString) { return img } // 2. Disk let file = directory.appendingPathComponent(key) guard let data = try? Data(contentsOf: file), let img = UIImage(data: data) else { return nil } memory.setObject(img, forKey: key as NSString, cost: data.count) return img } func store(_ image: UIImage, data: Data, for key: String) { memory.setObject(image, forKey: key as NSString, cost: data.count) let file = directory.appendingPathComponent(key) try? data.write(to: file, options: .atomic) } /// Total bytes currently stored on disk. var diskUsageBytes: Int { guard let enumerator = fileManager.enumerator( at: directory, includingPropertiesForKeys: [.fileSizeKey] ) else { return 0 } return enumerator.reduce(0) { total, item in guard let url = item as? URL, let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { return total } return total + size } } /// Remove all cached artwork from disk and memory. func clearAll() { memory.removeAllObjects() try? fileManager.removeItem(at: directory) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } } // MARK: - CachedAsyncImage /// Async image view that caches to both memory (NSCache) and disk (FileManager). /// Sends the MA auth token in the Authorization header so the image proxy responds. struct CachedAsyncImage: View { @Environment(MAService.self) private var service let url: URL? let content: (Image) -> Content let placeholder: () -> Placeholder @State private var image: UIImage? init( url: URL?, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder placeholder: @escaping () -> Placeholder ) { self.url = url self.content = content self.placeholder = placeholder } var body: some View { Group { if let image { content(Image(uiImage: image)) } else { placeholder() } } // task(id:) automatically cancels + restarts when the URL changes .task(id: url) { await loadImage() } } private func loadImage() async { guard let url else { return } let key = ImageCache.shared.cacheKey(for: url) // Serve from cache instantly if available if let cached = ImageCache.shared.image(for: key) { image = cached return } // Build request with auth header var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) if let token = service.authManager.currentToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } do { let (data, response) = try await URLSession.shared.data(for: request) // Only cache successful responses guard (response as? HTTPURLResponse)?.statusCode == 200 else { return } guard let uiImage = UIImage(data: data) else { return } ImageCache.shared.store(uiImage, data: data, for: key) image = uiImage } catch { // Network errors are silent — placeholder stays visible } } } // MARK: - Convenience Initializer extension CachedAsyncImage where Content == Image, Placeholder == Color { init(url: URL?) { self.init( url: url, content: { $0.resizable() }, placeholder: { Color.gray.opacity(0.2) } ) } }