1179 lines
42 KiB
Swift
1179 lines
42 KiB
Swift
|
|
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 radio→music 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()
|
||
|
|
}
|
||
|
|
}
|