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
336 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|