NavidromeApp/iOS/Views/Common/AsyncCoverArt.swift
Dallas Groot 3c28413af8 bug fixes and improvements
Gap 1: Lock Screen seek bar drift — nowPlayingSyncTimer (5s timer that
pushes elapsed time to MPNowPlayingInfoCenter) was created in
playWithAVPlayer but never restarted after a background/foreground
cycle. Now invalidated in suspendVisTimers() and recreated in
resumeVisTimers(). The crossfade path benefits too — it never went
through playWithAVPlayer so the timer was never created at all for
crossfade sessions.
Gap 2: Prefetcher re-downloads after restructure — AudioPreFetcher
used isSongDownloaded(song.id) (exact ID match). After a Companion
restructure changes IDs, songs already downloaded were re-fetched.
Changed to isSongAvailableOffline(song) which falls back to
title/artist/duration matching.
Gap 3: Unbounded image disk cache — trimDiskCache() only ran on app
launch. A long browsing session could push well past the 200MB limit.
Now storeToDisk increments a write counter and triggers trim every 50
writes. The counter lives on ioQueue (serial) so no lock needed.
Gap 4: Custom cover art in widget — WidgetBridge cached blur keyed by
coverArtId. Custom covers don’t change the ID, so the bridge skipped
the update. Now pushWidgetState() passes "custom_\(id)" as the key
when AlbumCoverStore has a custom image. Same album’s songs still
share the key → blur is reused, not redone. When custom is removed,
key reverts to the bare ID → re-blurs with server art.
2026-04-12 16:16:32 -07:00

