...
This commit is contained in:
parent
0df9057add
commit
920eab741d
1 changed files with 66 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue