NavidromeApp/Shared/Audio/AudioPlayer.swift
Dallas Groot c0a073bf3f Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar

iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)

iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill

iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed

Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00

1964 lines
80 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
configureAudioSession()
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)
.sink { [weak self] _ in self?.suspendVisTimers() }
.store(in: &cancellables)
// Restart the correct timer when returning to foreground
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.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)
.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)
.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()
// 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() {
// 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)")
}
// 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 if actually playing
guard isPlaying else { return }
// 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 !isUsingCrossfade {
startLevelSimulation()
}
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()
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
// 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 }
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
}
crossfade.playSong(song, url: url)
isPlaying = true
// Set AudioPlayer.player to the active crossfade player
// so Lock Screen controls and visualizer still work
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)
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)
// 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)
// KVO on status more reliable than polling; fires immediately on failure too
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
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)
player?.replaceCurrentItem(with: item)
player?.play()
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 radiomusic 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!
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")
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 use a smooth sinusoidal simulation random phase
// simulation reacts to currentTime jumps (buffer seeks) causing
// the peakFollower to spike and "raise" the wave unexpectedly.
#if os(iOS)
if isRadioStream {
startRadioSimulation()
} else {
startLevelSimulation()
}
#else
startLevelSimulation()
#endif
}
// MARK: - AVAudioEngine Path (local files with real FFT)
private func playLocalWithEngine(_ url: URL) {
#if os(iOS)
alog("Engine path: \(url.lastPathComponent)")
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() {
#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 {
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() }
}
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 the song that just finished.
// playerDidFinish only fires on natural item end crossfade replaces the item
// before that happens, so we scrobble here instead.
if let finishedSong = self.currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: finishedSong.id) }
}
// Sync queueIndex to whatever was actually in standby (pendingNextSong),
// not just queueIndex+1. The queue may have changed between prepareNext()
// and now this guarantees currentSong matches what the active player is playing.
if let nowPlaying = crossfade.pendingNextSong,
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
self.queueIndex = idx
self.currentSong = nowPlaying
} 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]
}
}
// Persist updated queue position time observer only saves position now
PlaybackStateStore.shared.save(
queue: self.queue, index: self.queueIndex,
currentTime: 0, currentSongId: self.currentSong?.id
)
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer on the
// new active item. play() registered it on the first item only subsequent
// crossfades swap the active slot so the original observer is now stale.
// Without this, a boundary-observer failure on track 2+ has no fallback.
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
)
}
self.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
self.pushWidgetState()
// 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
}
}
// 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
#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 {
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 {
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 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 {
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
self.startOfflineVisSync()
}
} catch {
alog("Offline vis: analysis failed: \(error.localizedDescription)")
await MainActor.run { self.startLevelSimulation() }
}
} // 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: - 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 upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
// 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.01.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() {
player?.pause()
player?.replaceCurrentItem(with: nil)
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = 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
}
#endif
stopEngine()
stopAVPlayer()
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
}
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()
#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()
}
}