NavidromeApp/iOS/Data/AudioPreFetcher.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
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
2026-03-28 20:49:47 +00:00

131 lines
5.3 KiB
Swift

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<String> = []
private var prefetchTasks: [String: Task<Void, Never>] = [:]
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..<end])
let offline = OfflineManager.shared
// Cancel prefetches for songs no longer upcoming
let upcomingIds = Set(upcoming.map { $0.id })
for (id, task) in prefetchTasks where !upcomingIds.contains(id) {
task.cancel()
prefetchTasks[id] = nil
}
for song in upcoming {
// Skip if already downloaded or already prefetching
if offline.isSongDownloaded(song.id) { continue }
if prefetchTasks[song.id] != nil { continue }
// Prefetch by downloading to the streaming cache
prefetchTasks[song.id] = Task {
do {
let url = streamURL(for: song)
guard let url else { return }
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else { return }
// Write to streaming cache
let cacheDir = Self.prefetchCacheDir()
let file = cacheDir.appendingPathComponent("\(song.id).\(song.suffix ?? "mp3")")
try data.write(to: file, options: .atomic)
prefetchedIds.insert(song.id)
DebugLogger.shared.log("Prefetched: \(song.title) (\(Self.formatBytes(data.count)))", category: "Prefetch")
} catch {
if !Task.isCancelled {
DebugLogger.shared.log("Prefetch failed: \(song.title)\(error.localizedDescription)", category: "Prefetch")
}
}
prefetchTasks[song.id] = nil
}
}
}
/// Returns the local prefetch URL if available
func prefetchedURL(for songId: String, suffix: String? = nil) -> 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)
}
}