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() 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) } } } }