NavidromeApp/iOS/Views/Common/AsyncCoverArt.swift
Dallas Groot f3b9483b23 overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00

447 lines
15 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
/// 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. 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: - 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 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 {
fileManager.fileExists(atPath: coverURL(for: artistId).path)
}
func loadCover(for artistId: String) -> UIImage? {
let url = coverURL(for: artistId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
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)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for artistId: String) {
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?
private var task: URLSessionDataTask?
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?.cancel()
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() {
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 {
// 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)
}
}
}
}