diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index f52c48d..0422f14 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -111,12 +111,67 @@ class AudioPlayer: NSObject, ObservableObject { AlbumCoverStore.shared.$updateTrigger .sink { [weak self] _ in guard let self, let id = self.currentSong?.coverArt else { return } - self.cachedArtworkCoverArtId = nil // Force refetch + self.cachedArtworkCoverArtId = nil self.fetchAndSetArtwork(coverArtId: id) } .store(in: &cancellables) + + // Stop all visualizer timers when backgrounded — they serve no purpose without + // a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU). + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in self?.suspendVisTimers() } + .store(in: &cancellables) + + // Restart the correct timer when returning to foreground + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in self?.resumeVisTimers() } + .store(in: &cancellables) #endif } + + #if os(iOS) + private func suspendVisTimers() { + stopOfflineVisTimer() + stopLevelTimer() + // Remove the periodic time observer — it fires at 10Hz updating @Published currentTime, + // which triggers SwiftUI re-evaluation of the entire view tree in background. + // This is the primary driver of the 93% background CPU that kills the process. + removeTimeObserver() + alog("Background: timers + time observer suspended") + } + + private func resumeVisTimers() { + guard isPlaying else { return } + // Reinstall the time observer before restarting vis timers so currentTime + // is accurate when the offline vis timer first reads position. + reinstallTimeObserver() + if isUsingOfflineVis { + startOfflineVisSync() + } else { + startLevelSimulation() + } + alog("Foreground: timers + time observer resumed (offlineVis=\(isUsingOfflineVis))") + } + + private func removeTimeObserver() { + if let observer = timeObserver { + player?.removeTimeObserver(observer) + timeObserver = nil + } + } + + private func reinstallTimeObserver() { + guard player != nil, timeObserver == nil else { return } + let interval = CMTime(seconds: 0.1, preferredTimescale: 600) + timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self = self else { return } + self.currentTime = time.seconds + if let dur = self.playerItem?.duration.seconds, !dur.isNaN { + self.duration = dur + } + } + } + #endif // MARK: - Audio Session @@ -1374,33 +1429,34 @@ class AudioPlayer: NSObject, ObservableObject { private func startLevelSimulation() { stopLevelTimer() - // Force a fresh target generation on the first tick after resume - // by invalidating the cached phase. Otherwise currentTime hasn't - // advanced yet and the wave smooths to a stale constant value. lastTargetPhase = -1 levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { if self?.internalLevels.contains(where: { $0 > 0.01 }) == true { - self?.internalLevels = Array(repeating: 0, count: 30) + for i in 0..<(self?.internalLevels.count ?? 0) { + self?.internalLevels[i] = 0 + } self?.setLevels(self?.internalLevels ?? []) } return } - - let time = self.currentTime + + let time = self.currentTime let phase = Int(time * 12) if phase != self.lastTargetPhase { self.lastTargetPhase = phase - self.targetLevels = (0..<30).map { _ in - Float.random(in: 0.1...0.85) + // In-place random target — no allocation + for i in 0..