Fixed idle visualizer bug
AudioPlayer.swift — startRadioSimulation() replaces startLevelSimulation() for radio streams. It generates two overlapping sine waves with a slow breathing amplitude envelope. The key property: it’s driven by an internal radioPhase counter that increments at a fixed rate, completely independent of currentTime. Buffer seeks, HLS restarts, and stream reconnects have zero visual effect. MitsuhaVisualizerView.swift — Two fixes: 1. TimelineView(.periodic(from: .distantPast, by: tickInterval)) — using .distantPast as the stable schedule origin instead of .now. Previously, any recalculation of tickInterval (triggered by isRenderingActive flipping during a radio buffer hiccup) would reset the origin to the current moment, deferring the next tick by a full interval — the visible stall. 2. songId parameter with .onChange(of: songId) that resets peakFollower, levelHistoryBuf, and lastTickTime on song change. This prevents the outgoing song’s simulation spike from “raising” the wave at the start of the next song. NowPlayingView.swift and MainTabView.swift — Pass songId: audioPlayer.currentSong?.id through to MitsuhaVisualizerView and CompactVisualizerView at all call sites.
This commit is contained in:
parent
7d78b22ce2
commit
282eb5d80c
4 changed files with 74 additions and 5 deletions
|
|
@ -796,7 +796,18 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
updateNowPlayingInfo()
|
||||
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
||||
// Radio streams use a smooth sinusoidal simulation — random phase
|
||||
// simulation reacts to currentTime jumps (buffer seeks) causing
|
||||
// the peakFollower to spike and "raise" the wave unexpectedly.
|
||||
#if os(iOS)
|
||||
if isRadioStream {
|
||||
startRadioSimulation()
|
||||
} else {
|
||||
startLevelSimulation()
|
||||
}
|
||||
#else
|
||||
startLevelSimulation()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - AVAudioEngine Path (local files with real FFT)
|
||||
|
|
@ -1607,7 +1618,48 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
#endif
|
||||
|
||||
// MARK: - Simulated Level Animation (for streams)
|
||||
|
||||
|
||||
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
|
||||
/// Radio streams can't be pre-analyzed and their AVPlayer currentTime can
|
||||
/// jump on buffer seeks — using the same random-phase simulation as music
|
||||
/// causes the peakFollower in the visualizer to spike and "raise" the wave.
|
||||
/// This uses a time-based sine wave that's completely independent of
|
||||
/// currentTime, so buffer resets and seek-backs have no visual effect.
|
||||
private func startRadioSimulation() {
|
||||
stopLevelTimer()
|
||||
var radioPhase: Double = 0
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
#if os(iOS)
|
||||
guard VisualizerSettings.shared.enabled else { return }
|
||||
#endif
|
||||
guard self.isPlaying else {
|
||||
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
||||
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
|
||||
self.setLevels(self.internalLevels)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
radioPhase += 1.0 / 30.0
|
||||
|
||||
// Smooth sine wave across the frequency bands — gentle, never spikes.
|
||||
// Base amplitude 0.35, modulated by a slow breathing envelope (0.15 range)
|
||||
// so it looks alive without jarring jumps.
|
||||
let count = self.internalLevels.count
|
||||
let breathe = Float(0.35 + 0.15 * sin(radioPhase * 0.4))
|
||||
for i in 0..<count {
|
||||
let x = Float(i) / Float(max(count - 1, 1))
|
||||
// Two overlapping sine waves at different frequencies for organic shape
|
||||
let wave1 = sin(Float(radioPhase * 1.1) + x * .pi * 2.0)
|
||||
let wave2 = sin(Float(radioPhase * 0.7) + x * .pi * 3.5) * 0.4
|
||||
self.internalLevels[i] = breathe * (0.5 + 0.5 * (wave1 + wave2) / 1.4)
|
||||
}
|
||||
|
||||
self.setLevels(self.internalLevels)
|
||||
}
|
||||
}
|
||||
|
||||
private func startLevelSimulation() {
|
||||
stopLevelTimer()
|
||||
lastTargetPhase = -1
|
||||
|
|
@ -1732,4 +1784,3 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -600,6 +600,7 @@ struct MiniPlayerBar: View {
|
|||
CompactVisualizerView(
|
||||
isPlaying: audioPlayer.isPlaying,
|
||||
isSongLoaded: audioPlayer.currentSong != nil,
|
||||
songId: audioPlayer.currentSong?.id,
|
||||
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
|
||||
height: VisualizerSettings.shared.miniPlayerHeight
|
||||
)
|
||||
|
|
@ -756,6 +757,7 @@ struct DynamicIslandView: View {
|
|||
CompactVisualizerView(
|
||||
isPlaying: audioPlayer.isPlaying,
|
||||
isSongLoaded: audioPlayer.currentSong != nil,
|
||||
songId: audioPlayer.currentSong?.id,
|
||||
accentColor: themeColor,
|
||||
height: 44
|
||||
)
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ struct NowPlayingView: View {
|
|||
MitsuhaVisualizerView(
|
||||
isPlaying: audioPlayer.isPlaying,
|
||||
isSongLoaded: audioPlayer.currentSong != nil,
|
||||
songId: audioPlayer.currentSong?.id,
|
||||
accentColor: accentPink,
|
||||
isVisible: isPresented
|
||||
)
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ struct MitsuhaVisualizerView: View {
|
|||
var previewLevels: [Float]? = nil
|
||||
let isPlaying: Bool
|
||||
var isSongLoaded: Bool = true
|
||||
var songId: String? = nil // used to reset peakFollower on song change
|
||||
let accentColor: Color
|
||||
var compact: Bool = false
|
||||
var isVisible: Bool = true
|
||||
|
|
@ -320,7 +321,11 @@ struct MitsuhaVisualizerView: View {
|
|||
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
|
||||
let tickInterval = 1.0 / max(targetFPS, 1.0)
|
||||
|
||||
TimelineView(.periodic(from: .now, by: tickInterval)) { timeline in
|
||||
// Use a stable start date so the periodic schedule origin never
|
||||
// resets. Previously, .now was passed every time targetFPS changed
|
||||
// (e.g. on a radio buffer hiccup), causing the next tick to be
|
||||
// deferred by a full interval — the visible "stall" in the wave.
|
||||
TimelineView(.periodic(from: .distantPast, by: tickInterval)) { timeline in
|
||||
let tickDate = timeline.date
|
||||
Canvas { context, size in
|
||||
_ = tickDate
|
||||
|
|
@ -374,13 +379,13 @@ struct MitsuhaVisualizerView: View {
|
|||
box.levelHistoryBuf.removeAll(keepingCapacity: true)
|
||||
box.historyWriteIdx = 0
|
||||
box.peakFollower = 0.01
|
||||
box.lastTickTime = 0 // ← reset so first resume frame uses 1/60 fallback dt
|
||||
box.lastTickTime = 0
|
||||
DebugLogger.shared.log(
|
||||
"PAUSE → levelHistoryBuf cleared, lastTickTime reset " +
|
||||
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count)]",
|
||||
category: "VisDebug", level: .info)
|
||||
} else {
|
||||
box.resumeTickCount = 0 // reset so we log the first 3 ticks
|
||||
box.resumeTickCount = 0
|
||||
DebugLogger.shared.log(
|
||||
"RESUME → isRenderingActive=\(isRenderingActive) " +
|
||||
"isAppActive=\(isAppActive) isVisible=\(isVisible) " +
|
||||
|
|
@ -389,6 +394,14 @@ struct MitsuhaVisualizerView: View {
|
|||
category: "VisDebug", level: .info)
|
||||
}
|
||||
}
|
||||
// Reset peakFollower on song change so a spike from the outgoing
|
||||
// simulation doesn't "raise" the wave at the start of the next song.
|
||||
.onChange(of: songId) { _, _ in
|
||||
box.peakFollower = 0.01
|
||||
box.levelHistoryBuf.removeAll(keepingCapacity: true)
|
||||
box.historyWriteIdx = 0
|
||||
box.lastTickTime = 0
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
isAppActive = true
|
||||
box.lastTickTime = 0
|
||||
|
|
@ -871,6 +884,7 @@ struct MitsuhaVisualizerView: View {
|
|||
struct CompactVisualizerView: View {
|
||||
let isPlaying: Bool
|
||||
var isSongLoaded: Bool = true
|
||||
var songId: String? = nil
|
||||
let accentColor: Color
|
||||
let height: CGFloat
|
||||
|
||||
|
|
@ -878,6 +892,7 @@ struct CompactVisualizerView: View {
|
|||
MitsuhaVisualizerView(
|
||||
isPlaying: isPlaying,
|
||||
isSongLoaded: isSongLoaded,
|
||||
songId: songId,
|
||||
accentColor: accentColor,
|
||||
compact: true
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue