From 16fd347b44a4489f24484675efdcd34fc362d273 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 18:05:10 -0700 Subject: [PATCH] fixes hopefully --- Shared/Audio/AudioPlayer.swift | 33 +++++++++++++++++++++------------ iOS/Data/AudioPreFetcher.swift | 14 +++++++++++++- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 18be65f..88da2ee 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -26,11 +26,12 @@ class AudioPlayer: NSObject, ObservableObject { @Published var volume: Float = 0.8 @Published var repeatMode: RepeatMode = .off @Published var shuffleEnabled = false - // audioLevels is NOT @Published — avoids 60fps SwiftUI state thrashing. - // Instead, levelTick increments every time levels change. The visualizer - // observes levelTick: when it changes, body re-evaluates, Canvas re-executes, - // and pulls the latest levels via currentLevels(). Guaranteed dependency. - @Published private(set) var levelTick: Int = 0 + // audioLevels is NOT @Published — avoids SwiftUI state thrashing. + // The Canvas reads currentLevels() directly on every TimelineView tick. + // levelTick is kept as an internal counter but intentionally NOT @Published — + // making it @Published caused 30fps objectWillChange on AudioPlayer, forcing + // every observing view (NowPlayingView, MainTabView, etc.) to re-evaluate 30x/sec. + private(set) var levelTick: Int = 0 nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30) func currentLevels() -> [Float] { _audioLevels } @@ -134,14 +135,11 @@ class AudioPlayer: NSObject, ObservableObject { private func suspendVisTimers() { stopOfflineVisTimer() stopLevelTimer() - // Cancel any in-progress offline vis analysis — it's a CPU-intensive FFT - // loop that will otherwise run to completion in background, burning ~60% CPU analysisTask?.cancel() analysisTask = nil - // Remove the periodic time observer — it fires at 10Hz updating @Published - // currentTime, driving SwiftUI re-evaluation of the entire view tree in background + AudioPreFetcher.shared.cancelAll() removeTimeObserver() - alog("Background: timers + time observer + analysis task suspended") + alog("Background: timers + time observer + analysis + prefetch suspended") } private func resumeVisTimers() { @@ -1234,8 +1232,19 @@ class AudioPlayer: NSObject, ObservableObject { offlineVisFPS = VisualizerSettings.shared.effectiveFPS let points = VisualizerSettings.shared.nowPlaying.numberOfPoints let cutoff = VisualizerSettings.shared.frequencyCutoff - - analysisTask?.cancel() // cancel any in-flight analysis from the previous song + + analysisTask?.cancel() + + // Don't start analysis if already in background — it will be triggered + // when the app returns to foreground instead (via resumeVisTimers path + // or next song load in foreground). + #if os(iOS) + guard UIApplication.shared.applicationState != .background else { + alog("Offline vis: skipping analysis in background for \(songId)") + return + } + #endif + analysisTask = Task { let storage = VisualizerStorageManager.shared let hasCache = await storage.hasCache(for: songId) diff --git a/iOS/Data/AudioPreFetcher.swift b/iOS/Data/AudioPreFetcher.swift index 6c8d329..98ffdbb 100644 --- a/iOS/Data/AudioPreFetcher.swift +++ b/iOS/Data/AudioPreFetcher.swift @@ -16,7 +16,13 @@ class AudioPreFetcher { /// 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 } @@ -95,6 +101,12 @@ class AudioPreFetcher { } } + /// 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)