more fixes
This commit is contained in:
parent
bbb7faf488
commit
75a4343ad1
2 changed files with 56 additions and 69 deletions
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue