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
447 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|