NavidromeApp/iOS/Views/Common/AsyncCoverArt.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

336 lines
11 KiB
Swift

import SwiftUI
import UIKit
// MARK: - Image Cache (Memory + Disk)
/// Two-tier image cache: NSCache for fast memory access, disk for persistence across launches.
class ImageCache {
static let shared = ImageCache()
private let memoryCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let diskCacheURL: URL
private let ioQueue = DispatchQueue(label: "com.navidromeplayer.imagecache", qos: .utility)
/// Max memory cache: ~80 images
/// Max disk cache: 200 MB
private let maxDiskBytes: UInt64 = 200 * 1024 * 1024
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
diskCacheURL = caches.appendingPathComponent("ImageCache", isDirectory: true)
try? fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
try? (diskCacheURL as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
memoryCache.countLimit = 80
memoryCache.totalCostLimit = 50 * 1024 * 1024 // ~50 MB
}
// MARK: - Key Hashing
private func cacheKey(for url: URL) -> String {
// Use endpoint + query params only (not the server base URL)
// so the same cover art ID caches the same regardless of local vs public server
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let path = components?.path ?? ""
// Extract just the endpoint name and relevant params (id, size)
let endpoint = (path as NSString).lastPathComponent
let params = (components?.queryItems ?? [])
.filter { ["id", "size"].contains($0.name) }
.sorted { $0.name < $1.name }
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&")
let keyStr = "\(endpoint)?\(params)"
let hash = keyStr.utf8.reduce(0) { ($0 &* 31) &+ UInt64($1) }
return String(hash, radix: 16)
}
// MARK: - Memory Cache
func memoryImage(for url: URL) -> UIImage? {
let key = cacheKey(for: url) as NSString
return memoryCache.object(forKey: key)
}
func storeInMemory(_ image: UIImage, for url: URL) {
let key = cacheKey(for: url) as NSString
let cost = Int(image.size.width * image.size.height * image.scale * 4)
memoryCache.setObject(image, forKey: key, cost: cost)
}
// MARK: - Disk Cache
private func diskURL(for url: URL) -> URL {
diskCacheURL.appendingPathComponent(cacheKey(for: url) + ".jpg")
}
func diskImage(for url: URL) -> UIImage? {
let path = diskURL(for: url)
guard let data = try? Data(contentsOf: path) else { return nil }
return UIImage(data: data)
}
func storeToDisk(_ image: UIImage, for url: URL) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let path = self.diskURL(for: url)
// JPEG at 85% quality for good size/quality balance
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: path, options: .atomic)
}
}
}
// MARK: - Combined Lookup
/// Returns cached image from memory or disk, promoting disk hits to memory.
func cachedImage(for url: URL) -> UIImage? {
// 1. Memory
if let img = memoryImage(for: url) {
return img
}
// 2. Disk -> promote to memory
if let img = diskImage(for: url) {
storeInMemory(img, for: url)
return img
}
return nil
}
/// Stores image in both memory and disk.
func store(_ image: UIImage, for url: URL) {
storeInMemory(image, for: url)
storeToDisk(image, for: url)
}
// MARK: - Cleanup
/// Trims disk cache to maxDiskBytes. Call periodically or on app launch.
func trimDiskCache() {
ioQueue.async { [weak self] in
guard let self = self else { return }
guard let files = try? self.fileManager.contentsOfDirectory(
at: self.diskCacheURL,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]
) else { return }
// Get file info
var fileInfos: [(url: URL, date: Date, size: UInt64)] = []
var totalSize: UInt64 = 0
for file in files {
guard let attrs = try? file.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]),
let date = attrs.contentModificationDate,
let size = attrs.fileSize else { continue }
let s = UInt64(size)
fileInfos.append((file, date, s))
totalSize += s
}
guard totalSize > self.maxDiskBytes else { return }
// Sort oldest first, delete until under limit
fileInfos.sort { $0.date < $1.date }
for info in fileInfos {
guard totalSize > self.maxDiskBytes else { break }
try? self.fileManager.removeItem(at: info.url)
totalSize -= info.size
}
}
}
/// Clears all cached images
func clearAll() {
memoryCache.removeAllObjects()
ioQueue.async { [weak self] in
guard let self = self else { return }
if let files = try? self.fileManager.contentsOfDirectory(at: self.diskCacheURL, includingPropertiesForKeys: nil) {
for file in files {
try? self.fileManager.removeItem(at: file)
}
}
}
}
}
// MARK: - Album Cover Store (custom covers, disk-persisted)
/// Stores user-set album cover art on disk, keyed by coverArtId.
/// Custom covers override server art everywhere (AlbumDetail, NowPlaying, MiniPlayer, grids).
class AlbumCoverStore: ObservableObject {
static let shared = AlbumCoverStore()
/// Triggers SwiftUI refresh when a cover changes
@Published var updateTrigger = UUID()
private let fileManager = FileManager.default
private let coverDir: URL
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
coverDir = docs.appendingPathComponent("AlbumCovers", isDirectory: true)
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
}
private func coverURL(for coverArtId: String) -> URL {
// Sanitize the ID for filesystem safety
let safe = coverArtId.replacingOccurrences(of: "/", with: "_")
return coverDir.appendingPathComponent("\(safe).jpg")
}
func hasCover(for coverArtId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: coverArtId).path)
}
func loadCover(for coverArtId: String) -> UIImage? {
let url = coverURL(for: coverArtId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
func saveCover(_ image: UIImage, for coverArtId: String) {
let url = coverURL(for: coverArtId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for coverArtId: String) {
let url = coverURL(for: coverArtId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
// MARK: - Cached Image Loader (ObservableObject per-view)
class CachedImageLoader: ObservableObject {
@Published var image: UIImage?
@Published var isLoading = false
private var url: URL?
private var task: URLSessionDataTask?
func load(from url: URL) {
// Skip if already loaded this URL
guard self.url != url else { return }
self.url = url
// Check cache first
if let cached = ImageCache.shared.cachedImage(for: url) {
self.image = cached
return
}
// Download
isLoading = true
task?.cancel()
task = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self, error == nil, let data = data,
let img = UIImage(data: data) else {
DispatchQueue.main.async { self?.isLoading = false }
return
}
// Cache it
ImageCache.shared.store(img, for: url)
DispatchQueue.main.async {
self.image = img
self.isLoading = false
}
}
task?.resume()
}
func cancel() {
task?.cancel()
task = nil
}
}
// MARK: - CachedAsyncImage View
struct CachedAsyncImage: View {
let url: URL?
@StateObject private var loader = CachedImageLoader()
var body: some View {
Group {
if let img = loader.image {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color.clear // placeholder handled by caller
}
}
.onAppear {
if let url = url {
loader.load(from: url)
}
}
.onChange(of: url) { _, newURL in
if let newURL = newURL {
loader.load(from: newURL)
}
}
.onDisappear {
loader.cancel()
}
}
}
// MARK: - AsyncCoverArt (drop-in replacement, now cached)
struct AsyncCoverArt: View {
let coverArtId: String?
let size: Int
@ObservedObject private var albumCoverStore = AlbumCoverStore.shared
var body: some View {
if let id = coverArtId {
// Check for user-set custom cover first
if let customImage = albumCoverStore.loadCover(for: id) {
let _ = albumCoverStore.updateTrigger // reactive dependency
Image(uiImage: customImage)
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: size) {
ZStack {
placeholderView
CachedAsyncImage(url: url)
}
.clipped()
} else {
placeholderView
}
} else {
placeholderView
}
}
private var placeholderView: some View {
ZStack {
Rectangle()
.fill(
LinearGradient(
colors: [Color(white: 0.2), Color(white: 0.15)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Image(systemName: "music.note")
.font(.system(size: max(12, CGFloat(size) * 0.15)))
.foregroundColor(.gray)
}
}
}