482 lines
17 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: @unchecked Sendable {
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
/// Counts disk writes triggers trim every 50 writes (not just on launch).
/// Only accessed from ioQueue so no lock needed.
private var diskWriteCount: Int = 0
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)"
// FNV-1a 64-bit deterministic, excellent distribution for short strings.
// The previous polynomial hash (x*31+c) had high collision risk for
// sequential cover art IDs like "al-1001", "al-1002".
var hash: UInt64 = 14695981039346656037 // FNV offset basis
for byte in keyStr.utf8 {
hash ^= UInt64(byte)
hash &*= 1099511628211 // FNV prime
}
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)
self.diskWriteCount += 1
// Trim every 50 writes so the cache doesn't grow unbounded
// between app launches (trimDiskCache is also called on launch)
if self.diskWriteCount % 50 == 0 {
self.trimDiskCacheOnQueue()
}
}
}
}
// MARK: - Combined Lookup
/// Synchronous memory-only check safe to call from any thread.
/// Returns nil if not in memory (does NOT hit disk).
func memoryOnlyImage(for url: URL) -> UIImage? {
memoryImage(for: url)
}
/// Returns cached image from memory first, then disk (async, off main thread).
/// Promotes disk hits to memory. Use this from async contexts.
func cachedImageAsync(for url: URL) async -> UIImage? {
// 1. Memory no I/O, safe on any thread
if let img = memoryImage(for: url) { return img }
// 2. Disk on ioQueue to keep main thread free
return await withCheckedContinuation { continuation in
ioQueue.async {
if let img = self.diskImage(for: url) {
self.storeInMemory(img, for: url)
continuation.resume(returning: img)
} else {
continuation.resume(returning: nil)
}
}
}
}
/// Synchronous combined lookup only call from background threads.
/// For main-thread callers, prefer memoryOnlyImage + cachedImageAsync.
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. Called on app launch and periodically from storeToDisk.
func trimDiskCache() {
ioQueue.async { [weak self] in
self?.trimDiskCacheOnQueue()
}
}
/// Internal trim must be called on ioQueue.
private func trimDiskCacheOnQueue() {
guard let files = try? self.fileManager.contentsOfDirectory(
at: self.diskCacheURL,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]
) else { return }
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 }
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
/// In-memory cache eliminates repeated Data(contentsOf:) disk reads
/// when SwiftUI re-evaluates body. Auto-evicts under memory pressure.
private let memoryCache = NSCache<NSString, UIImage>()
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 {
memoryCache.object(forKey: coverArtId as NSString) != nil
|| fileManager.fileExists(atPath: coverURL(for: coverArtId).path)
}
func loadCover(for coverArtId: String) -> UIImage? {
let key = coverArtId as NSString
// Memory hit no I/O
if let cached = memoryCache.object(forKey: key) { return cached }
// Disk fallback promote to memory
let url = coverURL(for: coverArtId)
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
memoryCache.setObject(image, forKey: key)
return image
}
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)
memoryCache.setObject(image, forKey: coverArtId as NSString)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for coverArtId: String) {
memoryCache.removeObject(forKey: coverArtId as NSString)
let url = coverURL(for: coverArtId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
// MARK: - Artist Cover Store (custom artist photos)
class ArtistCoverStore: ObservableObject {
static let shared = ArtistCoverStore()
@Published var updateTrigger = UUID()
private let fileManager = FileManager.default
private let coverDir: URL
private let memoryCache = NSCache<NSString, UIImage>()
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
coverDir = docs.appendingPathComponent("ArtistCovers", isDirectory: true)
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
}
private func coverURL(for artistId: String) -> URL {
let safe = artistId.replacingOccurrences(of: "/", with: "_")
return coverDir.appendingPathComponent("\(safe).jpg")
}
func hasCover(for artistId: String) -> Bool {
memoryCache.object(forKey: artistId as NSString) != nil
|| fileManager.fileExists(atPath: coverURL(for: artistId).path)
}
func loadCover(for artistId: String) -> UIImage? {
let key = artistId as NSString
if let cached = memoryCache.object(forKey: key) { return cached }
let url = coverURL(for: artistId)
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
memoryCache.setObject(image, forKey: key)
return image
}
func saveCover(_ image: UIImage, for artistId: String) {
let url = coverURL(for: artistId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
memoryCache.setObject(image, forKey: artistId as NSString)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for artistId: String) {
memoryCache.removeObject(forKey: artistId as NSString)
let url = coverURL(for: artistId)
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?
func load(from url: URL) {
// Skip if already loaded this URL
guard self.url != url else { return }
self.url = url
// 1. Memory hit instant, no I/O
if let cached = ImageCache.shared.memoryOnlyImage(for: url) {
self.image = cached
return
}
// 2. Async check (disk network) never blocks main thread
isLoading = true
Task { @MainActor in
// Check disk off main thread
if let cached = await ImageCache.shared.cachedImageAsync(for: url) {
self.image = cached
self.isLoading = false
return
}
// Network fetch
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let img = UIImage(data: data) else {
self.isLoading = false
return
}
ImageCache.shared.store(img, for: url)
self.image = img
self.isLoading = false
} catch {
self.isLoading = false
}
}
}
func cancel() {
url = 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 {
// Custom user-set cover takes priority everywhere
if let customImage = albumCoverStore.loadCover(for: id) {
let _ = albumCoverStore.updateTrigger
Image(uiImage: customImage)
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
} else if id.hasPrefix("companion:") {
// Route to Companion API cover art endpoint.
// Use a shared service instance creating CompanionAPIService() per render
// allocates a new URLSession each time, causing connection pool exhaustion.
let companionId = String(id.dropFirst("companion:".count))
if let url = CompanionSettings.shared.baseURL?
.appendingPathComponent("library/cover-art/\(companionId)") {
ZStack {
placeholderView
CachedAsyncImage(url: url)
}
.clipped()
} else {
placeholderView
}
} 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)
}
}
}
// MARK: - Keyboard Done Button
/// Adds a "Done" button above the keyboard on any TextField or SecureField.
/// Usage: TextField(...).keyboardDoneButton()
extension View {
func keyboardDoneButton() -> some View {
self.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333))
.fontWeight(.medium)
}
}
}
}