NavidromeApp/iOS/Views/Visualizer/VisualizerStorageManager.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

96 lines
3.6 KiB
Swift

import Foundation
/// Caches pre-computed visualizer FFT frames to disk for instant playback
actor VisualizerStorageManager {
static let shared = VisualizerStorageManager()
private let fileManager = FileManager.default
private var cacheDir: URL {
let dir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("VisualizerCache", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
private func fileURL(for trackId: String) -> URL {
cacheDir.appendingPathComponent("mitsuha_\(trackId).bin")
}
func hasCache(for trackId: String) -> Bool {
fileManager.fileExists(atPath: fileURL(for: trackId).path)
}
func saveCache(frames: [[Float]], for trackId: String) throws {
// Binary format: [frameCount: UInt32][pointsPerFrame: UInt32][float data...]
let frameCount = frames.count
let pointsPerFrame = frames.first?.count ?? 0
guard frameCount > 0, pointsPerFrame > 0 else { return }
var data = Data()
var fc = UInt32(frameCount)
var ppf = UInt32(pointsPerFrame)
data.append(Data(bytes: &fc, count: 4))
data.append(Data(bytes: &ppf, count: 4))
for frame in frames {
frame.withUnsafeBytes { data.append(contentsOf: $0) }
}
try data.write(to: fileURL(for: trackId), options: .atomic)
}
func loadCache(for trackId: String) throws -> [[Float]] {
let data = try Data(contentsOf: fileURL(for: trackId))
guard data.count >= 8 else { return [] }
let frameCount = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
let pointsPerFrame = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
let floatSize = MemoryLayout<Float>.size
let expectedSize = 8 + Int(frameCount) * Int(pointsPerFrame) * floatSize
guard data.count >= expectedSize else { return [] }
var frames: [[Float]] = []
frames.reserveCapacity(Int(frameCount))
var offset = 8
for _ in 0..<frameCount {
let frame: [Float] = data.withUnsafeBytes { raw in
let ptr = raw.baseAddress!.advanced(by: offset).assumingMemoryBound(to: Float.self)
return Array(UnsafeBufferPointer(start: ptr, count: Int(pointsPerFrame)))
}
frames.append(frame)
offset += Int(pointsPerFrame) * floatSize
}
return frames
}
/// Total cache size on disk
func cacheSize() -> Int64 {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else {
return 0
}
return files.reduce(0) { sum, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
return sum + Int64(size)
}
}
/// Number of cached tracks
func cachedTrackCount() -> Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil).count) ?? 0
}
func clearCache() {
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
func removeCache(for trackId: String) {
try? fileManager.removeItem(at: fileURL(for: trackId))
}
}