import Foundation import AVFoundation import Combine import QuartzCore // MARK: - Smart Crossfade Manager // Dual-AVPlayer (A/B) crossfade engine. // // Profile fields (from Companion API): // silence_start = where TRAILING silence begins (crossfade trigger point) // silence_end = where LEADING silence ends (seek-to point for skip) // loudness_lufs = integrated loudness for volume normalization // // Flow: // 1. playSong() → loads item, starts playback IMMEDIATELY, then fetches profile async // 2. Profile arrives → applies LUFS volume, installs boundary observer at silence_start - fade // 3. Boundary fires → beginCrossfade() fades A↓ B↑ via CADisplayLink // 4. Fade completes → swap slots, install new boundary, request next track class SmartCrossfadeManager: ObservableObject { static let shared = SmartCrossfadeManager() // MARK: - Settings (persisted) @Published var isEnabled = false { didSet { UserDefaults.standard.set(isEnabled, forKey: "smart_crossfade_enabled") } } @Published var crossfadeDuration: TimeInterval = 5.0 { didSet { UserDefaults.standard.set(crossfadeDuration, forKey: "smart_crossfade_duration") } } @Published var targetLUFS: Double = -14.0 { didSet { UserDefaults.standard.set(targetLUFS, forKey: "smart_target_lufs") } } @Published var skipSilence: Bool = true { didSet { UserDefaults.standard.set(skipSilence, forKey: "smart_skip_silence") } } // MARK: - State @Published var currentProfile: SmartDJProfile? @Published var nextProfile: SmartDJProfile? @Published var isCrossfading = false // MARK: - Dual Players private(set) var playerA: AVPlayer = AVPlayer() private(set) var playerB: AVPlayer = AVPlayer() private var activeSlot: Slot = .a var activePlayer: AVPlayer { activeSlot == .a ? playerA : playerB } var standbyPlayer: AVPlayer { activeSlot == .a ? playerB : playerA } enum Slot { case a, b } // MARK: - Volume Targets (LUFS-normalized) private var activeMaxVolume: Float = 1.0 private var standbyMaxVolume: Float = 1.0 // MARK: - Observers private var boundaryObserver: Any? private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? // MARK: - Crossfade Animation private var displayLink: CADisplayLink? private var fadeStartTime: CFTimeInterval = 0 private var fadeOutStartVolume: Float = 1.0 private var fadeInTargetVolume: Float = 1.0 private var didHandoffVisualizer = false // MARK: - Track Info (for self-crossfade) private var currentSongURL: URL? private var currentSong: Song? // MARK: - Profile Cache private var profiles: [String: SmartDJProfile] = [:] private let api = CompanionAPIService() // MARK: - Callbacks var needsNextTrack: (() -> Void)? var timeUpdate: ((TimeInterval, TimeInterval) -> Void)? var visualizerHandoff: ((AVPlayer) -> Void)? private init() { isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled") let dur = UserDefaults.standard.double(forKey: "smart_crossfade_duration") crossfadeDuration = dur > 0 ? dur : 5.0 let lufs = UserDefaults.standard.double(forKey: "smart_target_lufs") targetLUFS = lufs < 0 ? lufs : -14.0 skipSilence = UserDefaults.standard.object(forKey: "smart_skip_silence") as? Bool ?? true } // ═══════════════════════════════════════════════════════ // MARK: - PUBLIC API // ═══════════════════════════════════════════════════════ /// Load and play a song on the active player. /// Starts playback IMMEDIATELY — profile adjustments arrive async. func playSong(_ song: Song, url: URL) { log("▶ Playing: \(song.title) on \(slotName(activeSlot))") cancelCrossfade() removeAllObservers() currentSong = song currentSongURL = url // Load and play immediately — don't wait for profile let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) activePlayer.replaceCurrentItem(with: item) activePlayer.volume = 1.0 activeMaxVolume = 1.0 activePlayer.play() // Install time observer right away for seek bar installTimeObserver() // Fetch profile async — apply adjustments when it arrives Task { @MainActor [weak self] in guard let self else { return } let profile = await self.fetchProfile(for: song) self.currentProfile = profile // Apply LUFS normalization self.activeMaxVolume = self.lufsToVolume(profile?.loudnessLUFS) // Only adjust volume if not already crossfading if !self.isCrossfading { self.activePlayer.volume = self.activeMaxVolume } // Skip leading silence (silenceEnd = where leading silence ends) if self.skipSilence, let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { let current = self.activePlayer.currentTime().seconds // Only seek if we're still near the start if current < silEnd + 1.0 { await self.activePlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000)) self.log("Skipped \(String(format: "%.1f", silEnd))s leading silence") } } // Install boundary observer for crossfade trigger self.installCrossfadeTrigger(profile: profile) } } /// Pre-load the next song into standby (paused, vol 0, seeked past leading silence). func prepareNext(_ song: Song, url: URL) { log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))") let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) standbyPlayer.replaceCurrentItem(with: item) standbyPlayer.volume = 0.0 standbyPlayer.pause() Task { @MainActor [weak self] in guard let self else { return } let profile = await self.fetchProfile(for: song) self.nextProfile = profile self.standbyMaxVolume = self.lufsToVolume(profile?.loudnessLUFS) // Wait for item to be ready before seeking if let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { let seekTarget = CMTime(seconds: silEnd, preferredTimescale: 1000) let standby = self.standbyPlayer self.statusObservation = item.observe(\.status) { [weak self] observedItem, _ in guard observedItem.status == .readyToPlay else { return } DispatchQueue.main.async { [weak self] in self?.statusObservation?.invalidate() self?.statusObservation = nil standby.seek(to: seekTarget) } } } } } func pause() { activePlayer.pause() if isCrossfading { standbyPlayer.pause() } } func resume() { activePlayer.play() if isCrossfading { standbyPlayer.play() } } func seek(to time: TimeInterval) { let cmTime = CMTime(seconds: time, preferredTimescale: 1000) activePlayer.seek(to: cmTime) } func stop() { cancelCrossfade() removeAllObservers() playerA.pause() playerA.replaceCurrentItem(with: nil) playerB.pause() playerB.replaceCurrentItem(with: nil) activeSlot = .a currentProfile = nil nextProfile = nil currentSong = nil currentSongURL = nil activeMaxVolume = 1.0 standbyMaxVolume = 1.0 } // ═══════════════════════════════════════════════════════ // MARK: - LUFS → Volume // ═══════════════════════════════════════════════════════ private func lufsToVolume(_ lufs: Double?) -> Float { guard let lufs = lufs, lufs < 0 else { return 1.0 } let gainDB = targetLUFS - lufs let linear = pow(10.0, gainDB / 20.0) let clamped = Float(min(max(linear, 0.05), 1.0)) log("LUFS \(String(format: "%.1f", lufs)) → vol \(String(format: "%.3f", clamped))") return clamped } // ═══════════════════════════════════════════════════════ // MARK: - CROSSFADE TRIGGER // ═══════════════════════════════════════════════════════ /// Install a boundary time observer to trigger crossfade. /// Uses silenceStart (trailing silence begin) as the end-of-content marker. /// Falls back to duration - crossfadeDuration if no profile. private func installCrossfadeTrigger(profile: SmartDJProfile?) { removeBoundaryObserver() guard let item = activePlayer.currentItem else { return } let duration = item.duration.seconds // If duration isn't ready yet, poll until it is guard duration.isFinite, duration > 0 else { let pollObs = activePlayer.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] _ in guard let self else { return } let dur = self.activePlayer.currentItem?.duration.seconds ?? 0 if dur.isFinite, dur > 0 { // Duration ready — remove poll, install real trigger if let obs = self.boundaryObserver { self.activePlayer.removeTimeObserver(obs) self.boundaryObserver = nil } self.installCrossfadeTriggerWithDuration(dur, profile: profile) } } boundaryObserver = pollObs return } installCrossfadeTriggerWithDuration(duration, profile: profile) } private func installCrossfadeTriggerWithDuration(_ duration: TimeInterval, profile: SmartDJProfile?) { // silenceStart = where trailing silence begins let silenceStart = profile?.silenceStart ?? 0 let contentEnd: TimeInterval // Valid if it's in the second half of the track if silenceStart > duration * 0.5 && silenceStart <= duration { contentEnd = silenceStart } else { // No valid trailing silence — use full duration contentEnd = duration } // Trigger = content end minus crossfade duration (min 1s from start) let triggerTime = max(1.0, contentEnd - crossfadeDuration) log("🎯 Trigger at \(String(format: "%.1f", triggerTime))s " + "(content \(String(format: "%.1f", contentEnd))s, " + "dur \(String(format: "%.1f", duration))s)") let boundary = CMTime(seconds: triggerTime, preferredTimescale: 1000) boundaryObserver = activePlayer.addBoundaryTimeObserver( forTimes: [NSValue(time: boundary)], queue: .main ) { [weak self] in self?.beginCrossfade() } } private func removeBoundaryObserver() { if let obs = boundaryObserver { activePlayer.removeTimeObserver(obs) boundaryObserver = nil } } // ═══════════════════════════════════════════════════════ // MARK: - TIME OBSERVER // ═══════════════════════════════════════════════════════ private func installTimeObserver() { removeTimeObserver() let interval = CMTime(seconds: 0.1, preferredTimescale: 600) timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self else { return } // During crossfade past midpoint, report from the INCOMING player // so the seek bar shows the new track's position, not -0:00 let reportPlayer: AVPlayer if self.isCrossfading && self.didHandoffVisualizer { reportPlayer = self.standbyPlayer } else { reportPlayer = self.activePlayer } let current = reportPlayer.currentTime().seconds guard current.isFinite else { return } let dur = reportPlayer.currentItem?.duration.seconds ?? 0 if dur.isFinite, dur > 0 { self.timeUpdate?(current, dur) } } } private func removeTimeObserver() { if let obs = timeObserver { activePlayer.removeTimeObserver(obs) timeObserver = nil } } private func removeAllObservers() { removeBoundaryObserver() removeTimeObserver() statusObservation?.invalidate() statusObservation = nil } // ═══════════════════════════════════════════════════════ // MARK: - CROSSFADE EXECUTION // ═══════════════════════════════════════════════════════ private func beginCrossfade() { guard !isCrossfading else { return } // If standby has nothing, load self-crossfade if standbyPlayer.currentItem == nil { guard let url = currentSongURL else { log("⚠️ No standby and no current URL") return } log("🔁 No next track — self-crossfade") let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) standbyPlayer.replaceCurrentItem(with: item) standbyMaxVolume = activeMaxVolume nextProfile = currentProfile // Seek past leading silence if skipSilence, let silEnd = currentProfile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { standbyPlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000)) } } log("🔀 BEGIN (\(String(format: "%.1f", crossfadeDuration))s)") isCrossfading = true didHandoffVisualizer = false fadeOutStartVolume = activePlayer.volume fadeInTargetVolume = standbyMaxVolume standbyPlayer.volume = 0.0 standbyPlayer.play() fadeStartTime = CACurrentMediaTime() displayLink = CADisplayLink(target: self, selector: #selector(crossfadeTick)) displayLink?.add(to: .main, forMode: .common) } @objc private func crossfadeTick() { let elapsed = CACurrentMediaTime() - fadeStartTime let progress = Float(min(elapsed / crossfadeDuration, 1.0)) // Equal-power crossfade let fadeOut = cosf(progress * .pi / 2.0) let fadeIn = sinf(progress * .pi / 2.0) activePlayer.volume = fadeOutStartVolume * fadeOut standbyPlayer.volume = fadeInTargetVolume * fadeIn if progress >= 0.5 && !didHandoffVisualizer { didHandoffVisualizer = true visualizerHandoff?(standbyPlayer) } if progress >= 1.0 { finalizeCrossfade() } } private func finalizeCrossfade() { displayLink?.invalidate() displayLink = nil let outgoing = activePlayer outgoing.pause() outgoing.replaceCurrentItem(with: nil) removeAllObservers() activeSlot = (activeSlot == .a) ? .b : .a currentProfile = nextProfile nextProfile = nil activeMaxVolume = standbyMaxVolume standbyMaxVolume = 1.0 activePlayer.volume = activeMaxVolume isCrossfading = false log("🔀 DONE → \(slotName(activeSlot))") installTimeObserver() installCrossfadeTrigger(profile: currentProfile) needsNextTrack?() } private func cancelCrossfade() { displayLink?.invalidate() displayLink = nil isCrossfading = false didHandoffVisualizer = false } // ═══════════════════════════════════════════════════════ // MARK: - PROFILE FETCHING // ═══════════════════════════════════════════════════════ private func fetchProfile(for song: Song) async -> SmartDJProfile? { guard CompanionSettings.shared.smartDJEnabled else { return nil } guard let path = song.path else { return nil } if let cached = profiles[song.id] { return cached } do { let profile = try await api.fetchProfile(relativePath: path) profiles[song.id] = profile log("Profile: trailing=\(profile.silenceStart ?? 0)s, " + "leading=\(profile.silenceEnd ?? 0)s, " + "LUFS=\(profile.loudnessLUFS ?? 0)") return profile } catch { log("Profile failed: \(error.localizedDescription)") return nil } } // ═══════════════════════════════════════════════════════ // MARK: - HELPERS // ═══════════════════════════════════════════════════════ func resolveURL(for song: Song) -> URL? { if let localURL = OfflineManager.shared.localURL(for: song.id) { return localURL } let quality = StreamingQuality.shared return ServerManager.shared.client.streamURL( songId: song.id, format: quality.format, maxBitRate: quality.maxBitRate ) } private func slotName(_ slot: Slot) -> String { slot == .a ? "A" : "B" } private func log(_ msg: String) { DebugLogger.shared.log(msg, category: "SmartDJ") } } // MARK: - Notification extension Notification.Name { static let smartDJNeedsNextTrack = Notification.Name("smartDJNeedsNextTrack") }