Version 1 in App Store

This commit is contained in:
2026-04-05 19:44:05 +02:00
parent f931c92d94
commit c780be089d
12 changed files with 744 additions and 1484 deletions
@@ -7,13 +7,23 @@
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
@@ -30,23 +40,44 @@ final class ImageCache: @unchecked Sendable {
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
/// 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 }
memory.setObject(img, forKey: key as NSString, cost: data.count)
// Promote to memory on load
storeMemory(img, data: data, for: key)
return img
}
func store(_ image: UIImage, data: Data, for key: String) {
memory.setObject(image, forKey: key as NSString, cost: data.count)
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(
@@ -63,7 +94,11 @@ final class ImageCache: @unchecked Sendable {
/// Remove all cached artwork from disk and memory.
func clearAll() {
memory.removeAllObjects()
lock.withLock {
memory.removeAllObjects()
lruImages.removeAll()
lruKeys.removeAll()
}
try? fileManager.removeItem(at: directory)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
@@ -71,7 +106,7 @@ final class ImageCache: @unchecked Sendable {
// MARK: - CachedAsyncImage
/// Async image view that caches to both memory (NSCache) and disk (FileManager).
/// 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
@@ -90,6 +125,12 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
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 {
@@ -108,16 +149,26 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
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) {
// 1. Memory (LRU + NSCache) instant, no I/O
if let cached = ImageCache.shared.memoryImage(for: key) {
image = cached
return
}
// Build request with auth header
// 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")
@@ -125,13 +176,16 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
do {
let (data, response) = try await URLSession.shared.data(for: request)
// Only cache successful responses
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
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 {
// Network errors are silent placeholder stays visible
// 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)")
}
}
}