Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
487 lines
19 KiB
Swift
487 lines
19 KiB
Swift
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")
|
|
}
|