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.
482 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|