NavidromeApp/Shared/Audio/AudioPlayer.swift

1179 lines
42 KiB
Swift
Raw Normal View History

import Foundation
import AVFoundation
import Combine
import MediaPlayer
import Accelerate
import os
#if os(iOS)
import UIKit
#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 NOT @Published updating them 10x/sec
// would cause every view with @EnvironmentObject audioPlayer to re-render,
// killing gesture recognizers across the entire app.
// Views that need these (NowPlayingView, MiniPlayerBar) poll them directly
// via their own Timer/TimelineView.
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 60fps SwiftUI state thrashing
// Visualizer pulls directly via AudioPlayer.shared.currentLevels()
// No lock needed: writes happen on main thread (Timer callbacks),
// reads happen from view body. A one-frame-stale read is imperceptible.
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
/// Current visualizer levels (called from TimelineView body)
func currentLevels() -> [Float] {
_audioLevels
}
/// Write visualizer levels (called from timers/taps on main thread)
private func setLevels(_ levels: [Float]) {
_audioLevels = levels
}
@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?
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)
// 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 offlineVisFrames: [[Float]] = []
private var offlineVisTimer: Timer?
private var offlineVisFPS: Double = 30.0
#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))
super.init()
configureAudioSession()
setupRemoteControls()
}
// 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 {
print("Audio session category failed: \(error)")
}
#elseif os(watchOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
} catch {
print("watchOS audio session category failed: \(error)")
}
#endif
}
private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Audio session activation failed: \(error)")
}
}
// MARK: - Playback Controls
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().prefetchProfiles(for: upcoming) }
}
#endif
}
currentSong = song
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()
}
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()
return
}
}
isUsingCrossfade = false
#endif
// Check for offline version first
if let localURL = OfflineManager.shared.localURL(for: song.id) {
#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)
#endif
}
/// Play a radio stream from a direct URL
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)
// Resolve playlist URLs (.pls, .m3u, .asx) to actual stream URLs, then play
// Buffering is NOT started automatically user taps LIVE to enable it
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)
}
}
} else {
playWithAVPlayer(streamURL)
}
#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)
/// Switch radio playback from live to buffered file for scrubbing
private var isPlayingFromBuffer = false
func radioSeekBack(to secondsFromStart: TimeInterval) {
guard isRadioStream, let bufferURL = RadioStreamBuffer.shared.bufferPlaybackURL else { return }
RadioStreamBuffer.shared.isLive = false
// Guard: don't seek to exactly 0 can break partial file reads
let seekTime = max(0.1, secondsFromStart)
if isPlayingFromBuffer {
// Already on buffer just seek within it
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
player?.seek(to: cmTime) { [weak self] _ in
self?.player?.play()
}
} else {
// First switch: swap AVPlayer to the buffer file
isPlayingFromBuffer = true
let asset = AVURLAsset(url: bufferURL)
let item = AVPlayerItem(asset: asset)
player?.replaceCurrentItem(with: item)
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
player?.seek(to: cmTime) { [weak self] _ in
self?.player?.play()
}
}
}
/// Jump back to live radio
func radioGoLive() {
guard isRadioStream, let liveURL = radioStreamURL else { return }
RadioStreamBuffer.shared.isLive = true
isPlayingFromBuffer = false
let asset = AVURLAsset(url: liveURL)
let item = AVPlayerItem(asset: asset)
player?.replaceCurrentItem(with: item)
player?.play()
}
#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) {
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 {
print("Audio session reset failed: \(error)")
}
isUsingRealFFT = false
let asset = AVURLAsset(url: url)
playerItem = AVPlayerItem(asset: asset)
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
if let dur = self.playerItem?.duration.seconds, !dur.isNaN {
self.duration = dur
}
}
// 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)
startLevelSimulation()
}
// 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 fftSize: vDSP_Length = 1024
let log2n: vDSP_Length = 10
guard frameCount >= fftSize else { return }
let halfSize = Int(fftSize / 2)
// 1. Hann window
var windowed = [Float](repeating: 0, count: Int(fftSize))
var window = [Float](repeating: 0, count: Int(fftSize))
vDSP_hann_window(&window, fftSize, Int32(vDSP_HANN_NORM))
vDSP_vmul(channelData, 1, window, 1, &windowed, 1, fftSize)
// 2-3. FFT with safe pointers
var realp = [Float](repeating: 0, count: halfSize)
var imagp = [Float](repeating: 0, count: halfSize)
var magnitudes = [Float](repeating: 0, count: halfSize)
realp.withUnsafeMutableBufferPointer { realpBuf in
imagp.withUnsafeMutableBufferPointer { imagpBuf in
var splitComplex = DSPSplitComplex(
realp: realpBuf.baseAddress!,
imagp: imagpBuf.baseAddress!
)
windowed.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, &magnitudes, 1, vDSP_Length(halfSize))
}
}
// 4. Normalize by N² and take sqrt for amplitude
let fftSizeF = Float(fftSize)
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
vDSP_vsmul(magnitudes, 1, &scale, &magnitudes, 1, vDSP_Length(halfSize))
// sqrt for perceptual scaling
for i in 0..<halfSize {
magnitudes[i] = sqrt(magnitudes[i])
}
// 5. Push raw 512-bin magnitudes visualizer handles binning & smoothing
let finalMagnitudes = magnitudes
rawFFTLevels = finalMagnitudes
}
#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()
updateNowPlayingInfo()
return
}
#endif
// Always pause both only one is active, pausing nil is a no-op
player?.pause()
enginePlayerNode?.pause()
isPlaying = false
#if os(iOS)
stopOfflineVisTimer()
#endif
updateNowPlayingInfo()
}
func resume() {
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.resume()
isPlaying = true
updateNowPlayingInfo()
return
}
#endif
// Resume whichever player path is active
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
// Engine path (real-time FFT on local file without offline vis)
node.play()
} else {
// AVPlayer path (streams, downloads with offline vis, everything else)
player?.play()
}
isPlaying = true
#if os(iOS)
if isUsingOfflineVis {
startOfflineVisSync()
}
#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 }
// Always wire the callback first
crossfade.needsNextTrack = { [weak self] in
guard let self = self else { return }
// Check if there's actually a next track to advance to
let hasNext: Bool
if self.repeatMode == .all {
hasNext = true
} else {
hasNext = self.queueIndex + 1 < self.queue.count
}
if hasNext {
// Advance queue
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]
}
// else: self-crossfade keep same song, don't advance
self.player = crossfade.activePlayer
self.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
// Prepare the NEXT next track (or self-crossfade again)
self.prepareNextForCrossfade()
}
// Now check if there's actually a next track to prepare
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 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() }
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)
}
}
func playLater(_ song: Song) {
queue.append(song)
}
func playNow(_ song: Song) {
queue.insert(song, at: queueIndex + 1)
queueIndex += 1
play(song: song)
}
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() {
if let song = currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
}
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()
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()
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()
}
} catch { }
}
#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.numberOfPoints
let cutoff = VisualizerSettings.shared.frequencyCutoff
Task {
let storage = VisualizerStorageManager.shared
let hasCache = await storage.hasCache(for: songId)
if hasCache {
alog("Offline vis: loading cache for \(songId)")
if let frames = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisFrames(frames)
await MainActor.run {
self.offlineVisFrames = 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().fetchVisualizerFrames(relativePath: path) {
let normalized = Self.normalizeVisFrames(serverFrames)
try? await storage.saveCache(frames: serverFrames, for: songId)
alog("Offline vis: fetched \(serverFrames.count) frames from server")
await MainActor.run {
self.offlineVisFrames = 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)...")
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(
url: url,
pointsCount: points,
fps: offlineVisFPS,
cutoff: cutoff
) { [weak self] pct in
Task { @MainActor in
self?.offlineVisProgress = pct
}
}
try? await storage.saveCache(frames: frames, for: songId)
let normalized = Self.normalizeVisFrames(frames)
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
self.offlineVisFrames = 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.
private static func normalizeVisFrames(_ frames: [[Float]]) -> [[Float]] {
guard !frames.isEmpty else { return frames }
// Collect all non-zero values to find the peak
var allValues: [Float] = []
allValues.reserveCapacity(frames.count * (frames.first?.count ?? 20))
for frame in frames {
for v in frame where v > 0.001 {
allValues.append(v)
}
}
guard !allValues.isEmpty else { return frames }
// Use 95th percentile instead of absolute max to avoid outlier spikes
allValues.sort()
let p95Index = min(Int(Float(allValues.count) * 0.95), allValues.count - 1)
let p95 = allValues[p95Index]
guard p95 > 0.001 else { return frames }
let scale = 0.8 / p95
return frames.map { frame in
frame.map { min(1.0, $0 * scale) }
}
}
/// 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 = offlineVisFrames.count
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
// Wait briefly for duration to become available if it's still 0
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, self.isPlaying else { return }
let frames = self.offlineVisFrames
guard !frames.isEmpty else { return }
let dur = self.duration
let time = self.currentTime
// Map playback position to frame index proportionally
let frameIndex: Int
if dur > 0 {
let pct = min(time / dur, 1.0)
frameIndex = min(Int(pct * Double(frames.count)), frames.count - 1)
} else {
// Duration still unknown estimate at 30fps
frameIndex = min(Int(time * 30.0), frames.count - 1)
}
if frameIndex >= 0, frameIndex < frames.count {
self.setLevels(frames[frameIndex])
}
}
}
private func stopOfflineVisTimer() {
offlineVisTimer?.invalidate()
offlineVisTimer = nil
}
#endif
// MARK: - Simulated Level Animation (for streams)
private func startLevelSimulation() {
stopLevelTimer()
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
guard let self = self, self.isPlaying else {
if self?.internalLevels.contains(where: { $0 > 0.01 }) == true {
self?.internalLevels = Array(repeating: 0, count: 30)
self?.setLevels(self?.internalLevels ?? [])
}
return
}
let time = self.currentTime
let phase = Int(time * 12)
if phase != self.lastTargetPhase {
self.lastTargetPhase = phase
self.targetLevels = (0..<30).map { _ in
Float.random(in: 0.1...0.85)
}
}
let smoothing: Float = 0.15
self.internalLevels = zip(self.internalLevels, self.targetLevels).map { current, target in
current + (target - current) * smoothing
}
self.publishCounter += 1
if self.publishCounter % 2 == 0 {
self.setLevels(self.internalLevels)
}
}
}
// 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()
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)
offlineVisFrames = []
#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()
}
}