🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs): - EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport() - memoryCache dictionary mutated from main thread (loadBulkCache) and background Task.detached (bulkImport) simultaneously - Fix: NSLock serializes all memoryCache reads and writes 🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log): - SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button - timeObserver registered on old player, self.player swapped to crossfade's active player by finalizeCrossfade - Fix: remove observer from OLD player at both swap sites before assignment + reorder stopAVPlayer (observer before replaceCurrentItem) 🟡 Audit fixes (no crash logs, preventative): - KVO in radioSeekBack wrapped in DispatchQueue.main.async - Stale vis Task guarded by songId in all MainActor.run blocks - CHANGELOG.md with full findings documentation
2181 lines
90 KiB
Swift
2181 lines
90 KiB
Swift
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<Void, Never>?
|
||
#endif
|
||
|
||
// MARK: - Now Playing Artwork
|
||
private var cachedArtwork: MPMediaItemArtwork?
|
||
private var cachedArtworkCoverArtId: String?
|
||
|
||
private var cancellables = Set<AnyCancellable>()
|
||
|
||
// 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..<halfSize {
|
||
fftMagnitudes[i] = sqrt(fftMagnitudes[i])
|
||
}
|
||
|
||
// 4. Push to rawFFTLevels — single array copy, no intermediate allocation
|
||
rawFFTLevels = fftMagnitudes
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Engine Finish
|
||
|
||
private func playerDidFinishEngine() {
|
||
if repeatMode == .one, let file = engineFile, let node = enginePlayerNode {
|
||
node.stop()
|
||
node.scheduleFile(file, at: nil) { [weak self] in
|
||
DispatchQueue.main.async { self?.playerDidFinishEngine() }
|
||
}
|
||
node.play()
|
||
} else {
|
||
next()
|
||
}
|
||
}
|
||
|
||
// MARK: - Transport
|
||
|
||
func togglePlayPause() {
|
||
if isPlaying {
|
||
pause()
|
||
} else {
|
||
resume()
|
||
}
|
||
}
|
||
|
||
func pause() {
|
||
#if os(iOS)
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.pause()
|
||
isPlaying = false
|
||
stopOfflineVisTimer()
|
||
stopLevelTimer()
|
||
updateNowPlayingInfo()
|
||
pushWidgetState()
|
||
return
|
||
}
|
||
#endif
|
||
player?.pause()
|
||
enginePlayerNode?.pause()
|
||
isPlaying = false
|
||
#if os(iOS)
|
||
stopOfflineVisTimer()
|
||
// Don't stop levelTimer — the simulation timer's !isPlaying branch decays
|
||
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
|
||
// so the Canvas keeps re-rendering and the wave visually decays during pause.
|
||
// For offline vis path the offlineVisTimer (restarted above) handles this.
|
||
internalLevels = Array(repeating: 0, count: 30)
|
||
pushWidgetState()
|
||
#endif
|
||
updateNowPlayingInfo()
|
||
}
|
||
|
||
func resume() {
|
||
// Cold restore: app was killed, queue restored from PlaybackStateStore,
|
||
// but AVPlayer was never created. player?.play() would be a nil no-op.
|
||
// Detect this and go through the full play(song:) path, then seek to
|
||
// the saved position so playback resumes where the user left off.
|
||
if player == nil, let song = currentSong {
|
||
let savedPosition = currentTime
|
||
alog("Cold resume: loading \(song.title) at \(Int(savedPosition))s")
|
||
play(song: song)
|
||
// AVPlayer queues seeks — this executes once the item is ready
|
||
if savedPosition > 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<Float>()
|
||
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<Float>()
|
||
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..<normalized.count {
|
||
normalized[i] = min(1.0, normalized[i] * scale)
|
||
}
|
||
return VisFrameBuffer(
|
||
data: normalized,
|
||
frameCount: buffer.frameCount,
|
||
pointsPerFrame: buffer.pointsPerFrame
|
||
)
|
||
}
|
||
|
||
/// Sync cached frames to current playback time.
|
||
/// Uses proportional mapping (time/duration → frame index) so it works
|
||
/// regardless of what FPS the frames were analyzed at.
|
||
private func startOfflineVisSync() {
|
||
stopOfflineVisTimer()
|
||
stopLevelTimer()
|
||
|
||
let frameCount = offlineVisBuffer.frameCount
|
||
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
|
||
|
||
if duration <= 0 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||
self?.startOfflineVisSyncTimer()
|
||
}
|
||
} else {
|
||
startOfflineVisSyncTimer()
|
||
}
|
||
}
|
||
|
||
private func startOfflineVisSyncTimer() {
|
||
stopOfflineVisTimer()
|
||
|
||
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||
guard let self = self, self.isUsingOfflineVis else { return }
|
||
guard self.isPlaying else {
|
||
// Zero out levels so the visualizer decays during pause
|
||
if self._audioLevels.contains(where: { $0 > 0.005 }) {
|
||
for i in 0..<self._audioLevels.count { self._audioLevels[i] = 0 }
|
||
self.levelTick &+= 1
|
||
}
|
||
return
|
||
}
|
||
|
||
let buf = self.offlineVisBuffer
|
||
guard buf.frameCount > 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..<self.internalLevels.count { self.internalLevels[i] = 0 }
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
return
|
||
}
|
||
|
||
let bands = AudioTapProcessor.shared.computeFFTBands(bandCount: self.internalLevels.count)
|
||
|
||
// Apply viscosity smoothing (same physics as offline vis path)
|
||
let viscosity = Float(VisualizerSettings.shared.viscosity)
|
||
for i in 0..<self.internalLevels.count {
|
||
self.internalLevels[i] = self.internalLevels[i] * viscosity + bands[i] * (1 - viscosity)
|
||
}
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
}
|
||
#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
|
||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
// Skip all work when visualizer is disabled — avoids 60fps float math
|
||
// and setLevels() calls that serve no purpose with no Canvas reading them.
|
||
#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
|
||
}
|
||
|
||
let time = self.currentTime
|
||
let phase = Int(time * 12)
|
||
if phase != self.lastTargetPhase {
|
||
self.lastTargetPhase = phase
|
||
// In-place random target — no allocation
|
||
for i in 0..<self.targetLevels.count {
|
||
self.targetLevels[i] = Float.random(in: 0.1...0.85)
|
||
}
|
||
}
|
||
|
||
// In-place smooth — no zip/map allocation
|
||
let smoothing: Float = 0.15
|
||
for i in 0..<self.internalLevels.count {
|
||
self.internalLevels[i] += (self.targetLevels[i] - self.internalLevels[i]) * smoothing
|
||
}
|
||
|
||
self.publishCounter += 1
|
||
if self.publishCounter % 2 == 0 {
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Widget State Push
|
||
|
||
#if os(iOS)
|
||
/// Push current playback state to the widget via App Group.
|
||
/// Called on song change, play/pause, and artwork load.
|
||
private func pushWidgetState() {
|
||
guard let song = currentSong else { return }
|
||
|
||
// Notify LyricsManager of the current song (deduplicates internally by song ID)
|
||
LyricsManager.shared.songChanged(
|
||
songId: song.id,
|
||
artist: song.artist ?? "Unknown",
|
||
title: song.title,
|
||
path: song.path,
|
||
duration: duration
|
||
)
|
||
|
||
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||
let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in
|
||
var artData: Data?
|
||
if let id = s.coverArt {
|
||
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
||
// Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px)
|
||
artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))?
|
||
.jpegData(compressionQuality: 0.5)
|
||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) {
|
||
artData = ImageCache.shared.cachedImage(for: url)?
|
||
.jpegData(compressionQuality: 0.5)
|
||
}
|
||
}
|
||
return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData)
|
||
}
|
||
|
||
// Cover art: custom first, then server (memory + disk).
|
||
var coverImage: UIImage?
|
||
var artKey = song.coverArt
|
||
if let id = song.coverArt {
|
||
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
||
coverImage = custom
|
||
artKey = "custom_\(id)"
|
||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) {
|
||
coverImage = ImageCache.shared.cachedImage(for: url)
|
||
}
|
||
}
|
||
|
||
// Waveform: downsample offline vis data to 40 peaks, or pseudo-random fallback.
|
||
let waveform = sampleWaveform(sampleCount: 40)
|
||
|
||
// Crossfade trigger time from Smart DJ profile (seconds into track).
|
||
let crossfadeTime: TimeInterval? = {
|
||
if isUsingCrossfade,
|
||
let ss = SmartCrossfadeManager.shared.currentProfile?.silenceStart,
|
||
ss > 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..<sampleCount {
|
||
let startFrame = i * buf.frameCount / sampleCount
|
||
let endFrame = min((i + 1) * buf.frameCount / sampleCount, buf.frameCount)
|
||
var peak: Float = 0
|
||
for f in startFrame..<endFrame {
|
||
let offset = f * buf.pointsPerFrame
|
||
let end = min(offset + buf.pointsPerFrame, buf.data.count)
|
||
for j in offset..<end {
|
||
peak = max(peak, buf.data[j])
|
||
}
|
||
}
|
||
samples.append(peak)
|
||
}
|
||
return samples
|
||
}
|
||
|
||
// Fallback: seeded PRNG for consistent shape per song
|
||
guard let id = currentSong?.id else {
|
||
return [Float](repeating: 0.3, count: sampleCount)
|
||
}
|
||
var seed: UInt64 = 5381
|
||
for c in id.utf8 { seed = seed &* 33 &+ UInt64(c) }
|
||
var samples = [Float]()
|
||
samples.reserveCapacity(sampleCount)
|
||
for i in 0..<sampleCount {
|
||
seed = (seed &* 16807 &+ 7) % 2147483647
|
||
let base = Float(seed % 1000) / 1000.0
|
||
let envelope = 0.5 + 0.5 * sin(Float.pi * Float(i) / Float(sampleCount))
|
||
samples.append(min(0.2 + base * 0.6 * envelope, 1.0))
|
||
}
|
||
return samples
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Cleanup
|
||
|
||
private func stopLevelTimer() {
|
||
levelTimer?.invalidate()
|
||
levelTimer = nil
|
||
}
|
||
|
||
private func stopEngineTimer() {
|
||
engineTimeTimer?.invalidate()
|
||
engineTimeTimer = nil
|
||
}
|
||
|
||
private func stopEngine() {
|
||
scheduleGeneration += 1 // invalidate all pending completions
|
||
enginePlayerNode?.stop()
|
||
audioEngine?.mainMixerNode.removeTap(onBus: 0)
|
||
audioEngine?.stop()
|
||
audioEngine = nil
|
||
enginePlayerNode = nil
|
||
engineFile = nil
|
||
stopEngineTimer()
|
||
}
|
||
|
||
private func stopAVPlayer() {
|
||
#if os(iOS)
|
||
// Clean up audio tap before nilling the playerItem — prevents stale
|
||
// sourceFormat from confusing Shazam into skipping tap installation
|
||
AudioTapProcessor.shared.removeTap(from: playerItem)
|
||
#endif
|
||
// Remove time observer FIRST — before pause/replaceCurrentItem.
|
||
// replaceCurrentItem(with: nil) can tear down internal AVPlayer state
|
||
// that the observer depends on, causing removeTimeObserver to throw.
|
||
if let observer = timeObserver {
|
||
player?.removeTimeObserver(observer)
|
||
timeObserver = nil
|
||
}
|
||
player?.pause()
|
||
player?.replaceCurrentItem(with: nil)
|
||
playerItem = nil
|
||
}
|
||
|
||
/// Stop everything — called before starting new playback
|
||
private func stopAll() {
|
||
stopLevelTimer()
|
||
nowPlayingSyncTimer?.invalidate()
|
||
nowPlayingSyncTimer = nil
|
||
#if os(iOS)
|
||
stopOfflineVisTimer()
|
||
analysisTask?.cancel()
|
||
analysisTask = nil
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.stop()
|
||
isUsingCrossfade = false
|
||
}
|
||
// Zero the vis buffer so stale frames from the previous song don't render
|
||
// while the new song is loading. The new loadOfflineVisualizer() call will
|
||
// repopulate it once analysis finishes.
|
||
offlineVisBuffer = VisFrameBuffer.empty
|
||
#endif
|
||
stopEngine()
|
||
stopAVPlayer()
|
||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||
// Zero audio levels immediately — prevents the Canvas from painting stale
|
||
// FFT/vis data during track transitions (the "lifted bars" bug).
|
||
setLevels(Array(repeating: 0, count: _audioLevels.count))
|
||
}
|
||
|
||
func stop() {
|
||
stopAll()
|
||
isPlaying = false
|
||
currentSong = nil
|
||
setLevels(Array(repeating: 0, count: 30))
|
||
internalLevels = Array(repeating: 0, count: 30)
|
||
rawFFTLevels = Array(repeating: 0, count: 512)
|
||
isUsingRealFFT = false
|
||
isUsingOfflineVis = false
|
||
#if os(iOS)
|
||
offlineVisBuffer = VisFrameBuffer.empty
|
||
WidgetBridge.shared.clear()
|
||
// Tell watch playback stopped
|
||
WatchConnectivityManager.shared.sendNowPlayingToWatch(
|
||
song: nil, isPlaying: false, currentTime: 0, duration: 0
|
||
)
|
||
#endif
|
||
if isRadioStream {
|
||
isRadioStream = false
|
||
radioStreamURL = nil
|
||
#if os(iOS)
|
||
RadioStreamBuffer.shared.stopBuffering()
|
||
isPlayingFromBuffer = false
|
||
#endif
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
if let setup = fftSetup {
|
||
vDSP_destroy_fftsetup(setup)
|
||
}
|
||
stop()
|
||
}
|
||
}
|