Files
MobileMusicAssistant/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift
T

150 lines
4.7 KiB
Swift

//
// 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<NSString, UIImage>()
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<Content: View, Placeholder: View>: 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) }
)
}
}