overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
This commit is contained in:
parent
2f65da3ccc
commit
f3b9483b23
8 changed files with 196 additions and 138 deletions
|
|
@ -77,6 +77,16 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
// Written from audio render thread, read from Timer on main thread.
|
||||
// No lock needed: stale FFT frame is imperceptible at 30fps.
|
||||
nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512)
|
||||
|
||||
// Pre-allocated FFT scratch buffers — allocated once in init, reused every render callback.
|
||||
// Heap allocation from a real-time audio thread is forbidden (locks inside malloc can
|
||||
// block indefinitely and cause the hardware deadline to be missed → audio glitch).
|
||||
private let fftSize: vDSP_Length = 1024
|
||||
nonisolated(unsafe) private var fftWindow: [Float] // Hann window coefficients — constant
|
||||
nonisolated(unsafe) private var fftWindowed: [Float] // windowed input scratch
|
||||
nonisolated(unsafe) private var fftRealp: [Float] // split-complex real part
|
||||
nonisolated(unsafe) private var fftImagp: [Float] // split-complex imag part
|
||||
nonisolated(unsafe) private var fftMagnitudes: [Float] // output magnitudes
|
||||
|
||||
// MARK: - Level Simulation (for streams)
|
||||
private var levelTimer: Timer?
|
||||
|
|
@ -104,6 +114,17 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
private override init() {
|
||||
// Radix-2 FFT setup for 1024 samples (log2(1024) = 10)
|
||||
fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2))
|
||||
|
||||
// Pre-allocate all FFT scratch buffers and compute the constant Hann window.
|
||||
// These are reused on every render callback — zero heap allocation at render time.
|
||||
let halfSize = Int(1024 / 2)
|
||||
fftWindow = [Float](repeating: 0, count: 1024)
|
||||
fftWindowed = [Float](repeating: 0, count: 1024)
|
||||
fftRealp = [Float](repeating: 0, count: halfSize)
|
||||
fftImagp = [Float](repeating: 0, count: halfSize)
|
||||
fftMagnitudes = [Float](repeating: 0, count: halfSize)
|
||||
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
|
||||
|
||||
super.init()
|
||||
configureAudioSession()
|
||||
setupRemoteControls()
|
||||
|
|
@ -721,55 +742,43 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
private func processFFT(buffer: AVAudioPCMBuffer) {
|
||||
guard let fftSetup = fftSetup else { return }
|
||||
guard let channelData = buffer.floatChannelData?[0] else { return }
|
||||
|
||||
|
||||
let frameCount = buffer.frameLength
|
||||
let fftSize: vDSP_Length = 1024
|
||||
let log2n: vDSP_Length = 10
|
||||
guard frameCount >= fftSize else { return }
|
||||
|
||||
|
||||
let halfSize = Int(fftSize / 2)
|
||||
|
||||
// 1. Hann window
|
||||
var windowed = [Float](repeating: 0, count: Int(fftSize))
|
||||
var window = [Float](repeating: 0, count: Int(fftSize))
|
||||
vDSP_hann_window(&window, fftSize, Int32(vDSP_HANN_NORM))
|
||||
vDSP_vmul(channelData, 1, window, 1, &windowed, 1, fftSize)
|
||||
|
||||
// 2-3. FFT with safe pointers
|
||||
var realp = [Float](repeating: 0, count: halfSize)
|
||||
var imagp = [Float](repeating: 0, count: halfSize)
|
||||
var magnitudes = [Float](repeating: 0, count: halfSize)
|
||||
|
||||
realp.withUnsafeMutableBufferPointer { realpBuf in
|
||||
imagp.withUnsafeMutableBufferPointer { imagpBuf in
|
||||
|
||||
// 1. Apply pre-computed Hann window — no allocation, mutates pre-allocated scratch buffer
|
||||
vDSP_vmul(channelData, 1, fftWindow, 1, &fftWindowed, 1, fftSize)
|
||||
|
||||
// 2. FFT using pre-allocated split-complex buffers
|
||||
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
|
||||
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
|
||||
var splitComplex = DSPSplitComplex(
|
||||
realp: realpBuf.baseAddress!,
|
||||
imagp: imagpBuf.baseAddress!
|
||||
)
|
||||
|
||||
windowed.withUnsafeBytes { rawBuffer in
|
||||
fftWindowed.withUnsafeBytes { rawBuffer in
|
||||
let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress!
|
||||
vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize))
|
||||
}
|
||||
|
||||
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD))
|
||||
vDSP_zvmags(&splitComplex, 1, &magnitudes, 1, vDSP_Length(halfSize))
|
||||
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Normalize by N² and take sqrt for amplitude
|
||||
|
||||
// 3. Normalize by N² and take sqrt for amplitude — in-place on pre-allocated buffer
|
||||
let fftSizeF = Float(fftSize)
|
||||
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
|
||||
vDSP_vsmul(magnitudes, 1, &scale, &magnitudes, 1, vDSP_Length(halfSize))
|
||||
|
||||
// sqrt for perceptual scaling
|
||||
vDSP_vsmul(fftMagnitudes, 1, &scale, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
||||
|
||||
for i in 0..<halfSize {
|
||||
magnitudes[i] = sqrt(magnitudes[i])
|
||||
fftMagnitudes[i] = sqrt(fftMagnitudes[i])
|
||||
}
|
||||
|
||||
// 5. Push raw 512-bin magnitudes — visualizer handles binning & smoothing
|
||||
let finalMagnitudes = magnitudes
|
||||
rawFFTLevels = finalMagnitudes
|
||||
|
||||
// 4. Push to rawFFTLevels — single array copy, no intermediate allocation
|
||||
rawFFTLevels = fftMagnitudes
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -34,13 +34,7 @@ class LibraryCache: ObservableObject {
|
|||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
cacheDir = caches.appendingPathComponent("LibraryCache", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
// Allow background access — prevents -54 sandbox errors
|
||||
try? (cacheDir as NSURL).setResourceValue(
|
||||
URLFileProtection.completeUntilFirstUserAuthentication,
|
||||
forKey: .fileProtectionKey
|
||||
)
|
||||
|
||||
// Set file protection to allow background access — prevents -54 sandbox errors
|
||||
// Allow background file access — prevents -54 sandbox errors during background sync
|
||||
try? (cacheDir as NSURL).setResourceValue(
|
||||
URLFileProtection.completeUntilFirstUserAuthentication,
|
||||
forKey: .fileProtectionKey
|
||||
|
|
|
|||
|
|
@ -59,14 +59,16 @@ class OptimisticActionQueue: ObservableObject {
|
|||
/// Call this on app launch, on connectivity change, or after sync.
|
||||
func flush() {
|
||||
guard !pendingActions.isEmpty else { return }
|
||||
|
||||
|
||||
retryTask?.cancel()
|
||||
retryTask = Task { [weak self] in
|
||||
// @MainActor ensures pendingActions is only touched on the main thread —
|
||||
// eliminates the data race between queueAction (main) and the flush Task (pool).
|
||||
retryTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
let actions = self.pendingActions
|
||||
var remaining: [PendingAction] = []
|
||||
|
||||
|
||||
for action in actions {
|
||||
do {
|
||||
try await self.execute(action)
|
||||
|
|
@ -81,13 +83,10 @@ class OptimisticActionQueue: ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final = remaining
|
||||
await MainActor.run {
|
||||
self.pendingActions = final
|
||||
self.pendingCount = final.count
|
||||
self.savePending()
|
||||
}
|
||||
|
||||
self.pendingActions = remaining
|
||||
self.pendingCount = remaining.count
|
||||
self.savePending()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,33 @@ class ImageCache {
|
|||
}
|
||||
|
||||
// MARK: - Combined Lookup
|
||||
|
||||
/// Returns cached image from memory or disk, promoting disk hits to memory.
|
||||
|
||||
/// 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) {
|
||||
|
|
@ -265,32 +290,37 @@ class CachedImageLoader: ObservableObject {
|
|||
// Skip if already loaded this URL
|
||||
guard self.url != url else { return }
|
||||
self.url = url
|
||||
|
||||
// Check cache first
|
||||
if let cached = ImageCache.shared.cachedImage(for: url) {
|
||||
|
||||
// 1. Memory hit — instant, no I/O
|
||||
if let cached = ImageCache.shared.memoryOnlyImage(for: url) {
|
||||
self.image = cached
|
||||
return
|
||||
}
|
||||
|
||||
// Download
|
||||
|
||||
// 2. Async check (disk → network) — never blocks main thread
|
||||
isLoading = true
|
||||
task?.cancel()
|
||||
task = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
||||
guard let self = self, error == nil, let data = data,
|
||||
let img = UIImage(data: data) else {
|
||||
DispatchQueue.main.async { self?.isLoading = false }
|
||||
Task { @MainActor in
|
||||
// Check disk off main thread
|
||||
if let cached = await ImageCache.shared.cachedImageAsync(for: url) {
|
||||
self.image = cached
|
||||
self.isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Cache it
|
||||
ImageCache.shared.store(img, for: url)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
|
|
@ -350,9 +380,12 @@ struct AsyncCoverArt: View {
|
|||
.aspectRatio(contentMode: .fill)
|
||||
.clipped()
|
||||
} else if id.hasPrefix("companion:") {
|
||||
// Route to Companion API cover art endpoint
|
||||
// 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 = CompanionAPIService().coverArtURL(companionId: companionId) {
|
||||
if let url = CompanionSettings.shared.baseURL?
|
||||
.appendingPathComponent("library/cover-art/\(companionId)") {
|
||||
ZStack {
|
||||
placeholderView
|
||||
CachedAsyncImage(url: url)
|
||||
|
|
|
|||
|
|
@ -257,20 +257,21 @@ struct MultiAlbumEditorSheet: View {
|
|||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if excludedSongIds.contains(song.id) {
|
||||
Button {
|
||||
withAnimation { excludedSongIds.remove(song.id) }
|
||||
} label: {
|
||||
Label("Include", systemImage: "plus.circle")
|
||||
}
|
||||
.tint(accentPink)
|
||||
} else {
|
||||
Button(role: .destructive) {
|
||||
withAnimation { excludedSongIds.insert(song.id) }
|
||||
} label: {
|
||||
Label("Exclude", systemImage: "minus.circle")
|
||||
Button {
|
||||
withAnimation {
|
||||
if excludedSongIds.contains(song.id) {
|
||||
excludedSongIds.remove(song.id)
|
||||
} else {
|
||||
excludedSongIds.insert(song.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
excludedSongIds.contains(song.id) ? "Include" : "Exclude",
|
||||
systemImage: excludedSongIds.contains(song.id) ? "plus.circle" : "minus.circle"
|
||||
)
|
||||
}
|
||||
.tint(excludedSongIds.contains(song.id) ? accentPink : .red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
|
|
|
|||
|
|
@ -942,27 +942,23 @@ struct MyMusicView: View {
|
|||
|
||||
Divider()
|
||||
|
||||
// Favourite toggle
|
||||
// Favourite toggle — routes through OptimisticActionQueue for offline resilience.
|
||||
// Direct client calls with try? silently lose the action when Tailscale is reconnecting.
|
||||
Button(action: {
|
||||
Task {
|
||||
if song.starred != nil {
|
||||
try? await serverManager.client.unstar(id: song.id)
|
||||
await MainActor.run {
|
||||
favouriteSongs.removeAll { $0.id == song.id }
|
||||
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
||||
}
|
||||
} else {
|
||||
try? await serverManager.client.star(id: song.id)
|
||||
await MainActor.run {
|
||||
if !favouriteSongs.contains(where: { $0.id == song.id }) {
|
||||
favouriteSongs.append(song)
|
||||
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
||||
}
|
||||
}
|
||||
if song.starred != nil {
|
||||
OptimisticActionQueue.shared.unstar(songId: song.id)
|
||||
favouriteSongs.removeAll { $0.id == song.id }
|
||||
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
||||
} else {
|
||||
OptimisticActionQueue.shared.star(songId: song.id)
|
||||
if !favouriteSongs.contains(where: { $0.id == song.id }) {
|
||||
favouriteSongs.append(song)
|
||||
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
|
||||
Label(song.starred != nil ? "Unfavourite" : "Favourite",
|
||||
systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
|
@ -1120,19 +1116,27 @@ struct MyMusicView: View {
|
|||
// MARK: - Album Deduplication
|
||||
// Navidrome returns one album entry per artist for compilations.
|
||||
// Group by name + coverArt to show each album once.
|
||||
|
||||
// O(n) implementation: build frequency map in one pass, deduplicate in second pass.
|
||||
|
||||
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
|
||||
var seen = Set<String>()
|
||||
var result: [Album] = []
|
||||
// Count how many distinct artist entries share the same name+cover key
|
||||
var keyCount: [String: Int] = [:]
|
||||
for album in albums {
|
||||
let key = "\(album.name)|\(album.coverArt ?? "")"
|
||||
if seen.contains(key) { continue }
|
||||
keyCount[key, default: 0] += 1
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
var result: [Album] = []
|
||||
result.reserveCapacity(keyCount.count)
|
||||
|
||||
for album in albums {
|
||||
let key = "\(album.name)|\(album.coverArt ?? "")"
|
||||
guard !seen.contains(key) else { continue }
|
||||
seen.insert(key)
|
||||
|
||||
// Check if multiple artists share this album
|
||||
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
|
||||
if sameAlbum.count > 1 {
|
||||
// Replace artist with "Various Artists"
|
||||
|
||||
if keyCount[key, default: 1] > 1 {
|
||||
// Multiple artist entries — replace with "Various Artists"
|
||||
let grouped = Album(
|
||||
id: album.id, name: album.name,
|
||||
artist: "Various Artists", artistId: nil,
|
||||
|
|
|
|||
|
|
@ -218,9 +218,12 @@ class VisualizerSettings: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Debounce UserDefaults writes to 500ms so rapid slider drags don't
|
||||
// flood the defaults system. The Task is explicitly @MainActor so
|
||||
// pendingSaves is only ever read/written on the main thread — no data race.
|
||||
private func scheduleFlush() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { [weak self] in
|
||||
saveTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
for (k, v) in self.pendingSaves {
|
||||
|
|
@ -286,6 +289,12 @@ struct MitsuhaVisualizerView: View {
|
|||
var compact: Bool = false
|
||||
var isVisible: Bool = true
|
||||
|
||||
// Observe settings only for the enabled gate and config values.
|
||||
// The Canvas itself does NOT observe settings — we pass the values it
|
||||
// needs as local constants captured at body evaluation time. This means
|
||||
// a slider drag in VisualizerSettingsView does NOT invalidate the Canvas
|
||||
// on every change — only the outer view re-evaluates, and the Canvas
|
||||
// only re-evaluates when isPlaying / isVisible / isAppActive changes.
|
||||
@ObservedObject var settings = VisualizerSettings.shared
|
||||
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
||||
@StateObject private var box = VisualizerLevelBox()
|
||||
|
|
@ -305,12 +314,13 @@ struct MitsuhaVisualizerView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
if settings.enabled {
|
||||
// Always keep one TimelineView mounted — never swap it for a static Canvas.
|
||||
// Swapping causes a view-identity change: the old idle Canvas persists in the
|
||||
// compositor while the new TimelineView hasn't committed its first frame yet,
|
||||
// producing a frozen wave after pause/resume. Rendering idle vs live inside
|
||||
// the Canvas body avoids this entirely.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 60.0)) { timeline in
|
||||
// Adaptive FPS: full rate when rendering, 2fps when idle/paused.
|
||||
// This prevents the 60fps GPU wakeup that drains battery during pause
|
||||
// and stops the render loop from fighting Liquid Glass gesture tracking.
|
||||
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
|
||||
let tickInterval = 1.0 / max(targetFPS, 1.0)
|
||||
|
||||
TimelineView(.periodic(from: .now, by: tickInterval)) { timeline in
|
||||
let tickDate = timeline.date
|
||||
Canvas { context, size in
|
||||
_ = tickDate
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
|
||||
private let catalogKey = "watch_offline_songs"
|
||||
private let fileManager = FileManager.default
|
||||
private var pendingSongs: [String: Song] = [:] // taskId → Song
|
||||
private var taskToSongId: [Int: String] = [:] // URLSession task ID → songId
|
||||
// Both pendingSongs and taskToSongId are accessed from the URLSession delegate queue
|
||||
// (background) AND from the main thread. Protect them with a dedicated serial queue.
|
||||
private let storeQueue = DispatchQueue(label: "com.navidromeplayer.watch.offlinestore")
|
||||
private var pendingSongs: [String: Song] = [:] // access via storeQueue only
|
||||
private var taskToSongId: [Int: String] = [:] // access via storeQueue only
|
||||
|
||||
private lazy var backgroundSession: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download")
|
||||
|
|
@ -129,7 +132,7 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
|
||||
func downloadFromServer(song: Song) {
|
||||
guard !songs.contains(where: { $0.id == song.id }) else { return }
|
||||
guard downloadProgress[song.id] == nil else { return } // already downloading
|
||||
guard downloadProgress[song.id] == nil else { return }
|
||||
|
||||
guard let url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 128) else {
|
||||
print("[Watch] No stream URL for \(song.id)")
|
||||
|
|
@ -141,12 +144,13 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
self.downloadComplete[song.id] = false
|
||||
}
|
||||
|
||||
pendingSongs[song.id] = song
|
||||
|
||||
let task = backgroundSession.downloadTask(with: url)
|
||||
taskToSongId[task.taskIdentifier] = song.id
|
||||
// Protect dictionary mutations with storeQueue — delegate reads on background queue
|
||||
storeQueue.sync {
|
||||
self.pendingSongs[song.id] = song
|
||||
self.taskToSongId[task.taskIdentifier] = song.id
|
||||
}
|
||||
task.resume()
|
||||
|
||||
print("[Watch] Download started: \(song.title)")
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +164,8 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
// MARK: - URLSession Download Delegate
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
guard let songId = taskToSongId[downloadTask.taskIdentifier] else { return }
|
||||
let songId: String? = storeQueue.sync { taskToSongId[downloadTask.taskIdentifier] }
|
||||
guard let songId else { return }
|
||||
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||
DispatchQueue.main.async {
|
||||
self.downloadProgress[songId] = progress
|
||||
|
|
@ -168,8 +173,11 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
guard let songId = taskToSongId[downloadTask.taskIdentifier],
|
||||
let song = pendingSongs[songId] else { return }
|
||||
let (songId, song): (String?, Song?) = storeQueue.sync {
|
||||
let sid = taskToSongId[downloadTask.taskIdentifier]
|
||||
return (sid, sid.flatMap { pendingSongs[$0] })
|
||||
}
|
||||
guard let songId, let song else { return }
|
||||
|
||||
let filename = "\(songId).mp3"
|
||||
let destURL = musicDirectory.appendingPathComponent(filename)
|
||||
|
|
@ -184,38 +192,39 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
self.addSong(song, localPath: destURL.path)
|
||||
self.downloadProgress.removeValue(forKey: songId)
|
||||
self.downloadComplete[songId] = true
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
|
||||
|
||||
// Haptic feedback
|
||||
self.storeQueue.async {
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
|
||||
}
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
|
||||
// Notify iOS
|
||||
self.notifyiOS(songId: songId, downloaded: true)
|
||||
|
||||
// Clear completion indicator after 2s
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.downloadComplete.removeValue(forKey: songId)
|
||||
}
|
||||
}
|
||||
|
||||
print("[Watch] Download complete: \(song.title)")
|
||||
} catch {
|
||||
print("[Watch] Save failed: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
self.downloadProgress.removeValue(forKey: songId)
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
self.storeQueue.async {
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error, let songId = taskToSongId[task.taskIdentifier] else { return }
|
||||
guard let error else { return }
|
||||
let songId: String? = storeQueue.sync { taskToSongId[task.taskIdentifier] }
|
||||
guard let songId else { return }
|
||||
print("[Watch] Download error for \(songId): \(error.localizedDescription)")
|
||||
DispatchQueue.main.async {
|
||||
self.downloadProgress.removeValue(forKey: songId)
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
self.taskToSongId.removeValue(forKey: task.taskIdentifier)
|
||||
self.storeQueue.async {
|
||||
self.pendingSongs.removeValue(forKey: songId)
|
||||
self.taskToSongId.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,7 +272,6 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate
|
|||
// MARK: - Cache Management
|
||||
|
||||
var cacheSize: String {
|
||||
let size = songs.reduce(Int64(0)) { $0 + $1.fileSize }
|
||||
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue