NavidromeApp/iOS/Data/AudioPreFetcher.swift
Dallas Groot 3c28413af8 bug fixes and improvements
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.
2026-04-12 16:16:32 -07:00

144 lines
5.8 KiB
Swift

import Foundation
import UIKit
/// 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 }
#if os(iOS)
// Don't start prefetch tasks in background URLSession.shared data tasks
// are not background-session tasks and consume CPU/network budget
guard UIApplication.shared.applicationState != .background else { return }
#endif
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 (fuzzy match handles ID changes after restructure)
if offline.isSongAvailableOffline(song) { 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)
}
}
/// Cancel all in-flight prefetch tasks called when app enters background
func cancelAll() {
prefetchTasks.values.forEach { $0.cancel() }
prefetchTasks.removeAll()
}
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)
}
}