import Foundation /// Pre-fetches the next few tracks in the queue so playback is instant. /// Uses OfflineManager's download system but marks pre-fetched files as temporary /// (not user-requested downloads). class AudioPreFetcher { static let shared = AudioPreFetcher() private let maxPrefetch = 3 private var prefetchedIds: Set = [] private var prefetchTasks: [String: Task] = [:] private init() {} /// Call when queue changes or track advances. /// Pre-caches the next `maxPrefetch` tracks that aren't already downloaded. func prefetchUpcoming(queue: [Song], currentIndex: Int) { guard !queue.isEmpty else { return } let start = currentIndex + 1 let end = min(start + maxPrefetch, queue.count) guard start < end else { return } let upcoming = Array(queue[start.. URL? { let ext = suffix ?? "mp3" let file = Self.prefetchCacheDir().appendingPathComponent("\(songId).\(ext)") return FileManager.default.fileExists(atPath: file.path) ? file : nil } /// Clean up old prefetch files (call on app launch) func cleanOldPrefetches(keeping currentQueue: [Song]) { let keepIds = Set(currentQueue.map { $0.id }) let dir = Self.prefetchCacheDir() guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return } for file in files { let name = file.deletingPathExtension().lastPathComponent if !keepIds.contains(name) { try? FileManager.default.removeItem(at: file) } } prefetchedIds = prefetchedIds.intersection(keepIds) } /// Total size of prefetch cache var cacheSize: Int64 { let dir = Self.prefetchCacheDir() guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } return files.reduce(0) { sum, url in sum + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) } } func clearCache() { let dir = Self.prefetchCacheDir() try? FileManager.default.removeItem(at: dir) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) prefetchedIds.removeAll() prefetchTasks.values.forEach { $0.cancel() } prefetchTasks.removeAll() } // MARK: - Private private static func prefetchCacheDir() -> URL { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let dir = caches.appendingPathComponent("AudioPrefetch", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } private func streamURL(for song: Song) -> URL? { let quality = StreamingQuality.shared return ServerManager.shared.client.streamURL( songId: song.id, format: quality.format, maxBitRate: quality.maxBitRate ) } private static func formatBytes(_ bytes: Int) -> String { if bytes < 1024 * 1024 { return "\(bytes / 1024)KB" } return String(format: "%.1fMB", Double(bytes) / 1_048_576.0) } }