import Foundation import AVFoundation import Combine import MediaPlayer import Accelerate import os #if os(iOS) import UIKit import WidgetKit #endif /// Shared audio player with real FFT visualizer for local files class AudioPlayer: NSObject, ObservableObject { static let shared = AudioPlayer() // MARK: - Published State @Published var currentSong: Song? @Published var queue: [Song] = [] @Published var queueIndex: Int = 0 @Published var isPlaying = false // currentTime and duration are @Published but set only from addPeriodicTimeObserver // on the main queue — safe for SwiftUI observation. NowPlayingView binds directly, // eliminating the Timer.publish poller that caused runloop starvation. // currentTime and duration are intentionally NOT @Published. // @Published fires objectWillChange on AudioPlayer 10-20x/second, causing // every @EnvironmentObject/ObservedObject subscriber (NowPlayingView, // MiniPlayerBar, MyMusicView etc.) to re-evaluate their entire bodies at // that rate. Progress bars use TimelineView(.periodic) instead, which reads // these values directly on each tick without triggering objectWillChange. // Both are written and read exclusively on the main queue — no race risk. var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 @Published var volume: Float = 0.8 @Published var repeatMode: RepeatMode = .off @Published var shuffleEnabled = false // audioLevels is NOT @Published — avoids SwiftUI state thrashing. // The Canvas reads currentLevels() directly on every TimelineView tick. // levelTick is kept as an internal counter but intentionally NOT @Published — // making it @Published caused 30fps objectWillChange on AudioPlayer, forcing // every observing view (NowPlayingView, MainTabView, etc.) to re-evaluate 30x/sec. private(set) var levelTick: Int = 0 nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30) func currentLevels() -> [Float] { _audioLevels } private func setLevels(_ levels: [Float]) { _audioLevels = levels levelTick &+= 1 } @Published var isUsingRealFFT = false @Published var isUsingOfflineVis = false @Published var offlineVisProgress: Float = 0 @Published var sourcePlaylistId: String? @Published var sourcePlaylistName: String? enum RepeatMode { case off, all, one } // MARK: - AVPlayer (for streaming) private var player: AVPlayer? private var playerItem: AVPlayerItem? /// Exposed for ShazamRecognizer tap installation var currentPlayerItem: AVPlayerItem? { playerItem } private var timeObserver: Any? // MARK: - AVAudioEngine (for local files with FFT) private var audioEngine: AVAudioEngine? private var enginePlayerNode: AVAudioPlayerNode? private var engineTimeTimer: Timer? private var engineFile: AVAudioFile? private var engineStartFrame: AVAudioFramePosition = 0 private var scheduleGeneration: Int = 0 private var nowPlayingSyncTimer: Timer? #if os(iOS) /// When true, SmartCrossfadeManager owns playback instead of the local player/engine private var isUsingCrossfade = false #endif // MARK: - FFT (radix-2, vDSP_fft_zrip) private var fftSetup: FFTSetup? // Written from audio render thread, read from Timer on main thread. // No lock needed: stale FFT frame is imperceptible at 30fps. nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512) // Pre-allocated FFT scratch buffers — allocated once in init, reused every render callback. // Heap allocation from a real-time audio thread is forbidden (locks inside malloc can // block indefinitely and cause the hardware deadline to be missed → audio glitch). private let fftSize: vDSP_Length = 1024 nonisolated(unsafe) private var fftWindow: [Float] // Hann window coefficients — constant nonisolated(unsafe) private var fftWindowed: [Float] // windowed input scratch nonisolated(unsafe) private var fftRealp: [Float] // split-complex real part nonisolated(unsafe) private var fftImagp: [Float] // split-complex imag part nonisolated(unsafe) private var fftMagnitudes: [Float] // output magnitudes // MARK: - Level Simulation (for streams) private var levelTimer: Timer? private var targetLevels: [Float] = Array(repeating: 0, count: 30) private var internalLevels: [Float] = Array(repeating: 0, count: 30) private var publishCounter: Int = 0 private var lastTargetPhase: Int = 0 // MARK: - Offline Visualizer #if os(iOS) private var offlineVisBuffer = VisFrameBuffer.empty private var offlineVisTimer: Timer? private var offlineVisFPS: Double = 30.0 private var analysisTask: Task? #endif // MARK: - Now Playing Artwork private var cachedArtwork: MPMediaItemArtwork? private var cachedArtworkCoverArtId: String? private var cancellables = Set() // MARK: - Init private override init() { // Radix-2 FFT setup for 1024 samples (log2(1024) = 10) fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2)) // Pre-allocate all FFT scratch buffers and compute the constant Hann window. // These are reused on every render callback — zero heap allocation at render time. let halfSize = Int(1024 / 2) fftWindow = [Float](repeating: 0, count: 1024) fftWindowed = [Float](repeating: 0, count: 1024) fftRealp = [Float](repeating: 0, count: halfSize) fftImagp = [Float](repeating: 0, count: halfSize) fftMagnitudes = [Float](repeating: 0, count: halfSize) vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM)) super.init() // Audio session is configured lazily on first play — not here. // Setting .playback category on init interrupts whatever audio is // already playing (podcasts, other music apps). setupRemoteControls() #if os(iOS) // Refresh lock screen artwork when custom cover changes AlbumCoverStore.shared.$updateTrigger .sink { [weak self] _ in guard let self, let id = self.currentSong?.coverArt else { return } self.cachedArtworkCoverArtId = nil self.fetchAndSetArtwork(coverArtId: id) } .store(in: &cancellables) // Stop all visualizer timers when backgrounded — they serve no purpose without // a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU). NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.suspendVisTimers() } .store(in: &cancellables) // Restart the correct timer when returning to foreground NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.resumeVisTimers() } .store(in: &cancellables) // ── AVAudioSession interruption handling ────────────────────────────── // Required for correct behaviour during phone calls, Siri, alarms, and // other apps taking the audio session. Without this, playback stops but // isPlaying stays true — timers keep firing and music never auto-resumes. NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification) .receive(on: DispatchQueue.main) .sink { [weak self] notification in self?.handleAudioInterruption(notification) } .store(in: &cancellables) // ── Audio route change (headphones unplugged etc.) ──────────────────── // Apple HIG: pause on oldDeviceUnavailable (headphones pulled out). NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) .receive(on: DispatchQueue.main) .sink { [weak self] notification in self?.handleRouteChange(notification) } .store(in: &cancellables) #endif } #if os(iOS) private func handleAudioInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: // System took the session (phone call, Siri, alarm). // Mark as not playing — timers/observers were already suspended // by didEnterBackground if the app backgrounded; this handles the // foreground case (e.g. incoming call while app is visible). if isPlaying { pause() alog("Interruption began — paused playback") } case .ended: // Session returned to us. Resume only if the system says we should. if let optsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt { let opts = AVAudioSession.InterruptionOptions(rawValue: optsValue) if opts.contains(.shouldResume) { // Re-activate the session before resuming — it was deactivated // by the system during the interruption. do { try AVAudioSession.sharedInstance().setActive(true) resume() alog("Interruption ended — resumed playback") } catch { alog("Interruption ended — session reactivation failed: \(error)") } } else { alog("Interruption ended — shouldResume not set, staying paused") } } @unknown default: break } } private func handleRouteChange(_ notification: Notification) { guard let info = notification.userInfo, let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } // Pause when headphones are unplugged — matches system Music app behaviour. if reason == .oldDeviceUnavailable, isPlaying { pause() alog("Route change: output removed — paused playback") } } private func suspendVisTimers() { stopOfflineVisTimer() stopLevelTimer() nowPlayingSyncTimer?.invalidate() nowPlayingSyncTimer = nil analysisTask?.cancel() analysisTask = nil AudioPreFetcher.shared.cancelAll() removeTimeObserver() #if os(iOS) // Remove audio tap on background — no point running FFT when no one can see it if isRadioStream { AudioTapProcessor.shared.removeTap(from: playerItem) } #endif // SmartCrossfadeManager has its own 10Hz time observer that writes // @Published currentTime/duration — same SwiftUI churn as AudioPlayer's if isUsingCrossfade { SmartCrossfadeManager.shared.suspendForBackground() } alog("Background: all timers + observers suspended") } private func resumeVisTimers() { // Pick up any widget commands that arrived while the process was suspended. // Darwin notifications can't wake suspended processes, so commands written // by widget intents sit in App Group UserDefaults until we check here. WidgetBridge.shared.processAnyPendingCommand() // Sync currentTime/duration from the live AVPlayer state. // While backgrounded, the time observer was removed (suspendVisTimers) // but AVPlayer kept advancing. Without this sync, the vis timer's first // frames read the stale position from when the app backgrounded, causing // the waveform to render from the wrong song position then snap-correct // once the reinstalled time observer fires (~0.1s later). if let p = player, p.currentTime().isNumeric { currentTime = p.currentTime().seconds } if let dur = playerItem?.duration.seconds, dur.isFinite, dur > 0 { duration = dur } // Always reinstall time observers — they were removed on background regardless // of play state. Without this, currentTime is frozen after background+pause+play. reinstallTimeObserver() if isUsingCrossfade { SmartCrossfadeManager.shared.resumeFromBackground() } // Only restart vis timers + reclaim audio session if actually playing. // If nothing is playing, don't touch the session — let podcasts/other apps keep it. guard isPlaying else { return } // Re-activate the audio session — another app may have taken it while we // were in the background (e.g. a game or Spotify). Without this, player.play() // silently fails and isPlaying shows true with no audio output. do { try AVAudioSession.sharedInstance().setActive(true) } catch { alog("Foreground: session reactivation failed: \(error)") } // Restart Now Playing sync timer — keeps Lock Screen / Dynamic Island // seek bar accurate. Created in playWithAVPlayer but never restarted // after background cycle; without this the system seek bar drifts. if nowPlayingSyncTimer == nil { nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { return } self.updateNowPlayingInfo() } } if isUsingOfflineVis { startOfflineVisSync() } else if isRadioStream { #if os(iOS) // Reinstall the audio tap that was removed on background installAudioTapIfNeeded(on: playerItem) #endif } else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis { startLevelSimulation() } // When realAudioAnalysis is on but vis data hasn't loaded yet: stay at 0. alog("Foreground: session reactivated, observers + vis timers resumed (crossfade=\(isUsingCrossfade) offlineVis=\(isUsingOfflineVis))") } private func removeTimeObserver() { if let observer = timeObserver { player?.removeTimeObserver(observer) timeObserver = nil } } private func reinstallTimeObserver() { guard player != nil, timeObserver == nil else { return } let interval = CMTime(seconds: 0.1, preferredTimescale: 600) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration { self.duration = dur } } } #endif // MARK: - Audio Session private func alog(_ msg: String) { #if os(iOS) DebugLogger.shared.log(msg, category: "Audio") #else print("[Audio] \(msg)") #endif } private func configureAudioSession() { #if os(iOS) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, options: []) } catch { alog("Audio session category failed: \(error)") } #elseif os(watchOS) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, policy: .longFormAudio) } catch { alog("watchOS audio session category failed: \(error)") } #endif } private func activateAudioSession() { do { try AVAudioSession.sharedInstance().setActive(true) } catch { alog("Audio session activation failed: \(error)") } } // MARK: - Playback Controls /// Jump to a song already in the queue by index without replacing the queue. /// Used by QueueView tap — keeps the existing queue intact. func play(song: Song, at index: Int) { queueIndex = index play(song: song) } func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) { if let q = fromQueue { queue = shuffleEnabled ? q.shuffled() : q queueIndex = index sourcePlaylistId = playlistId sourcePlaylistName = playlistName // Prefetch Smart DJ profiles for upcoming tracks #if os(iOS) if CompanionSettings.shared.smartDJEnabled { let upcoming = Array(queue.dropFirst(index).prefix(10)) Task { await CompanionAPIService.shared.prefetchProfiles(for: upcoming) } } #endif } currentSong = song // Persist queue state so it survives app termination (AUDIT-055). // Position is saved as 0 here; the time observer updates it every 0.1s. #if os(iOS) PlaybackStateStore.shared.save( queue: queue, index: queueIndex, currentTime: 0, currentSongId: song.id ) #endif alog("Playing: \(song.title) by \(song.artist ?? "Unknown")") // Stop radio buffer if switching from radio to music if isRadioStream { isRadioStream = false radioStreamURL = nil #if os(iOS) RadioStreamBuffer.shared.stopBuffering() AudioTapProcessor.shared.removeTap(from: playerItem) isPlayingFromBuffer = false #endif } #if os(iOS) // Smart DJ Crossfade path — dual-AVPlayer engine handles everything let crossfade = SmartCrossfadeManager.shared if crossfade.isEnabled && CompanionSettings.shared.smartDJEnabled && !isRadioStream { if let url = crossfade.resolveURL(for: song) { stopAll() isUsingCrossfade = true // Claim audio session — deferred from init() to first play configureAudioSession() activateAudioSession() // Wire callbacks crossfade.timeUpdate = { [weak self] time, dur in self?.currentTime = time self?.duration = dur } crossfade.needsNextTrack = { [weak self] in self?.prepareNextForCrossfade() } // At crossfade midpoint, start loading the incoming song's vis frames // so they're ready (or nearly ready) by the time finalizeCrossfade fires. crossfade.visualizerHandoff = { [weak self] _ in guard let self = self, VisualizerSettings.shared.realAudioAnalysis, let nextSong = SmartCrossfadeManager.shared.pendingNextSong, let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong) else { return } // Clear stale vis from outgoing song before loading new self.offlineVisBuffer = VisFrameBuffer.empty self.setLevels(Array(repeating: 0, count: self._audioLevels.count)) self.loadOfflineVisualizer(songId: nextSong.id, url: url) } // At crossfade midpoint, transition metadata/artwork/colors/widget // so the UI reflects the incoming song while audio is still fading. crossfade.songHandoff = { [weak self] incomingSong in guard let self = self else { return } // Scrobble the outgoing song before updating currentSong if let outgoing = self.currentSong { Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) } } self.currentSong = incomingSong if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) { self.queueIndex = idx } self.updateNowPlayingInfo() self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt) self.pushWidgetState() LyricsManager.shared.songChanged( songId: incomingSong.id, artist: incomingSong.artist ?? "Unknown", title: incomingSong.title, path: incomingSong.path, duration: self.duration ) } crossfade.playSong(song, url: url) isPlaying = true // Set AudioPlayer.player to the active crossfade player // so Lock Screen controls and visualizer still work. // Remove any stale time observer first — it was registered on // the old player and would crash if removed from the new one. if let obs = timeObserver { player?.removeTimeObserver(obs) timeObserver = nil } player = crossfade.activePlayer // Safety: if boundary observer doesn't fire (short track, bad profile), // this catches the natural end and advances normally NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: crossfade.activePlayer.currentItem ) updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: song.coverArt) if !VisualizerSettings.shared.realAudioAnalysis { startLevelSimulation() } // Load offline visualizer for crossfade path too if VisualizerSettings.shared.realAudioAnalysis { loadOfflineVisualizer(songId: song.id, url: url) } // Prepare next track in standby prepareNextForCrossfade() pushWidgetState() return } } isUsingCrossfade = false #endif // Check for offline version first — use Song overload which falls back to // title/artist/duration matching if the ID changed after a Companion restructure if let localURL = OfflineManager.shared.localURL(for: song) { #if os(iOS) if VisualizerSettings.shared.realAudioAnalysis { // Use AVPlayer for reliable playback + offline vis for the visualizer playWithAVPlayer(localURL) loadOfflineVisualizer(songId: song.id, url: localURL) } else { playWithAVPlayer(localURL) } #else playWithAVPlayer(localURL) #endif } else { #if os(iOS) // Check prefetch cache — already downloaded in background if let prefetchURL = AudioPreFetcher.shared.prefetchedURL(for: song.id, suffix: song.suffix) { alog("Playing from prefetch cache") playWithAVPlayer(prefetchURL) if VisualizerSettings.shared.realAudioAnalysis { loadOfflineVisualizer(songId: song.id, url: prefetchURL) } } else { let quality = StreamingQuality.shared let streamURL = ServerManager.shared.client.streamURL( songId: song.id, format: quality.format, maxBitRate: quality.maxBitRate ) if let url = streamURL { playWithAVPlayer(url) } } #else let streamURL = ServerManager.shared.client.streamURL( songId: song.id, format: "mp3", maxBitRate: 192 ) if let url = streamURL { playWithAVPlayer(url) } #endif } // Scrobble Task { try? await ServerManager.shared.client.scrobble(id: song.id, submission: false) } // Pre-fetch next tracks for instant playback #if os(iOS) AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex) pushWidgetState() #endif } /// Play a radio stream from a direct URL. /// Automatically starts the stream buffer so timeshift is available immediately. /// Destroys any existing buffer first to prevent audio bleed between stations. func playRadio(song: Song, streamURL: URL) { alog("Radio: \(song.title) → \(streamURL.absoluteString)") currentSong = song queue = [song] queueIndex = 0 isRadioStream = true radioStreamURL = streamURL #if os(iOS) // Always destroy the previous buffer cleanly before starting a new station. // Prevents stale bytes / state from the old station bleeding into the new one. RadioStreamBuffer.shared.stopBuffering() if RadioStreamBuffer.isPlaylistURL(streamURL) { alog("Radio: resolving playlist URL...") Task { let resolved = await RadioStreamBuffer.resolveStreamURL(streamURL) await MainActor.run { self.radioStreamURL = resolved self.playWithAVPlayer(resolved) // Auto-start buffer immediately after resolution (non-HLS only) // isHLSStream is set inside startBuffering if the URL is M3U8 RadioStreamBuffer.shared.startBuffering( url: resolved, stationName: song.title ) } } } else { playWithAVPlayer(streamURL) // Auto-start buffer — will mark isHLSStream=true and bail if it's HLS RadioStreamBuffer.shared.startBuffering(url: streamURL, stationName: song.title) } #else playWithAVPlayer(streamURL) #endif } /// Whether current playback is a radio stream (for buffer/scrub behavior) @Published var isRadioStream = false private(set) var radioStreamURL: URL? #if os(iOS) /// Toggle recording the current radio stream on/off. /// No-op for HLS streams or when not buffering. func toggleRecording() { let buf = RadioStreamBuffer.shared if buf.isRecording { _ = buf.stopRecording() } else { buf.startRecording() } } #endif #if os(iOS) /// When true, AVPlayer is playing a snapshot file rather than the live stream @Published private(set) var isPlayingFromBuffer = false /// Current playback position within the buffer timeline (0 = start of buffer, bufferSec = live edge) @Published var radioBufferPlayheadSec: TimeInterval = 0 private var currentSnapshotURL: URL? private var snapshotStatusObservation: NSKeyValueObservation? private var snapshotEndObserver: Any? func radioSeekBack(to positionInBuffer: TimeInterval) { guard isRadioStream else { return } let buffer = RadioStreamBuffer.shared guard let srcURL = buffer.bufferPlaybackURL else { alog("Radio seek: no buffer file yet") return } buffer.isLive = false let seekTime = max(0.1, positionInBuffer) let snapshotBufSec = buffer.estimatedBufferSeconds // CRITICAL: use the correct audio extension (not .raw) so AVPlayer detects the format. // .raw causes AVPlayer to fail format detection → status never becomes readyToPlay. let ext = buffer.guessExtension() let snapURL = FileManager.default.temporaryDirectory .appendingPathComponent("radio_snap_\(UUID().uuidString).\(ext)") // Clean up any previous snapshot resources snapshotStatusObservation?.invalidate() snapshotStatusObservation = nil if let obs = snapshotEndObserver { NotificationCenter.default.removeObserver(obs) snapshotEndObserver = nil } if let old = currentSnapshotURL { try? FileManager.default.removeItem(at: old) currentSnapshotURL = nil } do { try FileManager.default.copyItem(at: srcURL, to: snapURL) } catch { alog("Radio seek: snapshot copy failed — \(error.localizedDescription)") return } currentSnapshotURL = snapURL isPlayingFromBuffer = true radioBufferPlayheadSec = seekTime let asset = AVURLAsset(url: snapURL) let item = AVPlayerItem(asset: asset) playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this // Auto-return to live when snapshot playback reaches the end snapshotEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in self?.alog("Radio: snapshot reached end — returning to live") self?.radioGoLive() } player?.replaceCurrentItem(with: item) #if os(iOS) installAudioTapIfNeeded(on: item) #endif // KVO on status — more reliable than polling; fires immediately on failure too. // IMPORTANT: NSKeyValueObservation fires on whatever thread changes the property. // AVPlayerItem.status is changed on an internal AVFoundation thread, NOT main. // All state access must be dispatched to main to prevent data races. snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in DispatchQueue.main.async { guard let self else { return } switch observedItem.status { case .readyToPlay: self.snapshotStatusObservation?.invalidate() self.snapshotStatusObservation = nil // Generous tolerance for raw audio without index tables let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) let tol = CMTime(seconds: 1, preferredTimescale: 600) self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in self?.player?.play() self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live") } case .failed: self.snapshotStatusObservation?.invalidate() self.snapshotStatusObservation = nil self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")") try? FileManager.default.removeItem(at: snapURL) self.currentSnapshotURL = nil self.radioGoLive() // fall back to live default: break } } } } /// Return to the live radio stream, discarding any snapshot func radioGoLive() { guard isRadioStream, let liveURL = radioStreamURL else { return } RadioStreamBuffer.shared.isLive = true isPlayingFromBuffer = false radioBufferPlayheadSec = 0 snapshotStatusObservation?.invalidate() snapshotStatusObservation = nil if let obs = snapshotEndObserver { NotificationCenter.default.removeObserver(obs) snapshotEndObserver = nil } if let old = currentSnapshotURL { try? FileManager.default.removeItem(at: old) currentSnapshotURL = nil } let asset = AVURLAsset(url: liveURL) let item = AVPlayerItem(asset: asset) playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this player?.replaceCurrentItem(with: item) player?.play() #if os(iOS) // Start simulation immediately — tap installs when stream reaches readyToPlay startRadioSimulation() let liveItem = item Task { @MainActor [weak self] in for await status in liveItem.publisher(for: \.status).values { guard let self, self.playerItem === liveItem else { break } if status == .readyToPlay { self.installAudioTapIfNeeded(on: liveItem) break } if status == .failed { break } } } #endif alog("Radio: ▶ live") } /// Skip ±seconds within the buffer. Negative = rewind. Snaps to live at edge. func radioSkip(by seconds: TimeInterval) { guard isRadioStream else { return } let buffer = RadioStreamBuffer.shared let bufferSec = buffer.estimatedBufferSeconds guard bufferSec > 1 else { alog("Radio skip: not enough buffer (\(String(format: "%.1f", bufferSec))s)") return } // Current position in buffer timeline let currentPos: TimeInterval if isPlayingFromBuffer { let t = player?.currentTime() currentPos = (t?.isNumeric == true) ? t!.seconds : radioBufferPlayheadSec } else { currentPos = bufferSec // at live edge } let newPos = currentPos + seconds alog("Radio skip \(seconds > 0 ? "+" : "")\(Int(seconds))s: \(String(format: "%.1f", currentPos))s → \(String(format: "%.1f", newPos))s (buf \(String(format: "%.1f", bufferSec))s)") if newPos >= bufferSec - 0.5 { radioGoLive() } else { radioSeekBack(to: max(0.1, newPos)) } } #endif #if os(iOS) /// Set now playing artwork from a UIImage (for radio station custom covers) func setNowPlayingArtwork(image: UIImage) { let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } cachedArtwork = artwork cachedArtworkCoverArtId = currentSong?.id updateNowPlayingInfo() } #endif // MARK: - AVPlayer Path (streams + fallback) private func playWithAVPlayer(_ url: URL) { #if os(iOS) let isStream = url.scheme == "http" || url.scheme == "https" let display = isStream ? "\(url.scheme ?? "")://\(url.host ?? "")...\(url.lastPathComponent)" : url.lastPathComponent DebugLogger.shared.log("▶ \(display)", category: "Audio") #endif alog("AVPlayer path: \(url.scheme ?? "file")://...\(url.lastPathComponent)") stopAll() // Reset audio session to pure playback (fixes radio→music conflict) do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) try AVAudioSession.sharedInstance().setActive(true) } catch { alog("Audio session reset failed: \(error)") } isUsingRealFFT = false let asset = AVURLAsset(url: url) playerItem = AVPlayerItem(asset: asset) // Observe item status — catches stream 404s, auth failures, and codec // errors that AVPlayer swallows silently otherwise #if os(iOS) let itemToObserve = playerItem! let isRadio = isRadioStream Task { @MainActor [weak self] in for await status in itemToObserve.publisher(for: \.status).values { guard let self = self, self.playerItem === itemToObserve else { break } switch status { case .failed: let err = itemToObserve.error?.localizedDescription ?? "unknown" let urlStr = url.absoluteString.contains("stream") ? url.absoluteString : url.lastPathComponent DebugLogger.shared.log("✗ Playback failed: \(err) | \(urlStr)", category: "Audio", level: .error) case .readyToPlay: DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio") // Install audio tap AFTER the stream has connected and the asset // has parsed its container. Before readyToPlay, loadTracks returns // empty for live streams → tap silently fails. if isRadio { self.installAudioTapIfNeeded(on: itemToObserve) } default: break } if status == .failed { break } } } #endif if player == nil { player = AVPlayer(playerItem: playerItem) } else { player?.replaceCurrentItem(with: playerItem) } player?.volume = volume player?.play() isPlaying = true // Time observer let interval = CMTime(seconds: 0.1, preferredTimescale: 600) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds // Guard duration write: @Published fires objectWillChange on every // assignment even if the value is identical. For a 4-min song this // would fire an extra objectWillChange 10x/sec for the entire track. if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration { self.duration = dur } // Throttle position saves to once per 5 seconds. // Only writes a single Double — queue shape is saved separately // in play(song:) and notifyQueueChanged(). if Int(time.seconds * 10) % 50 == 0 { PlaybackStateStore.shared.savePosition(time.seconds) #if os(iOS) WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying) #endif } #if os(iOS) // Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval) if Int(time.seconds * 10) % 5 == 0 { LyricsManager.shared.syncToTime(time.seconds) } #endif } // Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync) nowPlayingSyncTimer?.invalidate() nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { return } self.updateNowPlayingInfo() } NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem) updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: currentSong?.coverArt) // Radio streams: start simulation immediately for visual feedback while // the stream connects. The readyToPlay status observer (above) will install // the real FFT audio tap once the asset has loaded its tracks. #if os(iOS) if isRadioStream { startRadioSimulation() } else if !VisualizerSettings.shared.realAudioAnalysis { // Simulation mode only — real analysis waits for offline vis data startLevelSimulation() } // When realAudioAnalysis is on: levels stay at 0 until // loadOfflineVisualizer → startOfflineVisSync fills them. #else startLevelSimulation() #endif } // MARK: - AVAudioEngine Path (local files with real FFT) private func playLocalWithEngine(_ url: URL) { #if os(iOS) alog("Engine path: \(url.lastPathComponent)") configureAudioSession() activateAudioSession() stopAll() isUsingRealFFT = true do { let file = try AVAudioFile(forReading: url) engineFile = file let engine = AVAudioEngine() let playerNode = AVAudioPlayerNode() engine.attach(playerNode) engine.connect(playerNode, to: engine.mainMixerNode, format: file.processingFormat) // Install FFT tap on the mixer output let format = engine.mainMixerNode.outputFormat(forBus: 0) engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in self?.processFFT(buffer: buffer) } try engine.start() // Schedule file and play scheduleGeneration += 1 let gen = scheduleGeneration playerNode.scheduleFile(file, at: nil) { [weak self] in DispatchQueue.main.async { guard let self = self, self.scheduleGeneration == gen else { return } self.playerDidFinishEngine() } } playerNode.play() self.audioEngine = engine self.enginePlayerNode = playerNode self.engineStartFrame = 0 isPlaying = true // Duration from file duration = Double(file.length) / file.processingFormat.sampleRate // Time tracking for engine (accounts for seek offset) engineTimeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in guard let self = self, let node = self.enginePlayerNode, let file = self.engineFile, let nodeTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime: nodeTime) else { return } let sampleRate = file.processingFormat.sampleRate let offsetSeconds = Double(self.engineStartFrame) / sampleRate self.currentTime = offsetSeconds + Double(playerTime.sampleTime) / playerTime.sampleRate } // Publish FFT levels at ~30fps (smoothing already applied in processFFT) levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self = self else { return } let levels = self.rawFFTLevels self.setLevels(levels) } updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: currentSong?.coverArt) } catch { alog("Engine failed: \(error.localizedDescription), falling back to AVPlayer") playWithAVPlayer(url) } #endif } // MARK: - FFT Processing #if os(iOS) private func processFFT(buffer: AVAudioPCMBuffer) { guard let fftSetup = fftSetup else { return } guard let channelData = buffer.floatChannelData?[0] else { return } let frameCount = buffer.frameLength let log2n: vDSP_Length = 10 guard frameCount >= fftSize else { return } let halfSize = Int(fftSize / 2) // 1. Apply pre-computed Hann window — no allocation, mutates pre-allocated scratch buffer vDSP_vmul(channelData, 1, fftWindow, 1, &fftWindowed, 1, fftSize) // 2. FFT using pre-allocated split-complex buffers fftRealp.withUnsafeMutableBufferPointer { realpBuf in fftImagp.withUnsafeMutableBufferPointer { imagpBuf in var splitComplex = DSPSplitComplex( realp: realpBuf.baseAddress!, imagp: imagpBuf.baseAddress! ) fftWindowed.withUnsafeBytes { rawBuffer in let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress! vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize)) } vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD)) vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize)) } } // 3. Normalize by N² and take sqrt for amplitude — in-place on pre-allocated buffer let fftSizeF = Float(fftSize) var scale: Float = 1.0 / (fftSizeF * fftSizeF) vDSP_vsmul(fftMagnitudes, 1, &scale, &fftMagnitudes, 1, vDSP_Length(halfSize)) for i in 0.. 1 { seek(to: savedPosition) } return } #if os(iOS) if isUsingCrossfade { SmartCrossfadeManager.shared.resume() isPlaying = true // offlineVisTimer was stopped on pause — restart it so currentLevels() advances if isUsingOfflineVis { startOfflineVisSync() } updateNowPlayingInfo() pushWidgetState() return } #endif if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode { node.play() } else { player?.play() } isPlaying = true #if os(iOS) // Prime levels immediately before any timer fires. // The CADisplayLink draws at 60fps; the level timers fire at 30fps. // Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels // (zeroed by pause) and the wave starts flat then slowly ramps up. if isUsingOfflineVis { DebugLogger.shared.log("resume: offlineVis path — startOfflineVisSync", category: "VisDebug") // Offline vis: prime with the last-rendered frame at current position if !offlineVisBuffer.isEmpty { let dur = duration let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0 let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1) offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels) levelTick &+= 1 } startOfflineVisSync() } else if isUsingRealFFT, !isUsingOfflineVis { DebugLogger.shared.log("resume: realFFT path — rebuilding levelTimer", category: "VisDebug") // Engine FFT path: rawFFTLevels still holds pre-pause data — push it immediately setLevels(rawFFTLevels) stopLevelTimer() levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self = self else { return } self.setLevels(self.rawFFTLevels) } } else if !VisualizerSettings.shared.realAudioAnalysis { DebugLogger.shared.log("resume: simulation path — levelTimer nil=\(levelTimer == nil)", category: "VisDebug") // Simulation path: seed internalLevels from a fresh random target // so the wave has height immediately rather than starting from floor. lastTargetPhase = -1 targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) } internalLevels = targetLevels.map { $0 * 0.8 } setLevels(internalLevels) if levelTimer == nil { startLevelSimulation() } } // else: realAudioAnalysis on, vis data loading — stay at 0 pushWidgetState() #endif updateNowPlayingInfo() } func next() { guard !queue.isEmpty else { return } if repeatMode == .all { queueIndex = (queueIndex + 1) % queue.count } else { queueIndex = min(queueIndex + 1, queue.count - 1) } play(song: queue[queueIndex]) } func previous() { if currentTime > 3 { seek(to: 0) } else { queueIndex = max(queueIndex - 1, 0) play(song: queue[queueIndex]) } } #if os(iOS) /// Loads the next queue track into the crossfade standby player. /// If no next track exists, SmartCrossfadeManager will self-crossfade. private func prepareNextForCrossfade() { let crossfade = SmartCrossfadeManager.shared guard crossfade.isEnabled, !queue.isEmpty else { return } // Wire the callback — called by finalizeCrossfade after every successful crossfade crossfade.needsNextTrack = { [weak self] in guard let self = self else { return } // Scrobble + currentSong + artwork already handled by songHandoff at midpoint. // Here we only handle post-swap housekeeping. // Ensure queueIndex is synced (songHandoff may have set it, but verify) if let nowPlaying = crossfade.pendingNextSong, let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) { self.queueIndex = idx // Only update currentSong if songHandoff somehow didn't fire if self.currentSong?.id != nowPlaying.id { self.currentSong = nowPlaying self.updateNowPlayingInfo() self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt) self.pushWidgetState() } } else { // Fallback: queue was cleared or song removed — advance sequentially let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count if hasNext { if self.repeatMode == .all { self.queueIndex = (self.queueIndex + 1) % self.queue.count } else { self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1) } self.currentSong = self.queue[self.queueIndex] self.updateNowPlayingInfo() self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt) self.pushWidgetState() } } // Persist updated queue position PlaybackStateStore.shared.save( queue: self.queue, index: self.queueIndex, currentTime: 0, currentSongId: self.currentSong?.id ) // Swap AudioPlayer.player to the new active crossfade player. // Remove stale time observer first — it was registered on the old // player instance and removing it from the new one throws NSException. if let obs = self.timeObserver { self.player?.removeTimeObserver(obs) self.timeObserver = nil } self.player = crossfade.activePlayer // Re-register the safety-net AVPlayerItemDidPlayToEndTime observer NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) if let newItem = crossfade.activePlayer.currentItem { NotificationCenter.default.addObserver( self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: newItem ) } // Prepare next-next (picks up any queue changes that happened mid-fade) self.prepareNextForCrossfade() } // Determine what to pre-load in standby let nextIdx: Int if repeatMode == .all { nextIdx = (queueIndex + 1) % queue.count } else { nextIdx = queueIndex + 1 guard nextIdx < queue.count else { // No next track — SmartCrossfadeManager will self-crossfade alog("SmartDJ: no next track — will self-crossfade") return } } let nextSong = queue[nextIdx] // Guard: don't load the SAME song into the standby player. // With repeat-all + single-song queue, nextIdx wraps to 0 (same song). // Loading it into standby creates two AVPlayers with identical content — // if the crossfade boundary fires, both play simultaneously and drift // out of sync, producing the "duplicate audio" artifact. if nextSong.id == currentSong?.id { alog("SmartDJ: next is same song — skipping standby prep (repeat-one will handle)") return } guard let url = crossfade.resolveURL(for: nextSong) else { return } alog("SmartDJ: preparing \(nextSong.title) in standby") crossfade.prepareNext(nextSong, url: url) } #endif func seek(to time: TimeInterval) { #if os(iOS) if isUsingCrossfade { SmartCrossfadeManager.shared.seek(to: time) currentTime = time updateNowPlayingInfo() return } #endif let wasPaused = !isPlaying if let node = enginePlayerNode, let file = engineFile, let engine = audioEngine { let sampleRate = file.processingFormat.sampleRate let startFrame = AVAudioFramePosition(time * sampleRate) let totalFrames = file.length guard startFrame >= 0, startFrame < totalFrames else { return } let remainingFrames = AVAudioFrameCount(totalFrames - startFrame) scheduleGeneration += 1 let gen = scheduleGeneration node.stop() node.scheduleSegment(file, startingFrame: startFrame, frameCount: remainingFrames, at: nil) { [weak self] in DispatchQueue.main.async { guard let self = self, self.scheduleGeneration == gen else { return } self.playerDidFinishEngine() } } if engine.isRunning { node.play() // If we were paused, immediately pause again (we only played to set position) if wasPaused { node.pause() } } currentTime = time engineStartFrame = startFrame updateNowPlayingInfo() } else { currentTime = time // Update immediately for responsive UI let cmTime = CMTime(seconds: time, preferredTimescale: 600) player?.seek(to: cmTime) { [weak self] finished in guard let self = self, finished else { return } if wasPaused { self.player?.pause() } self.updateNowPlayingInfo() } } } func seekToPercent(_ pct: Double) { seek(to: duration * pct) } func toggleShuffle() { shuffleEnabled.toggle() if !queue.isEmpty { if shuffleEnabled { // Save current song, shuffle the rest let current = queue[queueIndex] var rest = queue rest.remove(at: queueIndex) rest.shuffle() queue = [current] + rest queueIndex = 0 } else { // Unshuffle — restore original order would need storing it. // For now, keep current order but ensure current song stays in place. } // Persist shuffled queue shape — time observer only saves position now #if os(iOS) PlaybackStateStore.shared.save( queue: queue, index: queueIndex, currentTime: currentTime, currentSongId: currentSong?.id ) #endif } } func cycleRepeat() { switch repeatMode { case .off: repeatMode = .all case .all: repeatMode = .one case .one: repeatMode = .off } } func setVolume(_ vol: Float) { volume = vol player?.volume = vol } // MARK: - Queue Management func playNext(_ song: Song) { let insertAt = queueIndex + 1 if insertAt <= queue.count { queue.insert(song, at: insertAt) } else { queue.append(song) } notifyQueueChanged() } func playLater(_ song: Song) { queue.append(song) notifyQueueChanged() } func playNow(_ song: Song) { queue.insert(song, at: queueIndex + 1) queueIndex += 1 play(song: song) } /// Move a song in the queue (for drag reorder) func moveQueueItem(from source: IndexSet, to destination: Int) { let oldIndex = queueIndex let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil queue.move(fromOffsets: source, toOffset: destination) if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) { queueIndex = newIdx } notifyQueueChanged() } /// Remove a song from the queue func removeFromQueue(at offsets: IndexSet) { let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil queue.remove(atOffsets: offsets) if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) { queueIndex = newIdx } else { queueIndex = min(queueIndex, max(queue.count - 1, 0)) } notifyQueueChanged() } /// Re-prepare crossfade when queue is modified (Play Next / Later / reorder). private func notifyQueueChanged() { #if os(iOS) // Persist queue shape immediately so Play Next / Later / reorder / remove survives termination PlaybackStateStore.shared.save( queue: queue, index: queueIndex, currentTime: currentTime, currentSongId: currentSong?.id ) let crossfade = SmartCrossfadeManager.shared guard crossfade.isEnabled, !queue.isEmpty else { return } if crossfade.isCrossfading { // Don't replace the standby item mid-fade — mark stale so finalizeCrossfade // calls prepareNextForCrossfade() after the swap completes. crossfade.queueChangedDuringFade = true } else { prepareNextForCrossfade() } AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex) #endif } func playInstantMix(basedOn song: Song) { Task { let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50) if let songs = similar, !songs.isEmpty { await MainActor.run { self.play(song: songs[0], fromQueue: songs) } } } } @objc private func playerDidFinish() { // When crossfade is active, needsNextTrack() already scrobbled the outgoing song. // playerDidFinish fires as a safety-net fallback (boundary observer miss) — skip // scrobble here to avoid double-counting. #if os(iOS) if !isUsingCrossfade, let song = currentSong { Task { try? await ServerManager.shared.client.scrobble(id: song.id) } } #else if let song = currentSong { Task { try? await ServerManager.shared.client.scrobble(id: song.id) } } #endif if repeatMode == .one { seek(to: 0) player?.play() } else { next() } } // MARK: - Now Playing Info Center private func updateNowPlayingInfo() { #if os(iOS) var info: [String: Any] = [:] info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown" info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown" info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? "" info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime info[MPMediaItemPropertyPlaybackDuration] = duration info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 if let artwork = cachedArtwork { info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info // Push state to Apple Watch — fires on song change, play/pause, and every 5s WatchConnectivityManager.shared.sendNowPlayingToWatch( song: currentSong, isPlaying: isPlaying, currentTime: currentTime, duration: duration ) #endif } private func fetchAndSetArtwork(coverArtId: String?) { #if os(iOS) guard let id = coverArtId else { return } if id == cachedArtworkCoverArtId, cachedArtwork != nil { return } // Check user-set custom cover first if let customImage = AlbumCoverStore.shared.loadCover(for: id) { let artwork = MPMediaItemArtwork(boundsSize: customImage.size) { _ in customImage } self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() self.pushWidgetState() return } guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) else { return } if let cached = ImageCache.shared.cachedImage(for: url) { let artwork = MPMediaItemArtwork(boundsSize: cached.size) { _ in cached } self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() self.pushWidgetState() return } Task { do { let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { return } ImageCache.shared.store(image, for: url) let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } await MainActor.run { self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() self.pushWidgetState() } } catch { // Log the failure so it's visible in the debug console. // Common cause: Tailscale reconnecting after network switch. alog("Artwork fetch failed for \(id): \(error.localizedDescription)") // Clear the cached ID so the next song play retries this artwork. await MainActor.run { self.cachedArtworkCoverArtId = nil } } } #endif } private func setupRemoteControls() { #if os(iOS) || os(watchOS) let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in self?.resume(); return .success } center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } center.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success } center.previousTrackCommand.addTarget { [weak self] _ in self?.previous() return .success } center.changePlaybackPositionCommand.addTarget { [weak self] event in guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: posEvent.positionTime) return .success } #endif } // MARK: - Offline Visualizer Pipeline #if os(iOS) /// Load or generate cached visualizer data for a downloaded song private func loadOfflineVisualizer(songId: String, url: URL) { stopOfflineVisTimer() isUsingOfflineVis = false offlineVisProgress = 0 offlineVisFPS = VisualizerSettings.shared.effectiveFPS let points = VisualizerSettings.shared.nowPlaying.numberOfPoints let cutoff = VisualizerSettings.shared.frequencyCutoff analysisTask?.cancel() // Don't start analysis if already in background — it will be triggered // when the app returns to foreground instead (via resumeVisTimers path // or next song load in foreground). #if os(iOS) guard UIApplication.shared.applicationState != .background else { alog("Offline vis: skipping analysis in background for \(songId)") return } #endif analysisTask = Task { let storage = VisualizerStorageManager.shared let hasCache = await storage.hasCache(for: songId) if hasCache { alog("Offline vis: loading cache for \(songId)") if let buffer = try? await storage.loadCache(for: songId) { let normalized = Self.normalizeVisBuffer(buffer) await MainActor.run { // Guard: if user skipped tracks while cache was loading, // don't apply stale vis data from the previous song. guard self.currentSong?.id == songId else { return } self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } } } else { // Try Companion API first (pre-computed server frames) var fetched = false if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path { do { if let serverFrames = try await CompanionAPIService.shared.fetchVisualizerFrames(relativePath: path) { try? await storage.saveCache(frames: serverFrames, for: songId) // Convert [[Float]] to flat buffer then normalize let ppf = serverFrames.first?.count ?? 0 var flat = ContiguousArray() flat.reserveCapacity(serverFrames.count * ppf) for frame in serverFrames { flat.append(contentsOf: frame) } let rawBuf = VisFrameBuffer(data: flat, frameCount: serverFrames.count, pointsPerFrame: ppf) let normalized = Self.normalizeVisBuffer(rawBuf) alog("Offline vis: fetched \(serverFrames.count) frames from server") await MainActor.run { guard self.currentSong?.id == songId else { return } self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } fetched = true } } catch { alog("Offline vis: server fetch failed, will analyze locally") } } if !fetched { alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...") let song = self.currentSong do { // ── Combined pass: vis frames + SmartDJ profile in one read ─ // extractSmartDJ only if Companion is disabled or unavailable AND // SmartDJ crossfade is enabled so we actually need the profile. let needsSmartDJ = CompanionSettings.shared.smartDJEnabled && !CompanionSettings.shared.isEnabled && song?.path != nil && SmartDJCache.shared.get(song?.path ?? "") == nil let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ( url: url, pointsCount: points, fps: offlineVisFPS, cutoff: cutoff, extractSmartDJ: needsSmartDJ ) { [weak self] pct in Task { @MainActor in guard self?.currentSong?.id == songId else { return } self?.offlineVisProgress = pct } } // Seed SmartDJ cache from on-device analysis if we extracted it if needsSmartDJ, let path = song?.path { let profile = SmartDJProfile( bpm: nil, silenceStart: result.silenceStart, silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS ) SmartDJCache.shared.store(profile, for: path) alog("Offline vis: seeded on-device SmartDJ profile for \(path) " + "(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " + "leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " + "trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)") } let frames = result.visFrames try? await storage.saveCache(frames: frames, for: songId) // Convert [[Float]] to flat VisFrameBuffer then normalize let ppf = frames.first?.count ?? 0 var flat = ContiguousArray() flat.reserveCapacity(frames.count * ppf) for frame in frames { flat.append(contentsOf: frame) } let rawBuf = VisFrameBuffer(data: flat, frameCount: frames.count, pointsPerFrame: ppf) let normalized = Self.normalizeVisBuffer(rawBuf) alog("Offline vis: cached \(frames.count) frames for \(songId)") await MainActor.run { guard self.currentSong?.id == songId else { return } self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } } catch { alog("Offline vis: analysis failed: \(error.localizedDescription)") // Don't fall back to simulation — levels stay at 0 } } // end if !fetched } } } /// Normalize vis frames so the wave is always visible regardless of song loudness. /// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping. /// Normalize a VisFrameBuffer to p95 amplitude in-place. /// One sort + one pass — no [[Float]] allocation. private static func normalizeVisBuffer(_ buffer: VisFrameBuffer) -> VisFrameBuffer { guard buffer.frameCount > 0 else { return buffer } // Collect non-zero values to find p95 var vals = buffer.data.filter { $0 > 0.001 } guard !vals.isEmpty else { return buffer } vals.sort() let p95 = vals[min(Int(Float(vals.count) * 0.95), vals.count - 1)] guard p95 > 0.001 else { return buffer } let scale = Float(0.8) / p95 var normalized = buffer.data // single ContiguousArray copy for i in 0.. 0.005 }) { for i in 0.. 0 else { return } let dur = self.duration let time = self.currentTime let frameIndex: Int if dur > 0 { let pct = min(time / dur, 1.0) frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1) } else { frameIndex = min(Int(time * 30.0), buf.frameCount - 1) } // Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels buf.copyFrame(at: frameIndex, into: &self._audioLevels) self.levelTick &+= 1 } } private func stopOfflineVisTimer() { offlineVisTimer?.invalidate() offlineVisTimer = nil } #endif // MARK: - Real-time FFT via Audio Tap (radio streams) #if os(iOS) /// Install the shared audio tap on a player item for real-time FFT visualization. /// Async because `loadTracks(withMediaType:)` is the modern non-deprecated API. /// Falls back to sinusoidal simulation if tap installation fails. func installAudioTapIfNeeded(on item: AVPlayerItem?) { guard let item else { startRadioSimulation() return } Task { @MainActor in let success = await AudioTapProcessor.shared.installTap(on: item) if success { startRadioFFT() } else { alog("Audio tap failed — falling back to simulation") startRadioSimulation() } } } /// Timer that reads real PCM samples from the audio tap ring buffer, /// runs vDSP FFT, and feeds 30 frequency bands to the visualizer. /// Replaces startRadioSimulation() when the tap is active. private func startRadioFFT() { stopLevelTimer() alog("Radio FFT: ▶ real-time analysis active") levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self else { return } guard VisualizerSettings.shared.enabled else { return } guard self.isPlaying else { if self.internalLevels.contains(where: { $0 > 0.01 }) { for i in 0.. 0.01 }) { for i in 0.. 0.01 }) { for i in 0.. 0 { return ss } return nil }() WidgetBridge.shared.updateNowPlaying( title: song.title, artist: song.artist ?? "Unknown", album: song.album ?? "", isPlaying: isPlaying, currentTime: currentTime, duration: duration, coverArtId: artKey, coverArtImage: coverImage, queue: upcoming, waveformSamples: waveform, crossfadeAt: crossfadeTime ) } /// Downsample the offline vis buffer to `sampleCount` peak amplitudes (0.0–1.0). /// If no vis data is loaded, returns a seeded pseudo-random shape per song ID. private func sampleWaveform(sampleCount: Int = 40) -> [Float] { let buf = offlineVisBuffer if !buf.isEmpty { var samples = [Float]() samples.reserveCapacity(sampleCount) for i in 0..