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..