Updated API calls and Library View
This commit is contained in:
@@ -6,16 +6,82 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
/// AsyncImage with URLCache support for album covers
|
||||
// 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?
|
||||
@State private var isLoading = false
|
||||
|
||||
|
||||
init(
|
||||
url: URL?,
|
||||
@ViewBuilder content: @escaping (Image) -> Content,
|
||||
@@ -25,53 +91,52 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
self.content = content
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
content(Image(uiImage: image))
|
||||
} else {
|
||||
placeholder()
|
||||
.task {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
// task(id:) automatically cancels + restarts when the URL changes
|
||||
.task(id: url) {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadImage() async {
|
||||
guard let url, !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
// Configure URLCache if needed
|
||||
configureURLCache()
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
if let uiImage = UIImage(data: data) {
|
||||
await MainActor.run {
|
||||
image = uiImage
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load image: \(error.localizedDescription)")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func configureURLCache() {
|
||||
let cache = URLCache.shared
|
||||
if cache.diskCapacity < 50_000_000 {
|
||||
URLCache.shared = URLCache(
|
||||
memoryCapacity: 10_000_000, // 10 MB
|
||||
diskCapacity: 50_000_000 // 50 MB
|
||||
)
|
||||
|
||||
// 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 Initializers
|
||||
// MARK: - Convenience Initializer
|
||||
|
||||
extension CachedAsyncImage where Content == Image, Placeholder == Color {
|
||||
init(url: URL?) {
|
||||
|
||||
Reference in New Issue
Block a user