The Artist → Album path bypassed this entirely because it navigates via artistId which fetches albums fresh from Navidrome each time. After installing this and doing a pull-to-refresh, the Albums tab will use real Navidrome IDs again. You may need to clear the app’s cache once if the stale Companion IDs are already persisted — Settings → clear library cache if that option exists, or just force-quit and relaunch after refreshing.
1735 lines
70 KiB
Swift
1735 lines
70 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 @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()
|
|
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)")
|
|
}
|
|
|
|
// 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 }
|
|
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()
|
|
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)
|
|
#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 radio→music conflict)
|
|
do {
|
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
} catch {
|
|
alog("Audio session reset failed: \(error)")
|
|
}
|
|
|
|
isUsingRealFFT = false
|
|
|
|
let asset = AVURLAsset(url: url)
|
|
playerItem = AVPlayerItem(asset: asset)
|
|
|
|
// Observe item status — catches stream 404s, auth failures, and codec
|
|
// errors that AVPlayer swallows silently otherwise
|
|
#if os(iOS)
|
|
let itemToObserve = playerItem!
|
|
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 state saves to once per 5 seconds — saves are cheap but
|
|
// the observer fires 10x/sec so we don't need to write that often.
|
|
if Int(time.seconds * 10) % 50 == 0 {
|
|
PlaybackStateStore.shared.save(
|
|
queue: self.queue,
|
|
index: self.queueIndex,
|
|
currentTime: time.seconds,
|
|
currentSongId: self.currentSong?.id
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 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()
|
|
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)
|
|
#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()
|
|
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() }
|
|
}
|
|
#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]
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// 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 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.
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
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()
|
|
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 {
|
|
// 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)
|
|
|
|
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: - 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
|
|
#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()
|
|
}
|
|
}
|
|
|