From 282eb5d80c46e459273e5ae57d75117ee6cff5bb Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 11 Apr 2026 19:01:10 -0700 Subject: [PATCH] Fixed idle visualizer bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Shared/Audio/AudioPlayer.swift | 55 ++++++++++++++++++- iOS/Views/Common/MainTabView.swift | 2 + iOS/Views/NowPlaying/NowPlayingView.swift | 1 + .../Visualizer/MitsuhaVisualizerView.swift | 21 ++++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 7a0d3c1..68b6d66 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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..