Widget v2: - Glassmorphism glass panel, 40-bar waveform Canvas with SeekToIntent - CIAreaAverage color extraction + secondary color for adaptive theming - Small: inset glass. Medium/Large: flush edge-to-edge (contentMarginsDisabled) - Large: 3-item queue with real cover art thumbnails, crossfade countdown - Waveform sampled from offline vis buffer, seeded PRNG fallback Live Lyrics: - LyricsService: direct LRCLIB from iOS, no companion dependency - Embedded lyrics read via AVAsset.load(.metadata) from downloads - Karaoke word-by-word gradient fill, auto-scroll, fade edges - Tap-to-sync timing editor with +/-0.1s offset adjust - Companion API fallback only for server-side embedded tags Backup System: - .nvdbackup export/import via ZIPFoundation - UTI registered for AirDrop, passwords stripped on export - PendingOperationsQueue with retry + disk persistence Crossfade fixes: - Seek bar: reports from incoming player immediately, not at 50% - songHandoff at midpoint: art/colors/text/lyrics transition mid-fade - Scrobble fires before metadata swap (correct outgoing song) Visualizer fixes: - stopAll() zeros _audioLevels + clears offlineVisBuffer - All 5 simulation start sites gated behind realAudioAnalysis check - Bars stay flat between skips, only rise when real vis data loads Smart DJ bulk prefetch, appendingPathComponent slash fix
2014 lines
83 KiB
Swift
2014 lines
83 KiB
Swift
import Foundation
|
||
import AVFoundation
|
||
import Combine
|
||
import MediaPlayer
|
||
import Accelerate
|
||
import os
|
||
#if os(iOS)
|
||
import UIKit
|
||
import WidgetKit
|
||
#endif
|
||
|
||
/// Shared audio player with real FFT visualizer for local files
|
||
class AudioPlayer: NSObject, ObservableObject {
|
||
|
||
static let shared = AudioPlayer()
|
||
|
||
// MARK: - Published State
|
||
@Published var currentSong: Song?
|
||
@Published var queue: [Song] = []
|
||
@Published var queueIndex: Int = 0
|
||
@Published var isPlaying = false
|
||
// currentTime and duration are @Published but set only from addPeriodicTimeObserver
|
||
// on the main queue — safe for SwiftUI observation. NowPlayingView binds directly,
|
||
// eliminating the Timer.publish poller that caused runloop starvation.
|
||
// currentTime and duration are intentionally NOT @Published.
|
||
// @Published fires objectWillChange on AudioPlayer 10-20x/second, causing
|
||
// every @EnvironmentObject/ObservedObject subscriber (NowPlayingView,
|
||
// MiniPlayerBar, MyMusicView etc.) to re-evaluate their entire bodies at
|
||
// that rate. Progress bars use TimelineView(.periodic) instead, which reads
|
||
// these values directly on each tick without triggering objectWillChange.
|
||
// Both are written and read exclusively on the main queue — no race risk.
|
||
var currentTime: TimeInterval = 0
|
||
var duration: TimeInterval = 0
|
||
@Published var volume: Float = 0.8
|
||
@Published var repeatMode: RepeatMode = .off
|
||
@Published var shuffleEnabled = false
|
||
// audioLevels is NOT @Published — avoids SwiftUI state thrashing.
|
||
// The Canvas reads currentLevels() directly on every TimelineView tick.
|
||
// levelTick is kept as an internal counter but intentionally NOT @Published —
|
||
// making it @Published caused 30fps objectWillChange on AudioPlayer, forcing
|
||
// every observing view (NowPlayingView, MainTabView, etc.) to re-evaluate 30x/sec.
|
||
private(set) var levelTick: Int = 0
|
||
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
|
||
|
||
func currentLevels() -> [Float] { _audioLevels }
|
||
|
||
private func setLevels(_ levels: [Float]) {
|
||
_audioLevels = levels
|
||
levelTick &+= 1
|
||
}
|
||
|
||
@Published var isUsingRealFFT = false
|
||
@Published var isUsingOfflineVis = false
|
||
@Published var offlineVisProgress: Float = 0
|
||
@Published var sourcePlaylistId: String?
|
||
@Published var sourcePlaylistName: String?
|
||
|
||
enum RepeatMode {
|
||
case off, all, one
|
||
}
|
||
|
||
// MARK: - AVPlayer (for streaming)
|
||
private var player: AVPlayer?
|
||
private var playerItem: AVPlayerItem?
|
||
/// Exposed for ShazamRecognizer tap installation
|
||
var currentPlayerItem: AVPlayerItem? { playerItem }
|
||
private var timeObserver: Any?
|
||
|
||
// MARK: - AVAudioEngine (for local files with FFT)
|
||
private var audioEngine: AVAudioEngine?
|
||
private var enginePlayerNode: AVAudioPlayerNode?
|
||
private var engineTimeTimer: Timer?
|
||
private var engineFile: AVAudioFile?
|
||
private var engineStartFrame: AVAudioFramePosition = 0
|
||
private var scheduleGeneration: Int = 0
|
||
private var nowPlayingSyncTimer: Timer?
|
||
|
||
#if os(iOS)
|
||
/// When true, SmartCrossfadeManager owns playback instead of the local player/engine
|
||
private var isUsingCrossfade = false
|
||
#endif
|
||
|
||
// MARK: - FFT (radix-2, vDSP_fft_zrip)
|
||
private var fftSetup: FFTSetup?
|
||
// Written from audio render thread, read from Timer on main thread.
|
||
// No lock needed: stale FFT frame is imperceptible at 30fps.
|
||
nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512)
|
||
|
||
// Pre-allocated FFT scratch buffers — allocated once in init, reused every render callback.
|
||
// Heap allocation from a real-time audio thread is forbidden (locks inside malloc can
|
||
// block indefinitely and cause the hardware deadline to be missed → audio glitch).
|
||
private let fftSize: vDSP_Length = 1024
|
||
nonisolated(unsafe) private var fftWindow: [Float] // Hann window coefficients — constant
|
||
nonisolated(unsafe) private var fftWindowed: [Float] // windowed input scratch
|
||
nonisolated(unsafe) private var fftRealp: [Float] // split-complex real part
|
||
nonisolated(unsafe) private var fftImagp: [Float] // split-complex imag part
|
||
nonisolated(unsafe) private var fftMagnitudes: [Float] // output magnitudes
|
||
|
||
// MARK: - Level Simulation (for streams)
|
||
private var levelTimer: Timer?
|
||
private var targetLevels: [Float] = Array(repeating: 0, count: 30)
|
||
private var internalLevels: [Float] = Array(repeating: 0, count: 30)
|
||
private var publishCounter: Int = 0
|
||
private var lastTargetPhase: Int = 0
|
||
|
||
// MARK: - Offline Visualizer
|
||
#if os(iOS)
|
||
private var offlineVisBuffer = VisFrameBuffer.empty
|
||
private var offlineVisTimer: Timer?
|
||
private var offlineVisFPS: Double = 30.0
|
||
private var analysisTask: Task<Void, Never>?
|
||
#endif
|
||
|
||
// MARK: - Now Playing Artwork
|
||
private var cachedArtwork: MPMediaItemArtwork?
|
||
private var cachedArtworkCoverArtId: String?
|
||
|
||
private var cancellables = Set<AnyCancellable>()
|
||
|
||
// MARK: - Init
|
||
|
||
private override init() {
|
||
// Radix-2 FFT setup for 1024 samples (log2(1024) = 10)
|
||
fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2))
|
||
|
||
// Pre-allocate all FFT scratch buffers and compute the constant Hann window.
|
||
// These are reused on every render callback — zero heap allocation at render time.
|
||
let halfSize = Int(1024 / 2)
|
||
fftWindow = [Float](repeating: 0, count: 1024)
|
||
fftWindowed = [Float](repeating: 0, count: 1024)
|
||
fftRealp = [Float](repeating: 0, count: halfSize)
|
||
fftImagp = [Float](repeating: 0, count: halfSize)
|
||
fftMagnitudes = [Float](repeating: 0, count: halfSize)
|
||
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
|
||
|
||
super.init()
|
||
configureAudioSession()
|
||
setupRemoteControls()
|
||
|
||
#if os(iOS)
|
||
// Refresh lock screen artwork when custom cover changes
|
||
AlbumCoverStore.shared.$updateTrigger
|
||
.sink { [weak self] _ in
|
||
guard let self, let id = self.currentSong?.coverArt else { return }
|
||
self.cachedArtworkCoverArtId = nil
|
||
self.fetchAndSetArtwork(coverArtId: id)
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Stop all visualizer timers when backgrounded — they serve no purpose without
|
||
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
|
||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||
.sink { [weak self] _ in self?.suspendVisTimers() }
|
||
.store(in: &cancellables)
|
||
|
||
// Restart the correct timer when returning to foreground
|
||
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||
.sink { [weak self] _ in self?.resumeVisTimers() }
|
||
.store(in: &cancellables)
|
||
|
||
// ── AVAudioSession interruption handling ──────────────────────────────
|
||
// Required for correct behaviour during phone calls, Siri, alarms, and
|
||
// other apps taking the audio session. Without this, playback stops but
|
||
// isPlaying stays true — timers keep firing and music never auto-resumes.
|
||
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
|
||
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
|
||
.store(in: &cancellables)
|
||
|
||
// ── Audio route change (headphones unplugged etc.) ────────────────────
|
||
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
|
||
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
|
||
.sink { [weak self] notification in self?.handleRouteChange(notification) }
|
||
.store(in: &cancellables)
|
||
#endif
|
||
}
|
||
|
||
#if os(iOS)
|
||
private func handleAudioInterruption(_ notification: Notification) {
|
||
guard let info = notification.userInfo,
|
||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
||
|
||
switch type {
|
||
case .began:
|
||
// System took the session (phone call, Siri, alarm).
|
||
// Mark as not playing — timers/observers were already suspended
|
||
// by didEnterBackground if the app backgrounded; this handles the
|
||
// foreground case (e.g. incoming call while app is visible).
|
||
if isPlaying {
|
||
pause()
|
||
alog("Interruption began — paused playback")
|
||
}
|
||
|
||
case .ended:
|
||
// Session returned to us. Resume only if the system says we should.
|
||
if let optsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||
let opts = AVAudioSession.InterruptionOptions(rawValue: optsValue)
|
||
if opts.contains(.shouldResume) {
|
||
// Re-activate the session before resuming — it was deactivated
|
||
// by the system during the interruption.
|
||
do {
|
||
try AVAudioSession.sharedInstance().setActive(true)
|
||
resume()
|
||
alog("Interruption ended — resumed playback")
|
||
} catch {
|
||
alog("Interruption ended — session reactivation failed: \(error)")
|
||
}
|
||
} else {
|
||
alog("Interruption ended — shouldResume not set, staying paused")
|
||
}
|
||
}
|
||
|
||
@unknown default:
|
||
break
|
||
}
|
||
}
|
||
|
||
private func handleRouteChange(_ notification: Notification) {
|
||
guard let info = notification.userInfo,
|
||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
|
||
|
||
// Pause when headphones are unplugged — matches system Music app behaviour.
|
||
if reason == .oldDeviceUnavailable, isPlaying {
|
||
pause()
|
||
alog("Route change: output removed — paused playback")
|
||
}
|
||
}
|
||
|
||
private func suspendVisTimers() {
|
||
stopOfflineVisTimer()
|
||
stopLevelTimer()
|
||
nowPlayingSyncTimer?.invalidate()
|
||
nowPlayingSyncTimer = nil
|
||
analysisTask?.cancel()
|
||
analysisTask = nil
|
||
AudioPreFetcher.shared.cancelAll()
|
||
removeTimeObserver()
|
||
// SmartCrossfadeManager has its own 10Hz time observer that writes
|
||
// @Published currentTime/duration — same SwiftUI churn as AudioPlayer's
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.suspendForBackground()
|
||
}
|
||
alog("Background: all timers + observers suspended")
|
||
}
|
||
|
||
private func resumeVisTimers() {
|
||
// Re-activate the audio session — another app may have taken it while we
|
||
// were in the background (e.g. a game or Spotify). Without this, player.play()
|
||
// silently fails and isPlaying shows true with no audio output.
|
||
do {
|
||
try AVAudioSession.sharedInstance().setActive(true)
|
||
} catch {
|
||
alog("Foreground: session reactivation failed: \(error)")
|
||
}
|
||
|
||
// Pick up any widget commands that arrived while the process was suspended.
|
||
// Darwin notifications can't wake suspended processes, so commands written
|
||
// by widget intents sit in App Group UserDefaults until we check here.
|
||
WidgetBridge.shared.processAnyPendingCommand()
|
||
|
||
// Sync currentTime/duration from the live AVPlayer state.
|
||
// While backgrounded, the time observer was removed (suspendVisTimers)
|
||
// but AVPlayer kept advancing. Without this sync, the vis timer's first
|
||
// frames read the stale position from when the app backgrounded, causing
|
||
// the waveform to render from the wrong song position then snap-correct
|
||
// once the reinstalled time observer fires (~0.1s later).
|
||
if let p = player, p.currentTime().isNumeric {
|
||
currentTime = p.currentTime().seconds
|
||
}
|
||
if let dur = playerItem?.duration.seconds, dur.isFinite, dur > 0 {
|
||
duration = dur
|
||
}
|
||
|
||
// Always reinstall time observers — they were removed on background regardless
|
||
// of play state. Without this, currentTime is frozen after background+pause+play.
|
||
reinstallTimeObserver()
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.resumeFromBackground()
|
||
}
|
||
// Only restart vis timers if actually playing
|
||
guard isPlaying else { return }
|
||
|
||
// Restart Now Playing sync timer — keeps Lock Screen / Dynamic Island
|
||
// seek bar accurate. Created in playWithAVPlayer but never restarted
|
||
// after background cycle; without this the system seek bar drifts.
|
||
if nowPlayingSyncTimer == nil {
|
||
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||
guard let self = self, self.isPlaying else { return }
|
||
self.updateNowPlayingInfo()
|
||
}
|
||
}
|
||
|
||
if isUsingOfflineVis {
|
||
startOfflineVisSync()
|
||
} else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis {
|
||
startLevelSimulation()
|
||
}
|
||
// When realAudioAnalysis is on but vis data hasn't loaded yet: stay at 0.
|
||
alog("Foreground: session reactivated, observers + vis timers resumed (crossfade=\(isUsingCrossfade) offlineVis=\(isUsingOfflineVis))")
|
||
}
|
||
|
||
private func removeTimeObserver() {
|
||
if let observer = timeObserver {
|
||
player?.removeTimeObserver(observer)
|
||
timeObserver = nil
|
||
}
|
||
}
|
||
|
||
private func reinstallTimeObserver() {
|
||
guard player != nil, timeObserver == nil else { return }
|
||
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
|
||
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||
guard let self = self else { return }
|
||
self.currentTime = time.seconds
|
||
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
|
||
self.duration = dur
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Audio Session
|
||
|
||
private func alog(_ msg: String) {
|
||
#if os(iOS)
|
||
DebugLogger.shared.log(msg, category: "Audio")
|
||
#else
|
||
print("[Audio] \(msg)")
|
||
#endif
|
||
}
|
||
|
||
private func configureAudioSession() {
|
||
#if os(iOS)
|
||
do {
|
||
let session = AVAudioSession.sharedInstance()
|
||
try session.setCategory(.playback, mode: .default, options: [])
|
||
} catch {
|
||
alog("Audio session category failed: \(error)")
|
||
}
|
||
#elseif os(watchOS)
|
||
do {
|
||
let session = AVAudioSession.sharedInstance()
|
||
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||
} catch {
|
||
alog("watchOS audio session category failed: \(error)")
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func activateAudioSession() {
|
||
do {
|
||
try AVAudioSession.sharedInstance().setActive(true)
|
||
} catch {
|
||
alog("Audio session activation failed: \(error)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Playback Controls
|
||
|
||
/// Jump to a song already in the queue by index without replacing the queue.
|
||
/// Used by QueueView tap — keeps the existing queue intact.
|
||
func play(song: Song, at index: Int) {
|
||
queueIndex = index
|
||
play(song: song)
|
||
}
|
||
|
||
func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) {
|
||
if let q = fromQueue {
|
||
queue = shuffleEnabled ? q.shuffled() : q
|
||
queueIndex = index
|
||
sourcePlaylistId = playlistId
|
||
sourcePlaylistName = playlistName
|
||
|
||
// Prefetch Smart DJ profiles for upcoming tracks
|
||
#if os(iOS)
|
||
if CompanionSettings.shared.smartDJEnabled {
|
||
let upcoming = Array(queue.dropFirst(index).prefix(10))
|
||
Task { await CompanionAPIService.shared.prefetchProfiles(for: upcoming) }
|
||
}
|
||
#endif
|
||
}
|
||
|
||
currentSong = song
|
||
|
||
// Persist queue state so it survives app termination (AUDIT-055).
|
||
// Position is saved as 0 here; the time observer updates it every 0.1s.
|
||
#if os(iOS)
|
||
PlaybackStateStore.shared.save(
|
||
queue: queue,
|
||
index: queueIndex,
|
||
currentTime: 0,
|
||
currentSongId: song.id
|
||
)
|
||
#endif
|
||
|
||
alog("Playing: \(song.title) by \(song.artist ?? "Unknown")")
|
||
|
||
// Stop radio buffer if switching from radio to music
|
||
if isRadioStream {
|
||
isRadioStream = false
|
||
radioStreamURL = nil
|
||
#if os(iOS)
|
||
RadioStreamBuffer.shared.stopBuffering()
|
||
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 }
|
||
// Clear stale vis from outgoing song before loading new
|
||
self.offlineVisBuffer = VisFrameBuffer.empty
|
||
self.setLevels(Array(repeating: 0, count: self._audioLevels.count))
|
||
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
|
||
}
|
||
// At crossfade midpoint, transition metadata/artwork/colors/widget
|
||
// so the UI reflects the incoming song while audio is still fading.
|
||
crossfade.songHandoff = { [weak self] incomingSong in
|
||
guard let self = self else { return }
|
||
// Scrobble the outgoing song before updating currentSong
|
||
if let outgoing = self.currentSong {
|
||
Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) }
|
||
}
|
||
self.currentSong = incomingSong
|
||
if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) {
|
||
self.queueIndex = idx
|
||
}
|
||
self.updateNowPlayingInfo()
|
||
self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt)
|
||
self.pushWidgetState()
|
||
LyricsManager.shared.songChanged(
|
||
songId: incomingSong.id,
|
||
artist: incomingSong.artist ?? "Unknown",
|
||
title: incomingSong.title,
|
||
path: incomingSong.path,
|
||
duration: self.duration
|
||
)
|
||
}
|
||
|
||
crossfade.playSong(song, url: url)
|
||
isPlaying = true
|
||
|
||
// Set AudioPlayer.player to the active crossfade player
|
||
// so Lock Screen controls and visualizer still work
|
||
player = crossfade.activePlayer
|
||
|
||
// Safety: if boundary observer doesn't fire (short track, bad profile),
|
||
// this catches the natural end and advances normally
|
||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||
NotificationCenter.default.addObserver(
|
||
self, selector: #selector(playerDidFinish),
|
||
name: .AVPlayerItemDidPlayToEndTime, object: crossfade.activePlayer.currentItem
|
||
)
|
||
|
||
updateNowPlayingInfo()
|
||
fetchAndSetArtwork(coverArtId: song.coverArt)
|
||
if !VisualizerSettings.shared.realAudioAnalysis {
|
||
startLevelSimulation()
|
||
}
|
||
|
||
// Load offline visualizer for crossfade path too
|
||
if VisualizerSettings.shared.realAudioAnalysis {
|
||
loadOfflineVisualizer(songId: song.id, url: url)
|
||
}
|
||
|
||
// Prepare next track in standby
|
||
prepareNextForCrossfade()
|
||
pushWidgetState()
|
||
return
|
||
}
|
||
}
|
||
isUsingCrossfade = false
|
||
#endif
|
||
|
||
// Check for offline version first — use Song overload which falls back to
|
||
// title/artist/duration matching if the ID changed after a Companion restructure
|
||
if let localURL = OfflineManager.shared.localURL(for: song) {
|
||
#if os(iOS)
|
||
if VisualizerSettings.shared.realAudioAnalysis {
|
||
// Use AVPlayer for reliable playback + offline vis for the visualizer
|
||
playWithAVPlayer(localURL)
|
||
loadOfflineVisualizer(songId: song.id, url: localURL)
|
||
} else {
|
||
playWithAVPlayer(localURL)
|
||
}
|
||
#else
|
||
playWithAVPlayer(localURL)
|
||
#endif
|
||
} else {
|
||
#if os(iOS)
|
||
// Check prefetch cache — already downloaded in background
|
||
if let prefetchURL = AudioPreFetcher.shared.prefetchedURL(for: song.id, suffix: song.suffix) {
|
||
alog("Playing from prefetch cache")
|
||
playWithAVPlayer(prefetchURL)
|
||
if VisualizerSettings.shared.realAudioAnalysis {
|
||
loadOfflineVisualizer(songId: song.id, url: prefetchURL)
|
||
}
|
||
} else {
|
||
let quality = StreamingQuality.shared
|
||
let streamURL = ServerManager.shared.client.streamURL(
|
||
songId: song.id,
|
||
format: quality.format,
|
||
maxBitRate: quality.maxBitRate
|
||
)
|
||
if let url = streamURL {
|
||
playWithAVPlayer(url)
|
||
}
|
||
}
|
||
#else
|
||
let streamURL = ServerManager.shared.client.streamURL(
|
||
songId: song.id,
|
||
format: "mp3",
|
||
maxBitRate: 192
|
||
)
|
||
if let url = streamURL {
|
||
playWithAVPlayer(url)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// Scrobble
|
||
Task {
|
||
try? await ServerManager.shared.client.scrobble(id: song.id, submission: false)
|
||
}
|
||
|
||
// Pre-fetch next tracks for instant playback
|
||
#if os(iOS)
|
||
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
|
||
pushWidgetState()
|
||
#endif
|
||
}
|
||
|
||
/// Play a radio stream from a direct URL.
|
||
/// Automatically starts the stream buffer so timeshift is available immediately.
|
||
/// Destroys any existing buffer first to prevent audio bleed between stations.
|
||
func playRadio(song: Song, streamURL: URL) {
|
||
alog("Radio: \(song.title) → \(streamURL.absoluteString)")
|
||
currentSong = song
|
||
queue = [song]
|
||
queueIndex = 0
|
||
isRadioStream = true
|
||
radioStreamURL = streamURL
|
||
|
||
#if os(iOS)
|
||
// Always destroy the previous buffer cleanly before starting a new station.
|
||
// Prevents stale bytes / state from the old station bleeding into the new one.
|
||
RadioStreamBuffer.shared.stopBuffering()
|
||
|
||
if RadioStreamBuffer.isPlaylistURL(streamURL) {
|
||
alog("Radio: resolving playlist URL...")
|
||
Task {
|
||
let resolved = await RadioStreamBuffer.resolveStreamURL(streamURL)
|
||
await MainActor.run {
|
||
self.radioStreamURL = resolved
|
||
self.playWithAVPlayer(resolved)
|
||
// Auto-start buffer immediately after resolution (non-HLS only)
|
||
// isHLSStream is set inside startBuffering if the URL is M3U8
|
||
RadioStreamBuffer.shared.startBuffering(
|
||
url: resolved,
|
||
stationName: song.title
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
playWithAVPlayer(streamURL)
|
||
// Auto-start buffer — will mark isHLSStream=true and bail if it's HLS
|
||
RadioStreamBuffer.shared.startBuffering(url: streamURL, stationName: song.title)
|
||
}
|
||
#else
|
||
playWithAVPlayer(streamURL)
|
||
#endif
|
||
}
|
||
|
||
/// Whether current playback is a radio stream (for buffer/scrub behavior)
|
||
@Published var isRadioStream = false
|
||
private(set) var radioStreamURL: URL?
|
||
|
||
#if os(iOS)
|
||
/// Toggle recording the current radio stream on/off.
|
||
/// No-op for HLS streams or when not buffering.
|
||
func toggleRecording() {
|
||
let buf = RadioStreamBuffer.shared
|
||
if buf.isRecording {
|
||
_ = buf.stopRecording()
|
||
} else {
|
||
buf.startRecording()
|
||
}
|
||
}
|
||
#endif
|
||
|
||
#if os(iOS)
|
||
/// When true, AVPlayer is playing a snapshot file rather than the live stream
|
||
@Published private(set) var isPlayingFromBuffer = false
|
||
/// Current playback position within the buffer timeline (0 = start of buffer, bufferSec = live edge)
|
||
@Published var radioBufferPlayheadSec: TimeInterval = 0
|
||
|
||
private var currentSnapshotURL: URL?
|
||
private var snapshotStatusObservation: NSKeyValueObservation?
|
||
private var snapshotEndObserver: Any?
|
||
|
||
func radioSeekBack(to positionInBuffer: TimeInterval) {
|
||
guard isRadioStream else { return }
|
||
let buffer = RadioStreamBuffer.shared
|
||
guard let srcURL = buffer.bufferPlaybackURL else {
|
||
alog("Radio seek: no buffer file yet")
|
||
return
|
||
}
|
||
|
||
buffer.isLive = false
|
||
let seekTime = max(0.1, positionInBuffer)
|
||
let snapshotBufSec = buffer.estimatedBufferSeconds
|
||
|
||
// CRITICAL: use the correct audio extension (not .raw) so AVPlayer detects the format.
|
||
// .raw causes AVPlayer to fail format detection → status never becomes readyToPlay.
|
||
let ext = buffer.guessExtension()
|
||
let snapURL = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("radio_snap_\(UUID().uuidString).\(ext)")
|
||
|
||
// Clean up any previous snapshot resources
|
||
snapshotStatusObservation?.invalidate()
|
||
snapshotStatusObservation = nil
|
||
if let obs = snapshotEndObserver {
|
||
NotificationCenter.default.removeObserver(obs)
|
||
snapshotEndObserver = nil
|
||
}
|
||
if let old = currentSnapshotURL {
|
||
try? FileManager.default.removeItem(at: old)
|
||
currentSnapshotURL = nil
|
||
}
|
||
|
||
do {
|
||
try FileManager.default.copyItem(at: srcURL, to: snapURL)
|
||
} catch {
|
||
alog("Radio seek: snapshot copy failed — \(error.localizedDescription)")
|
||
return
|
||
}
|
||
currentSnapshotURL = snapURL
|
||
isPlayingFromBuffer = true
|
||
radioBufferPlayheadSec = seekTime
|
||
|
||
let asset = AVURLAsset(url: snapURL)
|
||
let item = AVPlayerItem(asset: asset)
|
||
|
||
// 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 position saves to once per 5 seconds.
|
||
// Only writes a single Double — queue shape is saved separately
|
||
// in play(song:) and notifyQueueChanged().
|
||
if Int(time.seconds * 10) % 50 == 0 {
|
||
PlaybackStateStore.shared.savePosition(time.seconds)
|
||
#if os(iOS)
|
||
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
|
||
#endif
|
||
}
|
||
#if os(iOS)
|
||
// Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval)
|
||
if Int(time.seconds * 10) % 5 == 0 {
|
||
LyricsManager.shared.syncToTime(time.seconds)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
|
||
nowPlayingSyncTimer?.invalidate()
|
||
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||
guard let self = self, self.isPlaying else { return }
|
||
self.updateNowPlayingInfo()
|
||
}
|
||
|
||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||
|
||
updateNowPlayingInfo()
|
||
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
||
// Radio streams use a smooth sinusoidal simulation — random phase
|
||
// simulation reacts to currentTime jumps (buffer seeks) causing
|
||
// the peakFollower to spike and "raise" the wave unexpectedly.
|
||
#if os(iOS)
|
||
if isRadioStream {
|
||
startRadioSimulation()
|
||
} else if !VisualizerSettings.shared.realAudioAnalysis {
|
||
// Simulation mode only — real analysis waits for offline vis data
|
||
startLevelSimulation()
|
||
}
|
||
// When realAudioAnalysis is on: levels stay at 0 until
|
||
// loadOfflineVisualizer → startOfflineVisSync fills them.
|
||
#else
|
||
startLevelSimulation()
|
||
#endif
|
||
}
|
||
|
||
// MARK: - AVAudioEngine Path (local files with real FFT)
|
||
|
||
private func playLocalWithEngine(_ url: URL) {
|
||
#if os(iOS)
|
||
alog("Engine path: \(url.lastPathComponent)")
|
||
activateAudioSession()
|
||
stopAll()
|
||
|
||
isUsingRealFFT = true
|
||
|
||
do {
|
||
let file = try AVAudioFile(forReading: url)
|
||
engineFile = file
|
||
|
||
let engine = AVAudioEngine()
|
||
let playerNode = AVAudioPlayerNode()
|
||
|
||
engine.attach(playerNode)
|
||
engine.connect(playerNode, to: engine.mainMixerNode, format: file.processingFormat)
|
||
|
||
// Install FFT tap on the mixer output
|
||
let format = engine.mainMixerNode.outputFormat(forBus: 0)
|
||
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
||
self?.processFFT(buffer: buffer)
|
||
}
|
||
|
||
try engine.start()
|
||
|
||
// Schedule file and play
|
||
scheduleGeneration += 1
|
||
let gen = scheduleGeneration
|
||
playerNode.scheduleFile(file, at: nil) { [weak self] in
|
||
DispatchQueue.main.async {
|
||
guard let self = self, self.scheduleGeneration == gen else { return }
|
||
self.playerDidFinishEngine()
|
||
}
|
||
}
|
||
playerNode.play()
|
||
|
||
self.audioEngine = engine
|
||
self.enginePlayerNode = playerNode
|
||
self.engineStartFrame = 0
|
||
isPlaying = true
|
||
|
||
// Duration from file
|
||
duration = Double(file.length) / file.processingFormat.sampleRate
|
||
|
||
// Time tracking for engine (accounts for seek offset)
|
||
engineTimeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||
guard let self = self, let node = self.enginePlayerNode,
|
||
let file = self.engineFile,
|
||
let nodeTime = node.lastRenderTime,
|
||
let playerTime = node.playerTime(forNodeTime: nodeTime) else { return }
|
||
let sampleRate = file.processingFormat.sampleRate
|
||
let offsetSeconds = Double(self.engineStartFrame) / sampleRate
|
||
self.currentTime = offsetSeconds + Double(playerTime.sampleTime) / playerTime.sampleRate
|
||
}
|
||
|
||
// Publish FFT levels at ~30fps (smoothing already applied in processFFT)
|
||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
let levels = self.rawFFTLevels
|
||
self.setLevels(levels)
|
||
}
|
||
|
||
updateNowPlayingInfo()
|
||
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
||
|
||
} catch {
|
||
alog("Engine failed: \(error.localizedDescription), falling back to AVPlayer")
|
||
playWithAVPlayer(url)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - FFT Processing
|
||
|
||
#if os(iOS)
|
||
private func processFFT(buffer: AVAudioPCMBuffer) {
|
||
guard let fftSetup = fftSetup else { return }
|
||
guard let channelData = buffer.floatChannelData?[0] else { return }
|
||
|
||
let frameCount = buffer.frameLength
|
||
let log2n: vDSP_Length = 10
|
||
guard frameCount >= fftSize else { return }
|
||
|
||
let halfSize = Int(fftSize / 2)
|
||
|
||
// 1. Apply pre-computed Hann window — no allocation, mutates pre-allocated scratch buffer
|
||
vDSP_vmul(channelData, 1, fftWindow, 1, &fftWindowed, 1, fftSize)
|
||
|
||
// 2. FFT using pre-allocated split-complex buffers
|
||
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
|
||
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
|
||
var splitComplex = DSPSplitComplex(
|
||
realp: realpBuf.baseAddress!,
|
||
imagp: imagpBuf.baseAddress!
|
||
)
|
||
fftWindowed.withUnsafeBytes { rawBuffer in
|
||
let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress!
|
||
vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize))
|
||
}
|
||
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD))
|
||
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
||
}
|
||
}
|
||
|
||
// 3. Normalize by N² and take sqrt for amplitude — in-place on pre-allocated buffer
|
||
let fftSizeF = Float(fftSize)
|
||
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
|
||
vDSP_vsmul(fftMagnitudes, 1, &scale, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
||
|
||
for i in 0..<halfSize {
|
||
fftMagnitudes[i] = sqrt(fftMagnitudes[i])
|
||
}
|
||
|
||
// 4. Push to rawFFTLevels — single array copy, no intermediate allocation
|
||
rawFFTLevels = fftMagnitudes
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Engine Finish
|
||
|
||
private func playerDidFinishEngine() {
|
||
if repeatMode == .one, let file = engineFile, let node = enginePlayerNode {
|
||
node.stop()
|
||
node.scheduleFile(file, at: nil) { [weak self] in
|
||
DispatchQueue.main.async { self?.playerDidFinishEngine() }
|
||
}
|
||
node.play()
|
||
} else {
|
||
next()
|
||
}
|
||
}
|
||
|
||
// MARK: - Transport
|
||
|
||
func togglePlayPause() {
|
||
if isPlaying {
|
||
pause()
|
||
} else {
|
||
resume()
|
||
}
|
||
}
|
||
|
||
func pause() {
|
||
#if os(iOS)
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.pause()
|
||
isPlaying = false
|
||
stopOfflineVisTimer()
|
||
stopLevelTimer()
|
||
updateNowPlayingInfo()
|
||
pushWidgetState()
|
||
return
|
||
}
|
||
#endif
|
||
player?.pause()
|
||
enginePlayerNode?.pause()
|
||
isPlaying = false
|
||
#if os(iOS)
|
||
stopOfflineVisTimer()
|
||
// Don't stop levelTimer — the simulation timer's !isPlaying branch decays
|
||
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
|
||
// so the Canvas keeps re-rendering and the wave visually decays during pause.
|
||
// For offline vis path the offlineVisTimer (restarted above) handles this.
|
||
internalLevels = Array(repeating: 0, count: 30)
|
||
pushWidgetState()
|
||
#endif
|
||
updateNowPlayingInfo()
|
||
}
|
||
|
||
func resume() {
|
||
#if os(iOS)
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.resume()
|
||
isPlaying = true
|
||
// offlineVisTimer was stopped on pause — restart it so currentLevels() advances
|
||
if isUsingOfflineVis { startOfflineVisSync() }
|
||
updateNowPlayingInfo()
|
||
pushWidgetState()
|
||
return
|
||
}
|
||
#endif
|
||
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
|
||
node.play()
|
||
} else {
|
||
player?.play()
|
||
}
|
||
isPlaying = true
|
||
#if os(iOS)
|
||
// Prime levels immediately before any timer fires.
|
||
// The CADisplayLink draws at 60fps; the level timers fire at 30fps.
|
||
// Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels
|
||
// (zeroed by pause) and the wave starts flat then slowly ramps up.
|
||
if isUsingOfflineVis {
|
||
DebugLogger.shared.log("resume: offlineVis path — startOfflineVisSync", category: "VisDebug")
|
||
// Offline vis: prime with the last-rendered frame at current position
|
||
if !offlineVisBuffer.isEmpty {
|
||
let dur = duration
|
||
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
|
||
let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1)
|
||
offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels)
|
||
levelTick &+= 1
|
||
}
|
||
startOfflineVisSync()
|
||
} else if isUsingRealFFT, !isUsingOfflineVis {
|
||
DebugLogger.shared.log("resume: realFFT path — rebuilding levelTimer", category: "VisDebug")
|
||
// Engine FFT path: rawFFTLevels still holds pre-pause data — push it immediately
|
||
setLevels(rawFFTLevels)
|
||
stopLevelTimer()
|
||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
self.setLevels(self.rawFFTLevels)
|
||
}
|
||
} else if !VisualizerSettings.shared.realAudioAnalysis {
|
||
DebugLogger.shared.log("resume: simulation path — levelTimer nil=\(levelTimer == nil)", category: "VisDebug")
|
||
// Simulation path: seed internalLevels from a fresh random target
|
||
// so the wave has height immediately rather than starting from floor.
|
||
lastTargetPhase = -1
|
||
targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) }
|
||
internalLevels = targetLevels.map { $0 * 0.8 }
|
||
setLevels(internalLevels)
|
||
if levelTimer == nil { startLevelSimulation() }
|
||
}
|
||
// else: realAudioAnalysis on, vis data loading — stay at 0
|
||
pushWidgetState()
|
||
#endif
|
||
updateNowPlayingInfo()
|
||
}
|
||
|
||
func next() {
|
||
guard !queue.isEmpty else { return }
|
||
if repeatMode == .all {
|
||
queueIndex = (queueIndex + 1) % queue.count
|
||
} else {
|
||
queueIndex = min(queueIndex + 1, queue.count - 1)
|
||
}
|
||
play(song: queue[queueIndex])
|
||
}
|
||
|
||
func previous() {
|
||
if currentTime > 3 {
|
||
seek(to: 0)
|
||
} else {
|
||
queueIndex = max(queueIndex - 1, 0)
|
||
play(song: queue[queueIndex])
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// Loads the next queue track into the crossfade standby player.
|
||
/// If no next track exists, SmartCrossfadeManager will self-crossfade.
|
||
private func prepareNextForCrossfade() {
|
||
let crossfade = SmartCrossfadeManager.shared
|
||
guard crossfade.isEnabled, !queue.isEmpty else { return }
|
||
|
||
// Wire the callback — called by finalizeCrossfade after every successful crossfade
|
||
crossfade.needsNextTrack = { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// Scrobble + currentSong + artwork already handled by songHandoff at midpoint.
|
||
// Here we only handle post-swap housekeeping.
|
||
|
||
// Ensure queueIndex is synced (songHandoff may have set it, but verify)
|
||
if let nowPlaying = crossfade.pendingNextSong,
|
||
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
|
||
self.queueIndex = idx
|
||
// Only update currentSong if songHandoff somehow didn't fire
|
||
if self.currentSong?.id != nowPlaying.id {
|
||
self.currentSong = nowPlaying
|
||
self.updateNowPlayingInfo()
|
||
self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt)
|
||
self.pushWidgetState()
|
||
}
|
||
} else {
|
||
// Fallback: queue was cleared or song removed — advance sequentially
|
||
let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count
|
||
if hasNext {
|
||
if self.repeatMode == .all {
|
||
self.queueIndex = (self.queueIndex + 1) % self.queue.count
|
||
} else {
|
||
self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1)
|
||
}
|
||
self.currentSong = self.queue[self.queueIndex]
|
||
self.updateNowPlayingInfo()
|
||
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
|
||
self.pushWidgetState()
|
||
}
|
||
}
|
||
|
||
// Persist updated queue position
|
||
PlaybackStateStore.shared.save(
|
||
queue: self.queue, index: self.queueIndex,
|
||
currentTime: 0, currentSongId: self.currentSong?.id
|
||
)
|
||
|
||
// Swap AudioPlayer.player to the new active crossfade player
|
||
self.player = crossfade.activePlayer
|
||
|
||
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
|
||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||
if let newItem = crossfade.activePlayer.currentItem {
|
||
NotificationCenter.default.addObserver(
|
||
self, selector: #selector(playerDidFinish),
|
||
name: .AVPlayerItemDidPlayToEndTime, object: newItem
|
||
)
|
||
}
|
||
|
||
// Prepare next-next (picks up any queue changes that happened mid-fade)
|
||
self.prepareNextForCrossfade()
|
||
}
|
||
|
||
// Determine what to pre-load in standby
|
||
let nextIdx: Int
|
||
if repeatMode == .all {
|
||
nextIdx = (queueIndex + 1) % queue.count
|
||
} else {
|
||
nextIdx = queueIndex + 1
|
||
guard nextIdx < queue.count else {
|
||
// No next track — SmartCrossfadeManager will self-crossfade
|
||
alog("SmartDJ: no next track — will self-crossfade")
|
||
return
|
||
}
|
||
}
|
||
|
||
let nextSong = queue[nextIdx]
|
||
|
||
// Guard: don't load the SAME song into the standby player.
|
||
// With repeat-all + single-song queue, nextIdx wraps to 0 (same song).
|
||
// Loading it into standby creates two AVPlayers with identical content —
|
||
// if the crossfade boundary fires, both play simultaneously and drift
|
||
// out of sync, producing the "duplicate audio" artifact.
|
||
if nextSong.id == currentSong?.id {
|
||
alog("SmartDJ: next is same song — skipping standby prep (repeat-one will handle)")
|
||
return
|
||
}
|
||
|
||
guard let url = crossfade.resolveURL(for: nextSong) else { return }
|
||
|
||
alog("SmartDJ: preparing \(nextSong.title) in standby")
|
||
crossfade.prepareNext(nextSong, url: url)
|
||
}
|
||
#endif
|
||
|
||
func seek(to time: TimeInterval) {
|
||
#if os(iOS)
|
||
if isUsingCrossfade {
|
||
SmartCrossfadeManager.shared.seek(to: time)
|
||
currentTime = time
|
||
updateNowPlayingInfo()
|
||
return
|
||
}
|
||
#endif
|
||
let wasPaused = !isPlaying
|
||
|
||
if let node = enginePlayerNode, let file = engineFile, let engine = audioEngine {
|
||
let sampleRate = file.processingFormat.sampleRate
|
||
let startFrame = AVAudioFramePosition(time * sampleRate)
|
||
let totalFrames = file.length
|
||
guard startFrame >= 0, startFrame < totalFrames else { return }
|
||
let remainingFrames = AVAudioFrameCount(totalFrames - startFrame)
|
||
|
||
scheduleGeneration += 1
|
||
let gen = scheduleGeneration
|
||
node.stop()
|
||
|
||
node.scheduleSegment(file, startingFrame: startFrame, frameCount: remainingFrames, at: nil) { [weak self] in
|
||
DispatchQueue.main.async {
|
||
guard let self = self, self.scheduleGeneration == gen else { return }
|
||
self.playerDidFinishEngine()
|
||
}
|
||
}
|
||
|
||
if engine.isRunning {
|
||
node.play()
|
||
// If we were paused, immediately pause again (we only played to set position)
|
||
if wasPaused {
|
||
node.pause()
|
||
}
|
||
}
|
||
|
||
currentTime = time
|
||
engineStartFrame = startFrame
|
||
updateNowPlayingInfo()
|
||
} else {
|
||
currentTime = time // Update immediately for responsive UI
|
||
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
|
||
player?.seek(to: cmTime) { [weak self] finished in
|
||
guard let self = self, finished else { return }
|
||
if wasPaused {
|
||
self.player?.pause()
|
||
}
|
||
self.updateNowPlayingInfo()
|
||
}
|
||
}
|
||
}
|
||
|
||
func seekToPercent(_ pct: Double) {
|
||
seek(to: duration * pct)
|
||
}
|
||
|
||
func toggleShuffle() {
|
||
shuffleEnabled.toggle()
|
||
if !queue.isEmpty {
|
||
if shuffleEnabled {
|
||
// Save current song, shuffle the rest
|
||
let current = queue[queueIndex]
|
||
var rest = queue
|
||
rest.remove(at: queueIndex)
|
||
rest.shuffle()
|
||
queue = [current] + rest
|
||
queueIndex = 0
|
||
} else {
|
||
// Unshuffle — restore original order would need storing it.
|
||
// For now, keep current order but ensure current song stays in place.
|
||
}
|
||
// Persist shuffled queue shape — time observer only saves position now
|
||
#if os(iOS)
|
||
PlaybackStateStore.shared.save(
|
||
queue: queue, index: queueIndex,
|
||
currentTime: currentTime, currentSongId: currentSong?.id
|
||
)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
func cycleRepeat() {
|
||
switch repeatMode {
|
||
case .off: repeatMode = .all
|
||
case .all: repeatMode = .one
|
||
case .one: repeatMode = .off
|
||
}
|
||
}
|
||
|
||
// MARK: - Queue Management
|
||
|
||
func playNext(_ song: Song) {
|
||
let insertAt = queueIndex + 1
|
||
if insertAt <= queue.count {
|
||
queue.insert(song, at: insertAt)
|
||
} else {
|
||
queue.append(song)
|
||
}
|
||
notifyQueueChanged()
|
||
}
|
||
|
||
func playLater(_ song: Song) {
|
||
queue.append(song)
|
||
notifyQueueChanged()
|
||
}
|
||
|
||
func playNow(_ song: Song) {
|
||
queue.insert(song, at: queueIndex + 1)
|
||
queueIndex += 1
|
||
play(song: song)
|
||
}
|
||
|
||
/// Move a song in the queue (for drag reorder)
|
||
func moveQueueItem(from source: IndexSet, to destination: Int) {
|
||
let oldIndex = queueIndex
|
||
let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil
|
||
|
||
queue.move(fromOffsets: source, toOffset: destination)
|
||
|
||
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
||
queueIndex = newIdx
|
||
}
|
||
notifyQueueChanged()
|
||
}
|
||
|
||
/// Remove a song from the queue
|
||
func removeFromQueue(at offsets: IndexSet) {
|
||
let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil
|
||
queue.remove(atOffsets: offsets)
|
||
|
||
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
||
queueIndex = newIdx
|
||
} else {
|
||
queueIndex = min(queueIndex, max(queue.count - 1, 0))
|
||
}
|
||
notifyQueueChanged()
|
||
}
|
||
|
||
/// Re-prepare crossfade when queue is modified (Play Next / Later / reorder).
|
||
private func notifyQueueChanged() {
|
||
#if os(iOS)
|
||
// Persist queue shape immediately so Play Next / Later / reorder / remove survives termination
|
||
PlaybackStateStore.shared.save(
|
||
queue: queue,
|
||
index: queueIndex,
|
||
currentTime: currentTime,
|
||
currentSongId: currentSong?.id
|
||
)
|
||
let crossfade = SmartCrossfadeManager.shared
|
||
guard crossfade.isEnabled, !queue.isEmpty else { return }
|
||
if crossfade.isCrossfading {
|
||
// Don't replace the standby item mid-fade — mark stale so finalizeCrossfade
|
||
// calls prepareNextForCrossfade() after the swap completes.
|
||
crossfade.queueChangedDuringFade = true
|
||
} else {
|
||
prepareNextForCrossfade()
|
||
}
|
||
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
|
||
#endif
|
||
}
|
||
|
||
func playInstantMix(basedOn song: Song) {
|
||
Task {
|
||
let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50)
|
||
if let songs = similar, !songs.isEmpty {
|
||
await MainActor.run {
|
||
self.play(song: songs[0], fromQueue: songs)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func playerDidFinish() {
|
||
// When crossfade is active, needsNextTrack() already scrobbled the outgoing song.
|
||
// playerDidFinish fires as a safety-net fallback (boundary observer miss) — skip
|
||
// scrobble here to avoid double-counting.
|
||
#if os(iOS)
|
||
if !isUsingCrossfade, let song = currentSong {
|
||
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
|
||
}
|
||
#else
|
||
if let song = currentSong {
|
||
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
|
||
}
|
||
#endif
|
||
|
||
if repeatMode == .one {
|
||
seek(to: 0)
|
||
player?.play()
|
||
} else {
|
||
next()
|
||
}
|
||
}
|
||
|
||
// MARK: - Now Playing Info Center
|
||
|
||
private func updateNowPlayingInfo() {
|
||
#if os(iOS)
|
||
var info: [String: Any] = [:]
|
||
info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown"
|
||
info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown"
|
||
info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? ""
|
||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||
info[MPMediaItemPropertyPlaybackDuration] = duration
|
||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||
if let artwork = cachedArtwork {
|
||
info[MPMediaItemPropertyArtwork] = artwork
|
||
}
|
||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||
#endif
|
||
}
|
||
|
||
private func fetchAndSetArtwork(coverArtId: String?) {
|
||
#if os(iOS)
|
||
guard let id = coverArtId else { return }
|
||
if id == cachedArtworkCoverArtId, cachedArtwork != nil { return }
|
||
|
||
// Check user-set custom cover first
|
||
if let customImage = AlbumCoverStore.shared.loadCover(for: id) {
|
||
let artwork = MPMediaItemArtwork(boundsSize: customImage.size) { _ in customImage }
|
||
self.cachedArtwork = artwork
|
||
self.cachedArtworkCoverArtId = id
|
||
self.updateNowPlayingInfo()
|
||
self.pushWidgetState()
|
||
return
|
||
}
|
||
|
||
guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) else { return }
|
||
|
||
if let cached = ImageCache.shared.cachedImage(for: url) {
|
||
let artwork = MPMediaItemArtwork(boundsSize: cached.size) { _ in cached }
|
||
self.cachedArtwork = artwork
|
||
self.cachedArtworkCoverArtId = id
|
||
self.updateNowPlayingInfo()
|
||
self.pushWidgetState()
|
||
return
|
||
}
|
||
|
||
Task {
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
guard let image = UIImage(data: data) else { return }
|
||
ImageCache.shared.store(image, for: url)
|
||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||
await MainActor.run {
|
||
self.cachedArtwork = artwork
|
||
self.cachedArtworkCoverArtId = id
|
||
self.updateNowPlayingInfo()
|
||
self.pushWidgetState()
|
||
}
|
||
} catch {
|
||
// Log the failure so it's visible in the debug console.
|
||
// Common cause: Tailscale reconnecting after network switch.
|
||
alog("Artwork fetch failed for \(id): \(error.localizedDescription)")
|
||
// Clear the cached ID so the next song play retries this artwork.
|
||
await MainActor.run { self.cachedArtworkCoverArtId = nil }
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func setupRemoteControls() {
|
||
#if os(iOS) || os(watchOS)
|
||
let center = MPRemoteCommandCenter.shared()
|
||
center.playCommand.addTarget { [weak self] _ in self?.resume(); return .success }
|
||
center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
||
center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
|
||
center.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success }
|
||
center.previousTrackCommand.addTarget { [weak self] _ in
|
||
self?.previous()
|
||
return .success
|
||
}
|
||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||
guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||
self?.seek(to: posEvent.positionTime)
|
||
return .success
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Offline Visualizer Pipeline
|
||
|
||
#if os(iOS)
|
||
/// Load or generate cached visualizer data for a downloaded song
|
||
private func loadOfflineVisualizer(songId: String, url: URL) {
|
||
stopOfflineVisTimer()
|
||
isUsingOfflineVis = false
|
||
offlineVisProgress = 0
|
||
offlineVisFPS = VisualizerSettings.shared.effectiveFPS
|
||
let points = VisualizerSettings.shared.nowPlaying.numberOfPoints
|
||
let cutoff = VisualizerSettings.shared.frequencyCutoff
|
||
|
||
analysisTask?.cancel()
|
||
|
||
// Don't start analysis if already in background — it will be triggered
|
||
// when the app returns to foreground instead (via resumeVisTimers path
|
||
// or next song load in foreground).
|
||
#if os(iOS)
|
||
guard UIApplication.shared.applicationState != .background else {
|
||
alog("Offline vis: skipping analysis in background for \(songId)")
|
||
return
|
||
}
|
||
#endif
|
||
|
||
analysisTask = Task {
|
||
let storage = VisualizerStorageManager.shared
|
||
let hasCache = await storage.hasCache(for: songId)
|
||
|
||
if hasCache {
|
||
alog("Offline vis: loading cache for \(songId)")
|
||
if let buffer = try? await storage.loadCache(for: songId) {
|
||
let normalized = Self.normalizeVisBuffer(buffer)
|
||
await MainActor.run {
|
||
self.offlineVisBuffer = normalized
|
||
self.isUsingOfflineVis = true
|
||
self.isUsingRealFFT = true
|
||
self.offlineVisProgress = 1.0
|
||
self.startOfflineVisSync()
|
||
}
|
||
}
|
||
} else {
|
||
// Try Companion API first (pre-computed server frames)
|
||
var fetched = false
|
||
if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path {
|
||
do {
|
||
if let serverFrames = try await CompanionAPIService.shared.fetchVisualizerFrames(relativePath: path) {
|
||
try? await storage.saveCache(frames: serverFrames, for: songId)
|
||
// Convert [[Float]] to flat buffer then normalize
|
||
let ppf = serverFrames.first?.count ?? 0
|
||
var flat = ContiguousArray<Float>()
|
||
flat.reserveCapacity(serverFrames.count * ppf)
|
||
for frame in serverFrames { flat.append(contentsOf: frame) }
|
||
let rawBuf = VisFrameBuffer(data: flat, frameCount: serverFrames.count, pointsPerFrame: ppf)
|
||
let normalized = Self.normalizeVisBuffer(rawBuf)
|
||
alog("Offline vis: fetched \(serverFrames.count) frames from server")
|
||
await MainActor.run {
|
||
self.offlineVisBuffer = normalized
|
||
self.isUsingOfflineVis = true
|
||
self.isUsingRealFFT = true
|
||
self.offlineVisProgress = 1.0
|
||
self.startOfflineVisSync()
|
||
}
|
||
fetched = true
|
||
}
|
||
} catch {
|
||
alog("Offline vis: server fetch failed, will analyze locally")
|
||
}
|
||
}
|
||
|
||
if !fetched {
|
||
alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...")
|
||
let song = self.currentSong
|
||
do {
|
||
// ── Combined pass: vis frames + SmartDJ profile in one read ─
|
||
// extractSmartDJ only if Companion is disabled or unavailable AND
|
||
// SmartDJ crossfade is enabled so we actually need the profile.
|
||
let needsSmartDJ = CompanionSettings.shared.smartDJEnabled
|
||
&& !CompanionSettings.shared.isEnabled
|
||
&& song?.path != nil
|
||
&& SmartDJCache.shared.get(song?.path ?? "") == nil
|
||
|
||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||
url: url,
|
||
pointsCount: points,
|
||
fps: offlineVisFPS,
|
||
cutoff: cutoff,
|
||
extractSmartDJ: needsSmartDJ
|
||
) { [weak self] pct in
|
||
Task { @MainActor in self?.offlineVisProgress = pct }
|
||
}
|
||
|
||
// Seed SmartDJ cache from on-device analysis if we extracted it
|
||
if needsSmartDJ, let path = song?.path {
|
||
let profile = SmartDJProfile(
|
||
bpm: nil,
|
||
silenceStart: result.silenceStart,
|
||
silenceEnd: result.silenceEnd,
|
||
loudnessLUFS: result.loudnessLUFS
|
||
)
|
||
SmartDJCache.shared.store(profile, for: path)
|
||
alog("Offline vis: seeded on-device SmartDJ profile for \(path) " +
|
||
"(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " +
|
||
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
|
||
"trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)")
|
||
}
|
||
|
||
let frames = result.visFrames
|
||
try? await storage.saveCache(frames: frames, for: songId)
|
||
// Convert [[Float]] to flat VisFrameBuffer then normalize
|
||
let ppf = frames.first?.count ?? 0
|
||
var flat = ContiguousArray<Float>()
|
||
flat.reserveCapacity(frames.count * ppf)
|
||
for frame in frames { flat.append(contentsOf: frame) }
|
||
let rawBuf = VisFrameBuffer(data: flat, frameCount: frames.count, pointsPerFrame: ppf)
|
||
let normalized = Self.normalizeVisBuffer(rawBuf)
|
||
alog("Offline vis: cached \(frames.count) frames for \(songId)")
|
||
|
||
await MainActor.run {
|
||
self.offlineVisBuffer = normalized
|
||
self.isUsingOfflineVis = true
|
||
self.isUsingRealFFT = true
|
||
self.offlineVisProgress = 1.0
|
||
self.startOfflineVisSync()
|
||
}
|
||
} catch {
|
||
alog("Offline vis: analysis failed: \(error.localizedDescription)")
|
||
// Don't fall back to simulation — levels stay at 0
|
||
}
|
||
} // end if !fetched
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Normalize vis frames so the wave is always visible regardless of song loudness.
|
||
/// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping.
|
||
/// Normalize a VisFrameBuffer to p95 amplitude in-place.
|
||
/// One sort + one pass — no [[Float]] allocation.
|
||
private static func normalizeVisBuffer(_ buffer: VisFrameBuffer) -> VisFrameBuffer {
|
||
guard buffer.frameCount > 0 else { return buffer }
|
||
|
||
// Collect non-zero values to find p95
|
||
var vals = buffer.data.filter { $0 > 0.001 }
|
||
guard !vals.isEmpty else { return buffer }
|
||
vals.sort()
|
||
let p95 = vals[min(Int(Float(vals.count) * 0.95), vals.count - 1)]
|
||
guard p95 > 0.001 else { return buffer }
|
||
|
||
let scale = Float(0.8) / p95
|
||
var normalized = buffer.data // single ContiguousArray copy
|
||
for i in 0..<normalized.count {
|
||
normalized[i] = min(1.0, normalized[i] * scale)
|
||
}
|
||
return VisFrameBuffer(
|
||
data: normalized,
|
||
frameCount: buffer.frameCount,
|
||
pointsPerFrame: buffer.pointsPerFrame
|
||
)
|
||
}
|
||
|
||
/// Sync cached frames to current playback time.
|
||
/// Uses proportional mapping (time/duration → frame index) so it works
|
||
/// regardless of what FPS the frames were analyzed at.
|
||
private func startOfflineVisSync() {
|
||
stopOfflineVisTimer()
|
||
stopLevelTimer()
|
||
|
||
let frameCount = offlineVisBuffer.frameCount
|
||
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
|
||
|
||
if duration <= 0 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||
self?.startOfflineVisSyncTimer()
|
||
}
|
||
} else {
|
||
startOfflineVisSyncTimer()
|
||
}
|
||
}
|
||
|
||
private func startOfflineVisSyncTimer() {
|
||
stopOfflineVisTimer()
|
||
|
||
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||
guard let self = self, self.isUsingOfflineVis else { return }
|
||
guard self.isPlaying else {
|
||
// Zero out levels so the visualizer decays during pause
|
||
if self._audioLevels.contains(where: { $0 > 0.005 }) {
|
||
for i in 0..<self._audioLevels.count { self._audioLevels[i] = 0 }
|
||
self.levelTick &+= 1
|
||
}
|
||
return
|
||
}
|
||
|
||
let buf = self.offlineVisBuffer
|
||
guard buf.frameCount > 0 else { return }
|
||
|
||
let dur = self.duration
|
||
let time = self.currentTime
|
||
|
||
let frameIndex: Int
|
||
if dur > 0 {
|
||
let pct = min(time / dur, 1.0)
|
||
frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1)
|
||
} else {
|
||
frameIndex = min(Int(time * 30.0), buf.frameCount - 1)
|
||
}
|
||
|
||
// Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels
|
||
buf.copyFrame(at: frameIndex, into: &self._audioLevels)
|
||
self.levelTick &+= 1
|
||
}
|
||
}
|
||
|
||
private func stopOfflineVisTimer() {
|
||
offlineVisTimer?.invalidate()
|
||
offlineVisTimer = nil
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Simulated Level Animation (for streams)
|
||
|
||
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
|
||
/// Radio streams can't be pre-analyzed and their AVPlayer currentTime can
|
||
/// jump on buffer seeks — using the same random-phase simulation as music
|
||
/// causes the peakFollower in the visualizer to spike and "raise" the wave.
|
||
/// This uses a time-based sine wave that's completely independent of
|
||
/// currentTime, so buffer resets and seek-backs have no visual effect.
|
||
private func startRadioSimulation() {
|
||
stopLevelTimer()
|
||
var radioPhase: Double = 0
|
||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
#if os(iOS)
|
||
guard VisualizerSettings.shared.enabled else { return }
|
||
#endif
|
||
guard self.isPlaying else {
|
||
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
||
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
return
|
||
}
|
||
|
||
radioPhase += 1.0 / 30.0
|
||
|
||
// Smooth sine wave across the frequency bands — gentle, never spikes.
|
||
// Base amplitude 0.35, modulated by a slow breathing envelope (0.15 range)
|
||
// so it looks alive without jarring jumps.
|
||
let count = self.internalLevels.count
|
||
let breathe = Float(0.35 + 0.15 * sin(radioPhase * 0.4))
|
||
for i in 0..<count {
|
||
let x = Float(i) / Float(max(count - 1, 1))
|
||
// Two overlapping sine waves at different frequencies for organic shape
|
||
let wave1 = sin(Float(radioPhase * 1.1) + x * .pi * 2.0)
|
||
let wave2 = sin(Float(radioPhase * 0.7) + x * .pi * 3.5) * 0.4
|
||
self.internalLevels[i] = breathe * (0.5 + 0.5 * (wave1 + wave2) / 1.4)
|
||
}
|
||
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
}
|
||
|
||
private func startLevelSimulation() {
|
||
stopLevelTimer()
|
||
lastTargetPhase = -1
|
||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
// Skip all work when visualizer is disabled — avoids 60fps float math
|
||
// and setLevels() calls that serve no purpose with no Canvas reading them.
|
||
#if os(iOS)
|
||
guard VisualizerSettings.shared.enabled else { return }
|
||
#endif
|
||
guard self.isPlaying else {
|
||
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
||
for i in 0..<self.internalLevels.count {
|
||
self.internalLevels[i] = 0
|
||
}
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
return
|
||
}
|
||
|
||
let time = self.currentTime
|
||
let phase = Int(time * 12)
|
||
if phase != self.lastTargetPhase {
|
||
self.lastTargetPhase = phase
|
||
// In-place random target — no allocation
|
||
for i in 0..<self.targetLevels.count {
|
||
self.targetLevels[i] = Float.random(in: 0.1...0.85)
|
||
}
|
||
}
|
||
|
||
// In-place smooth — no zip/map allocation
|
||
let smoothing: Float = 0.15
|
||
for i in 0..<self.internalLevels.count {
|
||
self.internalLevels[i] += (self.targetLevels[i] - self.internalLevels[i]) * smoothing
|
||
}
|
||
|
||
self.publishCounter += 1
|
||
if self.publishCounter % 2 == 0 {
|
||
self.setLevels(self.internalLevels)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Widget State Push
|
||
|
||
#if os(iOS)
|
||
/// Push current playback state to the widget via App Group.
|
||
/// Called on song change, play/pause, and artwork load.
|
||
private func pushWidgetState() {
|
||
guard let song = currentSong else { return }
|
||
|
||
// Notify LyricsManager of the current song (deduplicates internally by song ID)
|
||
LyricsManager.shared.songChanged(
|
||
songId: song.id,
|
||
artist: song.artist ?? "Unknown",
|
||
title: song.title,
|
||
path: song.path,
|
||
duration: duration
|
||
)
|
||
|
||
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||
let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in
|
||
var artData: Data?
|
||
if let id = s.coverArt {
|
||
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
||
// Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px)
|
||
artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))?
|
||
.jpegData(compressionQuality: 0.5)
|
||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) {
|
||
artData = ImageCache.shared.cachedImage(for: url)?
|
||
.jpegData(compressionQuality: 0.5)
|
||
}
|
||
}
|
||
return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData)
|
||
}
|
||
|
||
// Cover art: custom first, then server (memory + disk).
|
||
var coverImage: UIImage?
|
||
var artKey = song.coverArt
|
||
if let id = song.coverArt {
|
||
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
||
coverImage = custom
|
||
artKey = "custom_\(id)"
|
||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) {
|
||
coverImage = ImageCache.shared.cachedImage(for: url)
|
||
}
|
||
}
|
||
|
||
// Waveform: downsample offline vis data to 40 peaks, or pseudo-random fallback.
|
||
let waveform = sampleWaveform(sampleCount: 40)
|
||
|
||
// Crossfade trigger time from Smart DJ profile (seconds into track).
|
||
let crossfadeTime: TimeInterval? = {
|
||
if isUsingCrossfade,
|
||
let ss = SmartCrossfadeManager.shared.currentProfile?.silenceStart,
|
||
ss > 0 { return ss }
|
||
return nil
|
||
}()
|
||
|
||
WidgetBridge.shared.updateNowPlaying(
|
||
title: song.title,
|
||
artist: song.artist ?? "Unknown",
|
||
album: song.album ?? "",
|
||
isPlaying: isPlaying,
|
||
currentTime: currentTime,
|
||
duration: duration,
|
||
coverArtId: artKey,
|
||
coverArtImage: coverImage,
|
||
queue: upcoming,
|
||
waveformSamples: waveform,
|
||
crossfadeAt: crossfadeTime
|
||
)
|
||
}
|
||
|
||
/// Downsample the offline vis buffer to `sampleCount` peak amplitudes (0.0–1.0).
|
||
/// If no vis data is loaded, returns a seeded pseudo-random shape per song ID.
|
||
private func sampleWaveform(sampleCount: Int = 40) -> [Float] {
|
||
let buf = offlineVisBuffer
|
||
if !buf.isEmpty {
|
||
var samples = [Float]()
|
||
samples.reserveCapacity(sampleCount)
|
||
for i in 0..<sampleCount {
|
||
let startFrame = i * buf.frameCount / sampleCount
|
||
let endFrame = min((i + 1) * buf.frameCount / sampleCount, buf.frameCount)
|
||
var peak: Float = 0
|
||
for f in startFrame..<endFrame {
|
||
let offset = f * buf.pointsPerFrame
|
||
let end = min(offset + buf.pointsPerFrame, buf.data.count)
|
||
for j in offset..<end {
|
||
peak = max(peak, buf.data[j])
|
||
}
|
||
}
|
||
samples.append(peak)
|
||
}
|
||
return samples
|
||
}
|
||
|
||
// Fallback: seeded PRNG for consistent shape per song
|
||
guard let id = currentSong?.id else {
|
||
return [Float](repeating: 0.3, count: sampleCount)
|
||
}
|
||
var seed: UInt64 = 5381
|
||
for c in id.utf8 { seed = seed &* 33 &+ UInt64(c) }
|
||
var samples = [Float]()
|
||
samples.reserveCapacity(sampleCount)
|
||
for i in 0..<sampleCount {
|
||
seed = (seed &* 16807 &+ 7) % 2147483647
|
||
let base = Float(seed % 1000) / 1000.0
|
||
let envelope = 0.5 + 0.5 * sin(Float.pi * Float(i) / Float(sampleCount))
|
||
samples.append(min(0.2 + base * 0.6 * envelope, 1.0))
|
||
}
|
||
return samples
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Cleanup
|
||
|
||
private func stopLevelTimer() {
|
||
levelTimer?.invalidate()
|
||
levelTimer = nil
|
||
}
|
||
|
||
private func stopEngineTimer() {
|
||
engineTimeTimer?.invalidate()
|
||
engineTimeTimer = nil
|
||
}
|
||
|
||
private func stopEngine() {
|
||
scheduleGeneration += 1 // invalidate all pending completions
|
||
enginePlayerNode?.stop()
|
||
audioEngine?.mainMixerNode.removeTap(onBus: 0)
|
||
audioEngine?.stop()
|
||
audioEngine = nil
|
||
enginePlayerNode = nil
|
||
engineFile = nil
|
||
stopEngineTimer()
|
||
}
|
||
|
||
private func stopAVPlayer() {
|
||
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
|
||
}
|
||
// Zero the vis buffer so stale frames from the previous song don't render
|
||
// while the new song is loading. The new loadOfflineVisualizer() call will
|
||
// repopulate it once analysis finishes.
|
||
offlineVisBuffer = VisFrameBuffer.empty
|
||
#endif
|
||
stopEngine()
|
||
stopAVPlayer()
|
||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||
// Zero audio levels immediately — prevents the Canvas from painting stale
|
||
// FFT/vis data during track transitions (the "lifted bars" bug).
|
||
setLevels(Array(repeating: 0, count: _audioLevels.count))
|
||
}
|
||
|
||
func stop() {
|
||
stopAll()
|
||
isPlaying = false
|
||
currentSong = nil
|
||
setLevels(Array(repeating: 0, count: 30))
|
||
internalLevels = Array(repeating: 0, count: 30)
|
||
rawFFTLevels = Array(repeating: 0, count: 512)
|
||
isUsingRealFFT = false
|
||
isUsingOfflineVis = false
|
||
#if os(iOS)
|
||
offlineVisBuffer = VisFrameBuffer.empty
|
||
WidgetBridge.shared.clear()
|
||
#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()
|
||
}
|
||
}
|