This commit is contained in:
Dallas Groot 2026-04-05 08:51:26 -07:00
parent 0df9057add
commit 920eab741d

View file

@ -140,27 +140,54 @@ final class WaveStateCache {
private init() {}
}
// MARK: - Touch Ripple (file-private so VisualizerLevelBox can reference it before MitsuhaVisualizerView)
// 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
/// Holds all mutable per-frame rendering state.
/// Conforms to ObservableObject but has ZERO @Published properties,
/// so SwiftUI never observes it and never warns "modifying state during view update".
/// TimelineView drives rerenders independently; the Canvas just reads from this box directly.
/// Per-instance mutable render state. Zero @Published properties no observation warnings.
fileprivate final class VisualizerLevelBox: ObservableObject {
var displayLevels: [Float] = []
var peakFollower: Float = 0.01
var levelHistoryBuf: [[Float]] = []
var historyWriteIdx: Int = 0
var wobblePhaseOffset: Double = 0
var lastTimelineDate: Date? = nil
var lastTickTime: CFTimeInterval = 0
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
var previewLevels: [Float]? = nil
let isPlaying: Bool
/// When false (song gap between tracks), hold the wave steady at its current
/// opacity so it doesn't snap to idle amplitude and create a visible "lift".
var isSongLoaded: Bool = true
let accentColor: Color
var compact: Bool = false
@ -168,39 +195,35 @@ struct MitsuhaVisualizerView: View {
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
// @StateObject: SwiftUI holds instance across renders; no @Published = no observation warnings
@StateObject private var box = VisualizerLevelBox()
@StateObject private var driver = VisualRefreshDriver()
@StateObject private var box = VisualizerLevelBox()
private static let historySize = 16 // ~267ms at 60fps 250ms original dispatch_after
private static let historySize = 16
var body: some View {
Group {
if settings.enabled {
// .periodic fires on a fixed wall-clock interval regardless of SwiftUI animation state.
// Update calls are moved INSIDE the Canvas closure = plain imperative code,
// not @ViewBuilder guaranteed to execute on every tick.
// `t` is a changing Date captured by Canvas: SwiftUI sees the closure environment
// changed each tick always re-executes the drawing closure.
TimelineView(.periodic(from: .now, by: 1.0 / settings.effectiveFPS)) { timeline in
let t = timeline.date // changes every tick forces Canvas redraw
// driver.tick is @Published increments every display frame via CADisplayLink.
// SwiftUI re-evaluates this body each tick, which re-executes the Canvas
// drawing closure. No TimelineView, no .animation schedule dependencies.
let _ = driver.tick
Canvas { context, size in
// Plain imperative context these calls always execute, no ViewBuilder rules
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
updateDisplayLevels(newRawLevels: rawLevels)
updateWobblePhase(date: t)
Canvas { context, size in
let now = CACurrentMediaTime()
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
updateDisplayLevels(newRawLevels: rawLevels)
updateWobblePhase(now: now)
let pts = box.displayLevels.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: box.displayLevels
guard pts.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: pts)
case .line: drawLine(ctx: context, size: size, levels: pts)
}
let pts = box.displayLevels.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: box.displayLevels
guard pts.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: pts)
case .line: drawLine(ctx: context, size: size, levels: pts)
}
}
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
@ -210,21 +233,23 @@ struct MitsuhaVisualizerView: View {
box.displayLevels = cached.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
driver.start(fps: settings.effectiveFPS)
}
.onDisappear {
driver.stop()
}
}
}
}
// MARK: - Wobble Phase (continuous time accumulator)
// MARK: - Wobble Phase
@discardableResult
private func updateWobblePhase(date: Date) -> Bool {
if let last = box.lastTimelineDate {
let delta = date.timeIntervalSince(last)
private func updateWobblePhase(now: CFTimeInterval) {
if box.lastTickTime > 0 {
let delta = now - box.lastTickTime
box.wobblePhaseOffset += min(delta, 0.1)
}
box.lastTimelineDate = date
return true
box.lastTickTime = now
}
// MARK: - Temporal Smoothing & Log Binning