204 lines
7.0 KiB
Swift
204 lines
7.0 KiB
Swift
//
|
|
// CachedAsyncImage.swift
|
|
// Mobile Music Assistant
|
|
//
|
|
// Created by Sven Hanold on 26.03.26.
|
|
//
|
|
|
|
import SwiftUI
|
|
import CryptoKit
|
|
import OSLog
|
|
|
|
private let imgLogger = Logger(subsystem: "com.musicassistant.mobile", category: "ImageCache")
|
|
|
|
// MARK: - Image Cache
|
|
|
|
final class ImageCache: @unchecked Sendable {
|
|
static let shared = ImageCache()
|
|
|
|
// NSCache: auto-evicts under memory pressure (large buffer)
|
|
private let memory = NSCache<NSString, UIImage>()
|
|
// LRU strong-reference store: last N images stay alive even after NSCache eviction
|
|
private var lruImages: [String: UIImage] = [:]
|
|
private var lruKeys: [String] = []
|
|
private let lruMaxCount = 60
|
|
private let lock = NSLock()
|
|
|
|
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()
|
|
}
|
|
|
|
/// Check in-memory caches (LRU + NSCache). Thread-safe, no disk I/O — safe on main thread.
|
|
func memoryImage(for key: String) -> UIImage? {
|
|
lock.withLock {
|
|
lruImages[key] ?? memory.object(forKey: key as NSString)
|
|
}
|
|
}
|
|
|
|
/// Check disk only (no memory). Must be called from a background thread.
|
|
func diskImage(for key: String) -> UIImage? {
|
|
let file = directory.appendingPathComponent(key)
|
|
guard let data = try? Data(contentsOf: file),
|
|
let img = UIImage(data: data) else { return nil }
|
|
// Promote to memory on load
|
|
storeMemory(img, data: data, for: key)
|
|
return img
|
|
}
|
|
|
|
func store(_ image: UIImage, data: Data, for key: String) {
|
|
storeMemory(image, data: data, for: key)
|
|
let file = directory.appendingPathComponent(key)
|
|
try? data.write(to: file, options: .atomic)
|
|
}
|
|
|
|
private func storeMemory(_ image: UIImage, data: Data, for key: String) {
|
|
lock.withLock {
|
|
memory.setObject(image, forKey: key as NSString, cost: data.count)
|
|
// Update LRU: move to most-recent position
|
|
lruKeys.removeAll { $0 == key }
|
|
lruKeys.append(key)
|
|
lruImages[key] = image
|
|
// Trim oldest entry when over limit
|
|
while lruKeys.count > lruMaxCount {
|
|
let oldest = lruKeys.removeFirst()
|
|
lruImages.removeValue(forKey: oldest)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
lock.withLock {
|
|
memory.removeAllObjects()
|
|
lruImages.removeAll()
|
|
lruKeys.removeAll()
|
|
}
|
|
try? fileManager.removeItem(at: directory)
|
|
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - CachedAsyncImage
|
|
|
|
/// Async image view that caches to memory (NSCache + LRU) and disk.
|
|
/// 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
|
|
// Pre-warm from memory (LRU + NSCache) — thread-safe, no disk I/O.
|
|
// Prevents placeholder flash when navigating back to a previously loaded view.
|
|
let cached = url.flatMap { u -> UIImage? in
|
|
ImageCache.shared.memoryImage(for: ImageCache.shared.cacheKey(for: u))
|
|
}
|
|
_image = State(initialValue: cached)
|
|
}
|
|
|
|
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)
|
|
|
|
// 1. Memory (LRU + NSCache) — instant, no I/O
|
|
if let cached = ImageCache.shared.memoryImage(for: key) {
|
|
image = cached
|
|
return
|
|
}
|
|
|
|
// 2. Disk — read off the main thread to avoid UI blocking
|
|
let diskCached = await Task.detached(priority: .userInitiated) {
|
|
ImageCache.shared.diskImage(for: key)
|
|
}.value
|
|
|
|
if let diskCached {
|
|
guard !Task.isCancelled else { return }
|
|
image = diskCached
|
|
return
|
|
}
|
|
|
|
// 3. Network fetch 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)
|
|
guard !Task.isCancelled else { return }
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
guard status == 200 else { return }
|
|
guard let uiImage = UIImage(data: data) else { return }
|
|
ImageCache.shared.store(uiImage, data: data, for: key)
|
|
image = uiImage
|
|
} catch {
|
|
// URLError.cancelled is expected when lazy views leave the viewport — not a real error
|
|
guard (error as? URLError)?.code != .cancelled else { return }
|
|
imgLogger.debug("Image load failed: \(error.localizedDescription) for \(url.absoluteString)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) }
|
|
)
|
|
}
|
|
}
|