diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 41ab592..ceb3483 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -28,20 +28,18 @@ 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 - // Visualizer pulls directly via AudioPlayer.shared.currentLevels() - // No lock needed: writes happen on main thread (Timer callbacks), - // reads happen from view body. A one-frame-stale read is imperceptible. + // 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 nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30) - - /// Current visualizer levels (called from TimelineView body) - func currentLevels() -> [Float] { - _audioLevels - } - - /// Write visualizer levels (called from timers/taps on main thread) + + func currentLevels() -> [Float] { _audioLevels } + private func setLevels(_ levels: [Float]) { _audioLevels = levels + levelTick &+= 1 } @Published var isUsingRealFFT = false @@ -724,11 +722,10 @@ class AudioPlayer: NSObject, ObservableObject { isPlaying = false #if os(iOS) stopOfflineVisTimer() - stopLevelTimer() - // DO NOT zero _audioLevels here. The Canvas reads zeros when isPlaying=false, - // so the wave decays visually via viscosity smoothing regardless. - // Preserving _audioLevels means on resume the Canvas immediately has real data — - // the wave reacts on the very first frame instead of waiting for the timer's first tick. + // Don't stop levelTimer — the simulation timer's !isPlaying branch decays + // internalLevels to zero and calls setLevels(zeros), which increments levelTick + // so the Canvas keeps re-rendering and the wave visually decays during pause. + // For offline vis path the offlineVisTimer (restarted above) handles this. internalLevels = Array(repeating: 0, count: 30) #endif updateNowPlayingInfo() @@ -750,20 +747,35 @@ class AudioPlayer: NSObject, ObservableObject { } isPlaying = true #if os(iOS) + // Prime levels immediately before any timer fires. + // The CADisplayLink draws at 60fps; the level timers fire at 30fps. + // Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels + // (zeroed by pause) and the wave starts flat then slowly ramps up. if isUsingOfflineVis { + // Offline vis: prime with the last-rendered frame at current position + if !offlineVisFrames.isEmpty { + let dur = duration + let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0 + let idx = min(Int(pct * Double(offlineVisFrames.count)), offlineVisFrames.count - 1) + setLevels(offlineVisFrames[idx]) + } startOfflineVisSync() } else if isUsingRealFFT, !isUsingOfflineVis { - // Engine (real FFT) path — restart the raw FFT publish timer. - // stopLevelTimer() on pause killed this; startLevelSimulation() would - // push random simulation data instead of the real FFT tap output. + // Engine FFT path: rawFFTLevels still holds pre-pause data — push it immediately + setLevels(rawFFTLevels) stopLevelTimer() levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self = self else { return } self.setLevels(self.rawFFTLevels) } } else { - // AVPlayer / streaming / radio — use level simulation - startLevelSimulation() + // Simulation path: seed internalLevels from a fresh random target + // so the wave has height immediately rather than starting from floor. + lastTargetPhase = -1 + targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) } + internalLevels = targetLevels.map { $0 * 0.8 } + setLevels(internalLevels) + if levelTimer == nil { startLevelSimulation() } } #endif updateNowPlayingInfo() @@ -1289,7 +1301,14 @@ class AudioPlayer: NSObject, ObservableObject { stopOfflineVisTimer() offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in - guard let self = self, self.isUsingOfflineVis, self.isPlaying else { return } + guard let self = self, self.isUsingOfflineVis else { return } + guard self.isPlaying else { + // Keep pulsing zeros so levelTick increments and the visualizer can decay + if self._audioLevels.contains(where: { $0 > 0.005 }) { + self.setLevels(Array(repeating: 0, count: 30)) + } + return + } let frames = self.offlineVisFrames guard !frames.isEmpty else { return } diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index ec1dcdb..515b0c9 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -140,39 +140,6 @@ final class WaveStateCache { private init() {} } -// MARK: - CADisplayLink Refresh Driver -/// Drives Canvas redraws at the screen's native refresh rate via CADisplayLink. -/// @Published tick increments each frame → SwiftUI re-renders MitsuhaVisualizerView.body -/// → Canvas drawing closure executes → wave updates. -/// CADisplayLink runs on the main run loop in .common mode: fires during scrolls, -/// sheet presentations, pause state, and any other condition that stalls SwiftUI's -/// animation scheduler. -fileprivate final class VisualRefreshDriver: ObservableObject { - @Published private(set) var tick: Int = 0 - private var displayLink: CADisplayLink? - - func start(fps: Double) { - guard displayLink == nil else { return } - let link = CADisplayLink(target: self, selector: #selector(step)) - link.preferredFrameRateRange = CAFrameRateRange( - minimum: Float(min(fps, 30)), - maximum: Float(min(fps, 60)), - preferred: Float(min(fps, 60)) - ) - link.add(to: .main, forMode: .common) - displayLink = link - } - - func stop() { - displayLink?.invalidate() - displayLink = nil - } - - @objc private func step() { - tick &+= 1 // wrapping increment — never overflows - } -} - // MARK: - Level Box /// Per-instance mutable render state. Zero @Published properties → no observation warnings. fileprivate final class VisualizerLevelBox: ObservableObject { @@ -194,27 +161,32 @@ struct MitsuhaVisualizerView: View { @ObservedObject var settings = VisualizerSettings.shared @ObservedObject var albumColors = AlbumColorExtractor.shared + // Observing AudioPlayer directly: levelTick is @Published and increments every time + // setLevels() is called — i.e. every time real audio data arrives. SwiftUI registers + // a definitive dependency and re-evaluates body on every level update. No separate + // timer, no CADisplayLink, no race between rendering and data delivery. + @ObservedObject private var audioPlayer = AudioPlayer.shared - @StateObject private var driver = VisualRefreshDriver() - @StateObject private var box = VisualizerLevelBox() + @StateObject private var box = VisualizerLevelBox() private static let historySize = 16 var body: some View { Group { if settings.enabled { - // Reading driver.tick as a local binding forces SwiftUI to register - // a real @Published dependency. The let _ = pattern can be elided by - // the compiler in ViewBuilder context; assigning to a named local is not. - let currentTick = driver.tick + // audioPlayer.levelTick is @Published and changes every time setLevels() + // is called in AudioPlayer — i.e. on every actual audio data delivery. + // Reading it here gives SwiftUI a concrete dependency: body re-evaluates + // exactly when new level data arrives, Canvas re-executes, wave updates. + // When paused the timers stop so levelTick stops changing — Canvas holds + // last frame (correct). On resume the timers restart and levelTick resumes + // incrementing — Canvas immediately reacts. + let _ = audioPlayer.levelTick Canvas { context, size in - // Reference currentTick so the Canvas closure captures it — - // a new value each frame guarantees SwiftUI re-executes the drawing closure. - _ = currentTick let now = CACurrentMediaTime() let rawLevels: [Float] = isPlaying - ? (previewLevels ?? AudioPlayer.shared.currentLevels()) + ? (previewLevels ?? audioPlayer.currentLevels()) : Array(repeating: Float(0), count: max(settings.numberOfPoints, 1)) updateDisplayLevels(newRawLevels: rawLevels) updateWobblePhase(now: now) @@ -236,10 +208,6 @@ struct MitsuhaVisualizerView: View { box.displayLevels = cached.isEmpty ? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints) : cached - driver.start(fps: settings.effectiveFPS) - } - .onDisappear { - driver.stop() } } }