Files
KMix_Pro/KJamsClone/DJWorkstation.swift
T
2026-11-05 13:42:20 +00:00

1182 lines
77 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AVFoundation
import CoreAudio
import CoreMIDI
import UniformTypeIdentifiers
import Combine
/// Equal-power crossfade, independent of the karaoke master and singer preferences.
enum DJMix {
static func weights(_ position: Double,curve: String = "Equal power") -> (Double, Double) {
let x = position.isFinite ? min(1,max(0,position)) : 0.5
if curve == "Linear" { return (1-x,x) }
return (cos(x * .pi / 2),sin(x * .pi / 2))
}
static func syncRate(bpm: Double, otherBPM: Double, otherRate: Double) -> Double? {
guard bpm.isFinite,otherBPM.isFinite,otherRate.isFinite,bpm >= 40,bpm <= 240,otherBPM >= 40,otherBPM <= 240 else { return nil }
let result = otherBPM * otherRate / bpm
return (0.5...2).contains(result) ? result : nil
}
}
struct DJPreferences: Codable, Equatable {
var autoPlayOnLoad = false
var resetEQOnLoad = false
var protectPlayingDeck = true
var tempoRange: Double = 16
var invertTempo = true
var crossfaderCurve = "Equal power"
var headroomDB: Double = 0
var spectralWaveforms = true
var waveformSeconds: Double = 8
var showTimeGrid = true
var platterMarker = true
var dimInactiveDeck = false
var libraryFont: Double = 11
var libraryRowHeight: Double = 28
var autoPlayHotCue = false
func tempo(at position: Double) -> Double { let x = min(1,max(0,position));return 1+((invertTempo ? 1-x : x)*2-1)*tempoRange/100 }
func position(for tempo: Double) -> Double { let x = min(1,max(0,(tempo-1)/(2*tempoRange/100)+0.5));return invertTempo ? 1-x : x }
var sanitized: Self {
var p = self;p.tempoRange = [8.0,16,50].contains(tempoRange) ? tempoRange : 16
p.headroomDB = [0.0,3,6,9].contains(headroomDB) ? headroomDB : 0
p.waveformSeconds = [4.0,8,16,32].contains(waveformSeconds) ? waveformSeconds : 8
p.libraryFont = libraryFont.isFinite ? min(16,max(10,libraryFont)) : 11
p.libraryRowHeight = libraryRowHeight.isFinite ? min(44,max(24,libraryRowHeight)) : 28
if !["Equal power","Linear"].contains(crossfaderCurve) { p.crossfaderCurve = "Equal power" };return p
}
}
@MainActor final class DJChannel: ObservableObject {
let player = Player(persistsMix:false)
let letter: String
@Published private(set) var song: Song?
@Published var bpm: Double = 120
@Published var bpmConfirmed = false
@Published var bpmEstimated = false
@Published var analysingBPM = false
@Published var artwork: NSImage?
private var infoTask: Task<Void,Never>?
private var analysisTask: Task<Double?,Never>?
@Published var gain: Double = 0 { didSet { player.inputGain = gain } }
@Published var level: Double = 0.8 { didSet { updateLevel() } }
@Published var loopBeats = 8
var master: Double = 0.8 { didSet { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
private var taps = [Double]()
init(_ letter: String) { self.letter = letter;player.autoFade = false;updateLevel() }
func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) }
func load(_ song: Song) throws {
var audioOnly = song;audioOnly.cdg = nil
do { try player.load(audioOnly,startPlaying:false) } catch { self.song = nil;throw error }
self.song = song;bpmConfirmed = false;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song)
}
func unload() { infoTask?.cancel();analysisTask?.cancel();player.stop();song = nil;bpmConfirmed = false;artwork = nil;analysingBPM = false }
private func loadTrackInfo(_ song: Song) {
infoTask?.cancel();analysisTask?.cancel();artwork = nil;analysingBPM = true
infoTask = Task { @MainActor [weak self] in
let image = await DJTrackInfo.artwork(song)
guard !Task.isCancelled,let self,self.song?.id == song.id else { return }
self.artwork = image
let task = Task.detached(priority:.utility) { try? DJBPM.analyse(URL(fileURLWithPath:song.audio),cancelled:{Task.isCancelled}) };self.analysisTask = task
let bpm = await task.value
guard !Task.isCancelled,self.song?.id == song.id else { return }
self.analysingBPM = false
if !self.bpmConfirmed,let bpm { self.bpm = bpm;self.bpmEstimated = true;self.bpmConfirmed = true }
}
}
deinit { infoTask?.cancel();analysisTask?.cancel() }
func tap(at time: Double = ProcessInfo.processInfo.systemUptime) {
if let last = taps.last,time-last > 2 { taps = [] }
taps.append(time);taps = Array(taps.suffix(8))
guard taps.count >= 3,let first = taps.first,let last = taps.last,last > first else { return }
let value = 60 * Double(taps.count-1)/(last-first)
if (40...240).contains(value) { bpm = (value*10).rounded()/10;bpmConfirmed = true;bpmEstimated = false }
}
func beatLoop() {
if player.loopEnabled { player.clearLoop();return }
guard bpmConfirmed,player.canJog else { return }
let start = player.time,end = min(player.duration,start+Double(loopBeats)*60/bpm)
player.defineLoop(start:start,end:end)
}
}
struct DJAudioDevice: Identifiable, Equatable {
var id: AudioDeviceID
var uid: String
var name: String
static func outputs() -> [Self] {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating:0,count:Int(size)/MemoryLayout<AudioDeviceID>.size)
let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) }
guard result == noErr else { return [] }
return ids.compactMap { id in
var streams = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreams,mScope:kAudioDevicePropertyScopeOutput,mElement:kAudioObjectPropertyElementMain)
var count: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id,&streams,0,nil,&count) == noErr,count > 0 else { return nil }
func string(_ selector: AudioObjectPropertySelector) -> String? {
var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var text: Unmanaged<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.size)
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
return text?.takeRetainedValue() as String?
}
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output")
}
}
static func defaultOutput() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var id = AudioDeviceID(0),size = UInt32(MemoryLayout<AudioDeviceID>.size)
return AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,&id) == noErr && id != 0 ? id : nil
}
}
@MainActor final class DJSystem: ObservableObject {
let a = DJChannel("A"),b = DJChannel("B")
let preview = Player(persistsMix:false)
let midi = DJMIDI()
@Published var crossfader: Double = 0.5 { didSet { applyMix() } }
@Published var master: Double = 0.8 { didSet { applyMix() } }
@Published private(set) var syncActive = false
private var syncOriginal: (Double,Double)?
private var syncObservers = Set<AnyCancellable>()
private var updatingSync = false
@Published var pendingReplacement: DJReplacement?
@Published var cueEditor: String?
@Published var cueDraft: Double = 0
@Published var cueEditing = false
@Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } }
@Published private(set) var previewDeck: String?
@Published var devices = [DJAudioDevice]()
@Published var masterUID = ""
@Published var cueUID = ""
@Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } }
@Published var status = "Load music into A or B. Karaoke keeps its own mixer and output."
@Published var error: String?
let root: URL
private(set) var library: Library?
let dragSession = UUID().uuidString
@Published var songs = [Song]()
@Published var playlists = [PersonPlace]()
@Published var importProgress: ImportProgress?
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
Task { @MainActor [weak self] in self?.maintainSync() }
}.store(in:&syncObservers)
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized }
applyMix()
}
}
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } }
func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } }
func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);try refresh() } }
func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } }
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!urls.isEmpty else { return }
importing = true;importProgress = .init()
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media")
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
var importedIDs = [Int64]()
do {
let db = try Library(url:database)
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
}
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs
await MainActor.run { [weak self] in
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.perform { try self.refresh() }
if let failure = result.failure { self.error = failure }
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying) }
}
}
}
func cancelImport() { importTask?.cancel() }
deinit { importTask?.cancel() }
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = weights.0;b.crossWeight = weights.1 }
func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
func load(_ song: Song,into letter: String,overridePlaying: Bool = false) {
perform {
let deck = channel(letter)
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
if syncActive { disableSync() }
if previewDeck == letter { stopPreview() }
try deck.load(song)
if !masterUID.isEmpty {
do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error }
}
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad { deck.player.toggle() }
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
}
}
func refreshDevices() { devices = DJAudioDevice.outputs();midi.refreshSources() }
private func device(_ uid: String) throws -> AudioDeviceID {
if uid.isEmpty,let id = DJAudioDevice.defaultOutput() { return id }
guard let output = devices.first(where:{$0.uid == uid}) else { throw LibraryError.message("Selected audio output is disconnected. Refresh and choose an available output.") }
return output.id
}
func routeMaster(_ uid: String) {
let old = masterUID
perform {
let id = try device(uid)
stopPreview()
do { try a.player.routeOutput(id);try b.player.routeOutput(id);masterUID = uid }
catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error }
}
}
func stopPreview() { preview.stop();previewDeck = nil }
func startPreview(_ deck: DJChannel) {
if previewDeck == deck.letter { stopPreview();return }
perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") }
guard let song = deck.song else { throw LibraryError.message("Load a track first.") }
let output = try device(cueUID)
guard output != (try device(masterUID)) else { throw LibraryError.message("Headphone preview requires a different output device from the master. Multichannel pairs on one device are not yet supported.") }
stopPreview();var audio = song;audio.cdg = nil
try preview.load(audio,startPlaying:false)
try preview.routeOutput(output)
preview.tempo = deck.player.tempo;preview.lowGain = deck.player.lowGain;preview.midGain = deck.player.midGain;preview.highGain = deck.player.highGain;preview.filter = deck.player.filter
preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
}
}
func sync(_ deck: DJChannel) {
if syncActive { disableSync();return }
guard a.player.canJog,b.player.canJog else { status = "Load both decks before enabling Sync.";return }
guard a.bpmConfirmed,b.bpmConfirmed else { status = "Waiting for BPM analysis. Set or tap BPM on both decks if a tempo cannot be detected.";return }
let target = a.bpm*a.player.tempo,rate = target/b.bpm
guard rate.isFinite,(0.5...2).contains(rate) else { status = "These tracks require a tempo outside the supported 50–200% range.";return }
syncOriginal = (a.player.tempo,b.player.tempo)
syncActive = true;updatingSync = true;b.player.tempo = rate;updatingSync = false
status = String(format:"SYNC ON · Both decks %.1f BPM · A is master",target)
}
func disableSync() {
guard syncActive else { return };syncActive = false
if let original = syncOriginal { a.player.tempo = original.0;b.player.tempo = original.1 }
syncOriginal = nil;status = "SYNC OFF · Both decks restored to their pre-sync tempos"
}
private func maintainSync() {
guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return }
let rate = a.bpm*a.player.tempo/b.bpm
guard rate.isFinite,(0.5...2).contains(rate) else { disableSync();return }
if abs(b.player.tempo-rate) > 0.00001 { updatingSync = true;b.player.tempo = rate;updatingSync = false }
}
func toggleLoop(_ deck: DJChannel) {
guard deck.player.canJog else { status = "Load a track first.";return }
guard deck.player.loopEnabled || deck.bpmConfirmed else { status = "Waiting for BPM analysis. Enter or tap the track BPM, or define a loop with IN and OUT.";return }
deck.beatLoop();status = deck.player.loopEnabled ? "Deck \(deck.letter) · \(deck.loopBeats)-beat loop active" : "Deck \(deck.letter) · Loop off"
}
func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true }
func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false }
func hotCue(_ index: Int,deck: DJChannel) {
let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil
deck.player.hotCue(index)
if existing,settings.autoPlayHotCue,!deck.player.playing { deck.player.toggle() }
}
func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) {
if name == "Crossfader" { crossfader = min(1,max(0,value));return }
if name == "Master" { master = min(1,max(0,value));return }
let parts = name.split(separator:" ",maxSplits:1).map(String.init)
guard parts.count == 2 else { return };let d = channel(parts[0]),p = d.player
switch parts[1] {
case "Play": p.toggle()
case "Stop": p.stopDeck()
case "Cue": p.returnToCue()
case "Set cue": p.setCue()
case "Sync": sync(d)
case "Loop": toggleLoop(d)
case "Headphones": startPreview(d)
case "Load": if let selected { load(selected,into:d.letter) }
case "Gain": d.gain = value*24-12
case "High": p.highGain = value*24-12
case "Mid": p.midGain = value*24-12
case "Low": p.lowGain = value*24-12
case "Filter": p.filter = value*2-1
case "Level": d.level = value
case "Tempo": if syncActive,d.letter == "B" { a.player.tempo = b.bpm*settings.tempo(at:value)/a.bpm;maintainSync() } else { p.tempo = settings.tempo(at:value) }
case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2)
case "Hot cue 1": hotCue(0,deck:d)
case "Hot cue 2": hotCue(1,deck:d)
case "Hot cue 3": hotCue(2,deck:d)
case "Hot cue 4": hotCue(3,deck:d)
default: break
}
}
}
// Streaming MIDI 1.0 decoder: preserves running status across packets; ignores clock and SysEx.
struct DJMIDIMessage: Equatable { var status: UInt8;var data1: UInt8;var data2: UInt8 }
struct DJMIDIDecoder {
private var status: UInt8 = 0
private var data = [UInt8]()
mutating func feed(_ bytes: [UInt8]) -> [DJMIDIMessage] {
var result = [DJMIDIMessage]()
for byte in bytes {
if byte >= 0xF8 { continue }
if byte & 0x80 != 0 { status = byte < 0xF0 ? byte : 0;data = [];continue }
guard status != 0 else { continue };data.append(byte)
let count = [0xC0,0xD0].contains(status & 0xF0) ? 1 : 2
if data.count == count {
if [0x80,0x90,0xB0].contains(status & 0xF0) { result.append(.init(status:status,data1:data[0],data2:data.count > 1 ? data[1] : 0)) }
data = []
}
}
return result
}
}
struct DJMIDIMapping: Codable, Identifiable, Equatable {
var source: Int32;var status: UInt8;var number: UInt8;var action: String;var mode: String
var id: String { "\(source):\(status):\(number)" }
static func value(_ byte: UInt8,mode: String) -> Double {
if mode == "Relative signed" { return byte < 64 ? Double(byte) : Double(Int(byte)-128) }
if mode == "Relative offset" { return Double(Int(byte)-64) }
return Double(byte)/127
}
}
@MainActor final class DJMIDI: ObservableObject {
struct Source: Identifiable { var id: Int32;var endpoint: MIDIEndpointRef;var name: String }
@Published var sources = [Source]()
@Published private(set) var sourceID: Int32 = 0
@Published var mappings = [DJMIDIMapping]()
@Published var learning: String?
@Published var learnMode = "Absolute"
@Published var status = "Choose a MIDI source; learn each control. No assumed factory mapping."
var onAction: ((String,Double,Bool) -> Void)?
private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0)
private var decoder = DJMIDIDecoder()
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard,connect: Bool = true) {
self.defaults = defaults
if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved }
guard connect else { return }
let result = MIDIClientCreateWithBlock("KJMix DJ" as CFString,&client) { [weak self] _ in Task { @MainActor in self?.refreshSources() } }
guard result == noErr else { status = "MIDI unavailable (\(result))";return }
let input = MIDIInputPortCreateWithBlock(client,"DJ controls" as CFString,&port) { [weak self] list,_ in
var bytes = [UInt8]()
// Walk the original variable-length packet list, never a copied 256-byte tuple.
var packet = UnsafeRawPointer(list).advanced(by:MemoryLayout<MIDIPacketList>.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self)
for _ in 0..<list.pointee.numPackets {
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.offset(of: \.data)!)
bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)))
packet = UnsafePointer(MIDIPacketNext(packet))
}
let received = bytes
Task { @MainActor in self?.receive(received) }
}
if input != noErr { status = "MIDI input unavailable (\(input))" }
refreshSources()
}
func refreshSources() {
sources = (0..<MIDIGetNumberOfSources()).map { index in
let endpoint = MIDIGetSource(index);var uid: Int32 = 0;var name: Unmanaged<CFString>?
MIDIObjectGetIntegerProperty(endpoint,kMIDIPropertyUniqueID,&uid)
MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&name)
return Source(id:uid,endpoint:endpoint,name:name?.takeRetainedValue() as String? ?? "MIDI source")
}
if connected != 0,!sources.contains(where:{$0.endpoint == connected}) { select(0);status = "Controller disconnected; choose a source after reconnecting." }
}
func select(_ id: Int32) {
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil
guard let source = sources.first(where:{$0.id == id}) else { return }
let result = MIDIPortConnectSource(port,source.endpoint,nil)
if result == noErr { connected = source.endpoint;sourceID = id;status = "Connected: \(source.name)" }
else { status = "Could not connect MIDI source (\(result))" }
}
func receive(_ bytes: [UInt8]) {
for message in decoder.feed(bytes) {
let type = message.status & 0xF0
guard type == 0xB0 || (type == 0x90 && message.data2 > 0) else { continue }
if let action = learning {
let mapping = DJMIDIMapping(source:sourceID,status:message.status,number:message.data1,action:action,mode:action.hasSuffix(" Jog") ? learnMode : "Absolute")
mappings.removeAll{$0.id == mapping.id || ($0.source == sourceID && $0.action == action)};mappings.append(mapping);save();learning = nil;status = "Learned \(action) · channel \((message.status & 0x0F)+1), control \(message.data1)"
continue
}
guard let map = mappings.first(where:{$0.source == sourceID && $0.status == message.status && $0.number == message.data1}) else { continue }
let continuous = ["Gain","High","Mid","Low","Filter","Level","Tempo","Jog"].contains(map.action.split(separator:" ",maxSplits:1).last.map(String.init) ?? "") || ["Crossfader","Master"].contains(map.action)
if !continuous && message.data2 == 0 { continue }
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
}
}
func remove(_ id: String) { mappings.removeAll{$0.id == id};save() }
private func save() { if let data = try? JSONEncoder().encode(mappings) { defaults.set(data,forKey:"DJ.MIDIMappings.v1") } }
deinit { if port != 0 { MIDIPortDispose(port) };if client != 0 { MIDIClientDispose(client) } }
}
private enum DJStyle {
static let background = Color(red:0.055,green:0.065,blue:0.085)
static let panel = Color(red:0.095,green:0.11,blue:0.14)
static let a = Color(red:0.20,green:0.80,blue:0.95)
static let b = Color(red:1.0,green:0.66,blue:0.22)
static func color(_ deck: String) -> Color { deck == "A" ? a : b }
static func stamp(_ value: Double) -> String {
guard value.isFinite else { return "0:00" };let seconds = Int(max(0,value));return String(format:"%d:%02d",seconds/60,seconds%60)
}
}
struct DJConsole: View {
@ObservedObject var model: AppModel
@ObservedObject var system: DJSystem
@State private var settingsModal = false
@State private var routing = false
@State private var mapping = false
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [Int64]()
@State private var selected: Int64?
@State private var playlistEditor = false
@State private var playlistName = ""
@State private var renaming = false
@State private var page = 0
private var songs: [Song] {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} }
return all
}
private var selectedSong: Song? { system.songs.first{$0.id == selected} }
private var pages: Int { max(1,(songs.count+49)/50) }
private var visible: [Song] { Array(songs.dropFirst(min(page,pages-1)*50).prefix(50)) }
var body: some View {
ScrollView {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")})
browser.frame(height:130)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
Spacer()
if system.importing { ProgressView().controlSize(.mini);Button("Cancel") { system.cancelImport() } }
else { Button("Dismiss") { system.importProgress = nil } }
}.font(.caption).controlSize(.mini)
}
HStack { Text(system.status).lineLimit(1);Spacer();Text("DJ master separate from karaoke").foregroundStyle(.secondary) }.font(.system(size:10))
}.padding(14)
}.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
.alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in
Button("Cancel",role:.cancel) { system.pendingReplacement = nil }
Button("Replace & Load",role:.destructive) { system.confirmReplacement() }
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
.sheet(isPresented:$settingsModal) { StudioSettingsDialog(model:model,scope:"DJ") }
.sheet(isPresented:$routing) { StudioSettingsDialog(model:model,scope:"DJ",category:"Devices") }
.sheet(isPresented:$mapping) { StudioSettingsDialog(model:model,scope:"DJ",category:"MIDI") }
.sheet(isPresented:$playlistEditor) { playlistSheet }
.alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
.onAppear { refreshPlaylist();connectMIDI() }
.onChange(of:selected) { _ in connectMIDI() }
.onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil }
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in page = 0 }
}
private var header: some View {
HStack {
Image(systemName:"waveform.path").foregroundStyle(DJStyle.a)
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Spacer()
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
Button { settingsModal = true } label: { Label("DJ Settings",systemImage:"gearshape") }
Button { routing = true } label: { Label("Audio Routing",systemImage:"hifispeaker.fill") }
Button { mapping = true } label: { Label("MIDI",systemImage:"pianokeys") }
}.controlSize(.small)
}
private var browser: some View {
VStack(spacing:5) {
HStack(spacing:8) {
Picker("DJ library",selection:$playlist) { Text("All music").tag(nil as Int64?);ForEach(system.playlists) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:175)
Menu {
Button("New Playlist…") { renaming = false;playlistName = "";playlistEditor = true }
if let playlist {
Button("Rename Playlist…") { renaming = true;playlistName = system.playlists.first{$0.id == playlist}?.name ?? "";playlistEditor = true }
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
List(selection:$selected) {
ForEach(visible) { song in
HStack(spacing:8) {
Image(systemName:"music.note").foregroundStyle(.secondary)
Text(song.title).font(.system(size:system.settings.libraryFont,weight:.medium)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading)
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
.onDrag { system.dragReference(song.id).provider() }
.contextMenu { playlistMenu(song) }
}
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
}.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6))
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() }
Button("Move Later") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:1) };refreshPlaylist() }
Button("Remove from Playlist") { system.perform { try system.library?.removeFromPlaylist(playlist,song:song.id) };refreshPlaylist() }
}
}
private var playlistSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text(renaming ? "Rename Playlist" : "New Playlist").font(.title2)
TextField("Playlist name",text:$playlistName).textFieldStyle(.roundedBorder)
HStack { Button("Cancel") { playlistEditor = false };Spacer();Button("Save") {
let name = playlistName.trimmingCharacters(in:.whitespacesAndNewlines)
if renaming,let playlist { system.rename("playlists",id:playlist,name:name) } else { system.createPlaylist(name) }
playlistEditor = false
}.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty).keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:360)
}
private func loadSelected(_ deck: String) { if let selectedSong { system.load(selectedSong,into:deck) } else { system.status = "Select a DJ library track, then Load \(deck), or drag a DJ library track onto the deck." } }
private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [] }
private func connectMIDI() {
let selected = selectedSong
system.midi.onAction = { [weak system] name,value,relative in system?.action(name,value:value,relative:relative,selected:selected) }
}
private func importMedia() {
let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true
if panel.runModal() == .OK { system.importFiles(panel.urls) }
}
}
private struct DJDeckSurfaces {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
private var color: Color { DJStyle.color(deck.letter) }
var tempoStrip: some View {
VStack(spacing:5) {
Button("SYNC") { system.sync(deck) }.font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(color,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Toggle both decks to deck A’s BPM; click again to restore their original tempos").accessibilityIdentifier("dj.\(deck.letter).sync")
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit())
Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo")
DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:52,height:94)
HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) };Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) } }.buttonStyle(.plain).font(.system(size:15,weight:.medium))
}.frame(width:62).disabled(!player.canJog)
}
var tonePanel: some View {
HStack(alignment:.top,spacing:10) {
VStack(spacing:8) {
DJKnob(label:"HIGH",value:$player.highGain,range:-12...12,reset:0,color:color)
DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color)
DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color)
}
VStack(spacing:12) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:8,height:5) }
}.accessibilityLabel("Deck output meter")
}
}.frame(width:88)
}
}
private struct DJKnob: View {
let label: String
@Binding var value: Double
let range: ClosedRange<Double>
let reset: Double
let color: Color
var format: (Double) -> String = {String(format:"%+.0f dB",$0)}
@State private var initial: Double?
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
var body: some View {
VStack(spacing:1) {
ZStack {
Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) }
Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90))
Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270))
}.frame(width:29,height:29).contentShape(Circle())
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil })
.simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil})
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary)
}.frame(minWidth:36).help("\(label): \(format(value)). Drag vertically; double-click to reset.")
.accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value))
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) }
}
}
private struct DJPlatter: View {
@ObservedObject var player: Player
let letter: String
let color: Color
var markerEnabled = true
@State private var lastAngle: Double?
var body: some View {
GeometryReader { geometry in
ZStack {
Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) }
ForEach([0.70,0.80,0.90],id:\.self) { scale in Circle().stroke(.white.opacity(0.06),lineWidth:1).scaleEffect(scale) }
ZStack {
Circle().fill(color.opacity(0.15)).frame(width:53,height:53)
Text(letter).font(.system(size:25,weight:.bold,design:.rounded)).foregroundStyle(color)
if markerEnabled {
Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37)
Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45)
}
}.rotationEffect(.degrees(player.time*200))
}.contentShape(Circle())
.gesture(DragGesture(minimumDistance:2).onChanged { drag in
let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2))
if let previous = lastAngle { var delta = angle-previous;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta / (2 * .pi) * 2) }
lastAngle = angle
}.onEnded{_ in lastAngle = nil})
}.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.")
}
}
private struct DJWaveform: View {
@ObservedObject var player: Player
let color: Color
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
guard !values.isEmpty else { return }
let count = max(1,Int(size.width/2))
for i in 0..<count {
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < player.time/max(0.001,player.duration) ? 0.4 : 0.9)))
}
if player.loopEnabled,let start = player.loopIn,let end = player.loopOut,player.duration > 0 {
context.fill(Path(CGRect(x:start/player.duration*size.width,y:0,width:(end-start)/player.duration*size.width,height:size.height)),with:.color(.green.opacity(0.18)))
}
let x = player.time/max(0.001,player.duration)*size.width
context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white))
}.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3))
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in player.seek(Double(value.location.x/max(1,geometry.size.width))*player.duration) })
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
}
}
private struct DJMappingSheet: View {
@ObservedObject var midi: DJMIDI
var embedded = false
@State private var action = "A Play"
@Environment(\.dismiss) private var dismiss
private let commands = ["Play","Stop","Cue","Set cue","Sync","Loop","Headphones","Load","Gain","High","Mid","Low","Filter","Level","Tempo","Jog","Hot cue 1","Hot cue 2","Hot cue 3","Hot cue 4"]
private var actions: [String] { ["Crossfader","Master"]+["A","B"].flatMap{deck in commands.map{deck+" "+$0}} }
var body: some View {
VStack(alignment:.leading,spacing:14) {
HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } }
Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } }
Text("Numark DJ2GO / DJ2GO2 / DJ2GO2 Touch use USB MIDI. “Mix Go” is not a confirmed model name. Select the connected source and learn its controls; no factory note numbers are assumed.").font(.caption).foregroundStyle(.secondary)
HStack {
Picker("Action",selection:$action) { ForEach(actions,id:\.self) { Text($0).tag($0) } }
Picker("Mode",selection:$midi.learnMode) { Text("Absolute").tag("Absolute");Text("Relative signed").tag("Relative signed");Text("Relative offset").tag("Relative offset") }.frame(width:160)
}
HStack {
Button(midi.learning == nil ? "Learn Next Control" : "Cancel Learning") { if midi.learning == nil { midi.learning = action } else { midi.learning = nil } }.disabled(midi.sourceID == 0)
if let learning = midi.learning { Text("Move or press the control for \(learning)…").foregroundStyle(.orange) }
}
Text(midi.status).font(.caption)
List {
ForEach(midi.mappings.filter{$0.source == midi.sourceID}) { map in
HStack {
Text(map.action).frame(width:110,alignment:.leading)
Text("\(map.status & 0xF0 == 0xB0 ? "CC" : "Note") \(map.number) · ch \((map.status & 0x0F)+1)").foregroundStyle(.secondary)
Spacer();Text(map.mode).font(.caption).foregroundStyle(.secondary)
Button { midi.remove(map.id) } label: { Image(systemName:"trash") }.buttonStyle(.borderless).accessibilityLabel("Remove mapping for \(map.action)")
}
}
}.frame(height:200)
Text("Mappings save per MIDI source ID. Use relative mode only for Jog encoders. Supported: note buttons and 7-bit CCs. Load uses the selected DJ library row. Button releases are ignored. Controller LEDs, motor feedback, 14-bit controls, scratching and vendor-specific handshakes are not implemented.").font(.caption).foregroundStyle(.secondary)
if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } }
}.padding(24).frame(width:660)
}
}
/// Native mouse/keyboard/accessibility behaviour with a rectangular mixer-fader cap.
struct DJHardwareFader: NSViewRepresentable {
@Binding var value: Double
let label: String
var vertical = false
func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
func makeNSView(context: Context) -> NSSlider {
let slider = NSSlider(value:value,minValue:0,maxValue:1,target:context.coordinator,action:#selector(Coordinator.changed(_:)))
slider.cell = DJHardwareFaderCell()
slider.target = context.coordinator;slider.action = #selector(Coordinator.changed(_:))
slider.frame = NSRect(x:0,y:0,width:vertical ? 52 : 240,height:vertical ? 94 : 36)
slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical
slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value
slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false
slider.setAccessibilityLabel(label)
slider.setContentHuggingPriority(.defaultLow,for:.horizontal)
return slider
}
func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label) }
func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) }
final class Coordinator: NSObject {
var value: Binding<Double>
init(value: Binding<Double>) { self.value = value }
@objc func changed(_ sender: NSSlider) { value.wrappedValue = sender.doubleValue }
}
}
private final class DJHardwareFaderCell: NSSliderCell {
override func drawBar(inside rect: NSRect,flipped: Bool) {
let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0)
NSColor(calibratedWhite:0.36,alpha:1).setStroke()
for i in 0...8 {
let mark = NSBezierPath()
if vertical { let y = rect.minY+rect.height*Double(i)/8;mark.move(to:NSPoint(x:rect.midX-21,y:y));mark.line(to:NSPoint(x:rect.midX+21,y:y)) }
else { let x = rect.minX+rect.width*Double(i)/8;mark.move(to:NSPoint(x:x,y:rect.midY-13));mark.line(to:NSPoint(x:x,y:rect.midY+13)) }
mark.lineWidth = 1;mark.stroke()
}
let groove = vertical ? NSRect(x:rect.midX-2,y:rect.minY,width:4,height:rect.height) : NSRect(x:rect.minX,y:rect.midY-2,width:rect.width,height:4)
NSColor(calibratedWhite:0.025,alpha:1).setFill();NSBezierPath(roundedRect:groove,xRadius:2,yRadius:2).fill()
}
override func drawTickMarks() {}
override func drawKnob(_ rect: NSRect) {
let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0)
let cap = vertical ? NSRect(x:rect.midX-21,y:rect.midY-6,width:42,height:12) : NSRect(x:rect.midX-6,y:rect.midY-14,width:12,height:28)
let path = NSBezierPath(roundedRect:cap,xRadius:4,yRadius:4)
NSGradient(starting:NSColor(calibratedWhite:0.60,alpha:1),ending:NSColor(calibratedWhite:0.30,alpha:1))?.draw(in:path,angle:90)
NSColor(calibratedWhite:0.75,alpha:0.6).setStroke()
let marker = NSBezierPath();if vertical { marker.move(to:NSPoint(x:cap.minX+6,y:cap.midY));marker.line(to:NSPoint(x:cap.maxX-6,y:cap.midY)) } else { marker.move(to:NSPoint(x:cap.midX,y:cap.minY+5));marker.line(to:NSPoint(x:cap.midX,y:cap.maxY-5)) };marker.lineWidth = 1;marker.stroke()
}
}
struct DJContinuousConsole: View {
@ObservedObject var system: DJSystem
let loadA: () -> Void
let loadB: () -> Void
var body: some View {
VStack(spacing:0) {
HStack(spacing:0) {
DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA)
Divider()
DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB)
}.frame(height:92).background(Color.black.opacity(0.4))
Divider()
HStack(spacing:0) {
DJConsoleWing(deck:system.a,player:system.a.player,system:system)
Divider()
VStack(spacing:1) {
DJCentreWaveform(player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings)
DJCentreWaveform(player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings)
}.frame(maxWidth:.infinity).background(.black)
Divider()
DJConsoleWing(deck:system.b,player:system.b.player,system:system)
}.frame(height:198).background(Color(white:0.12))
Divider()
HStack(spacing:12) {
DJConsoleTransport(deck:system.a,player:system.a.player,system:system)
VStack(spacing:0) {
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36)
HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9))
}.frame(width:195)
DJConsoleTransport(deck:system.b,player:system.b.player,system:system)
}.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14))
}.overlay(Rectangle().stroke(.white.opacity(0.09)))
}
}
private struct DJTrackHeader: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
let load: () -> Void
private var color: Color { DJStyle.color(deck.letter) }
var body: some View {
HStack(spacing:10) {
if deck.letter == "A" { artwork }
VStack(alignment:.leading,spacing:5) {
HStack {
VStack(alignment:.leading,spacing:2) {
Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1)
HStack(spacing:6) {
Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1)
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM")
Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary)
Button("Tap") { deck.tap() }.controlSize(.mini)
}.font(.system(size:10)).disabled(!player.canJog)
}
Spacer(minLength:4)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit())
Button("Load",action:load).controlSize(.mini)
}
DJWaveform(player:player,color:color).frame(height:22)
}
if deck.letter == "B" { artwork }
}.padding(10).frame(maxWidth:.infinity)
.onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
}
private var artwork: some View {
ZStack {
RoundedRectangle(cornerRadius:4).fill(color.opacity(0.12))
if let image = deck.artwork { Image(nsImage:image).resizable().scaledToFill() }
else { Image(systemName:"music.note").font(.system(size:26)).foregroundStyle(color) }
}.frame(width:62,height:62).clipped().clipShape(RoundedRectangle(cornerRadius:4))
}
}
private struct DJConsoleWing: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
var body: some View {
let face = DJDeckSurfaces(deck:deck,player:player,system:system)
HStack(spacing:10) {
if deck.letter == "B" { face.tonePanel }
if deck.letter == "A" { face.tempoStrip }
ZStack {
DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:134,height:134)
VStack(spacing:3) {
Text(String(format:"%+.1f%%",(player.tempo-1)*100)).font(.system(size:9)).foregroundStyle(.secondary)
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Divider().frame(width:62)
Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:11).monospacedDigit())
}.frame(width:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false)
}
if deck.letter == "A" { face.tonePanel }
if deck.letter == "B" { face.tempoStrip }
}.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
.onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
}
}
private struct DJConsoleTransport: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
private var playButton: some View {
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }.foregroundStyle(player.playing ? Color.green : Color.white).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
}
private var cueButton: some View { Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22).contextMenu { Button("Set cue here") { player.setCue() } } }
private var loopButtons: some View {
HStack(spacing:0) {
Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }
Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle beat loop; requires confirmed BPM")
Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }
}
}
private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) }
var body: some View {
VStack(spacing:6) {
HStack(spacing:8) {
if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }
else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton }
}.controlSize(.regular)
HStack(spacing:6) {
if deck.letter == "B" { Spacer(minLength:0) }
ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } }
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() }
Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") }
if deck.letter == "A" { Spacer(minLength:0) }
}.controlSize(.mini)
}.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog)
}
}
private struct DJCentreWaveform: View {
@ObservedObject var player: Player
let letter: String
let color: Color
var settings = DJPreferences()
@State private var scrubStart: Double?
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
guard !player.waveform.isEmpty,player.duration > 0 else { return }
let span = settings.waveformSeconds,start = player.time-span/2,count = max(1,Int(size.width/2))
for second in 0...8 {
let x = Double(second)/8*size.width
context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0)))
}
for i in 0..<count {
let seconds = start+Double(i)/Double(count)*span
guard seconds >= 0,seconds < player.duration else { continue }
let index = min(player.waveform.count-1,Int(seconds/player.duration*Double(player.waveform.count)))
let height = max(1,Double(player.waveform[index])*size.height*0.85)
var shade = color
if settings.spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
shade = Color(hue:Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72,saturation:0.85,brightness:0.95)
}
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade))
}
context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red))
}.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) }
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in if scrubStart == nil { scrubStart = player.time-settings.waveformSeconds/2 };player.seek((scrubStart ?? 0)+Double(value.location.x/max(1,geometry.size.width))*settings.waveformSeconds) }.onEnded { _ in scrubStart = nil })
}.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time))
}
}
struct DJPreferencesPane: View {
@ObservedObject var system: DJSystem
let category: String
var body: some View {
ScrollView {
VStack(alignment:.leading,spacing:26) {
switch category {
case "General": general
case "Devices": devices
case "Sound": sound
case "Library": library
case "Appearance": appearance
case "MIDI": midi
default: advanced
}
}.padding(26).frame(maxWidth:.infinity,alignment:.leading)
}.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
}
private var general: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Song Loading") {
VStack(alignment:.leading,spacing:14) {
Toggle("Start playback when loading a track",isOn:$system.settings.autoPlayOnLoad)
Toggle("Reset EQ and filter on song load",isOn:$system.settings.resetEQOnLoad)
Toggle("Protect active deck",isOn:$system.settings.protectPlayingDeck)
Text("Protect active deck requires pausing a playing deck before replacing its track.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Sync Mode") {
VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Tempo matching");Text("Uses entered or tapped BPM. Automatic beat analysis, beat-grid alignment and persistent sync lock are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12)
}
PreferencesSection("Tempo") {
HStack(spacing:24) { Picker("Slider range",selection:$system.settings.tempoRange) { Text("±8%").tag(8.0);Text("±16%").tag(16.0);Text("±50%").tag(50.0) }.frame(width:250);Toggle("Invert slider",isOn:$system.settings.invertTempo) }.padding(12)
}
}
}
private var devices: some View {
PreferencesSection("DJ Audio Devices") {
VStack(alignment:.leading,spacing:18) {
Picker("Main output",selection:Binding(get:{system.masterUID},set:{system.routeMaster($0)})) { Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
Picker("Headphone preview",selection:Binding(get:{system.cueUID},set:{system.stopPreview();system.cueUID = $0})) { Text("None").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
HStack { Text("Preview volume");Slider(value:$system.cueLevel,in:0...1) }
Button("Refresh devices") { system.refreshDevices() }
Text("Independent headphone playback requires a different device from main output. Each device uses its default stereo pair. Multichannel master/cue pairs, booth output, microphone input and external mixer routing are not implemented. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}.onAppear { system.refreshDevices() }
}
private var sound: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Mixer") {
VStack(alignment:.leading,spacing:16) {
Picker("Crossfader curve",selection:$system.settings.crossfaderCurve) { Text("Equal power").tag("Equal power");Text("Linear").tag("Linear") }.frame(width:380)
LabeledContent("EQ type",value:"Three-band shelf / parametric EQ")
LabeledContent("Filter",value:"Low-pass / high-pass · centre bypass")
}.padding(12)
}
PreferencesSection("Audio Output") {
VStack(alignment:.leading,spacing:12) {
Picker("Output headroom",selection:$system.settings.headroomDB) { Text("None").tag(0.0);Text("−3 dB").tag(3.0);Text("−6 dB").tag(6.0);Text("−9 dB").tag(9.0) }.frame(width:350)
Text("Headroom reduces both DJ decks before the master mix. An audio limiter and automatic gain analysis are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
}
}
private var library: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Independent DJ Collection") {
VStack(alignment:.leading,spacing:14) {
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")
Text(system.root.path).font(.callout).textSelection(.enabled)
Button("Show DJ library in Finder") { NSWorkspace.shared.open(system.root) }
Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. Spotify playback, automatic BPM/key analysis and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Library Display") {
VStack(spacing:14) { HStack { Text("Font size").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryFont,in:10...16,step:1);Text("\(Int(system.settings.libraryFont)) pt").frame(width:45) };HStack { Text("Row height").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryRowHeight,in:24...44,step:2);Text("\(Int(system.settings.libraryRowHeight)) pt").frame(width:45) } }.padding(12)
}
}
}
private var appearance: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Jog Wheels") { Toggle("Attach rotating tape marker",isOn:$system.settings.platterMarker).padding(12) }
PreferencesSection("Waveforms") {
VStack(alignment:.leading,spacing:14) {
Toggle("Use frequency colours in scrolling waveforms",isOn:$system.settings.spectralWaveforms)
Toggle("Show time grid",isOn:$system.settings.showTimeGrid)
Toggle("Dim inactive deck",isOn:$system.settings.dimInactiveDeck)
Picker("Visible window",selection:$system.settings.waveformSeconds) { ForEach([4.0,8,16,32],id:\.self) { Text("\(Int($0)) seconds").tag($0) } }.frame(width:340)
}.padding(12)
}
}
}
private var midi: some View {
VStack(alignment:.leading,spacing:20) {
PreferencesSection("MIDI Controller") {
Text("Select a source and learn controls using the MIDI button above the DJ console. Mappings save per controller. Standard note buttons and 7-bit CC controls are supported; jog encoders can use relative modes.").padding(12)
}
DJMappingSheet(midi:system.midi,embedded:true)
}
}
private var advanced: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Cue Points & Loops") {
VStack(alignment:.leading,spacing:14) {
Toggle("Start playback when triggering a hot cue",isOn:$system.settings.autoPlayHotCue)
Text("Hot cues and loop points belong to the loaded track session. Manual IN/OUT loops and BPM-based beat loops are supported. Beat-grid quantization and saving cue/loop data per song are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Playback") { Text("Platter dragging performs seek-based jogging. DVS, touch scratching, recording, Neural Mix and cloud synchronization are not implemented.").font(.callout).foregroundStyle(.secondary).padding(12) }
}
}
}
private struct DJConsoleButtonStyle: ButtonStyle {
let accent: Color?
let active: Bool
@Environment(\.controlSize) private var size
@Environment(\.isEnabled) private var enabled
init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active }
func makeBody(configuration: Configuration) -> some View {
configuration.label.padding(.horizontal,size == .mini ? 6 : 12).padding(.vertical,size == .mini ? 3 : 7)
.background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5))
.overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2))
.opacity(enabled ? 1 : 0.35)
}
}
struct DJReplacement: Identifiable {
let id = UUID()
let deck: String
let song: Song?
let urls: [URL]
var title: String { song?.title ?? urls.first?.lastPathComponent ?? "track" }
}
@MainActor extension DJSystem {
func acceptDrop(_ providers: [NSItemProvider],into letter: String) -> Bool {
if let provider = providers.first(where:{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)}) {
Task { @MainActor in
do {
let url: URL = try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
if let url = item as? URL { continuation.resume(returning:url) }
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil) { continuation.resume(returning:url) }
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read the Finder file.")) }
}
}
guard url.isFileURL else { throw LibraryError.message("Drop a local audio file from Finder.") }
requestFileLoad([url],into:letter)
} catch { self.error = error.localizedDescription }
}
return true
}
guard let provider = providers.first(where:{$0.canLoadObject(ofClass:NSString.self)}) else { return false }
Task { @MainActor in
do {
let reference = try await SongDragReference.load(provider)
guard reference.library == dragSession,let song = songs.first(where:{$0.id == reference.song}) else { throw LibraryError.message("Drag from the DJ library or drop an audio file from Finder.") }
load(song,into:letter)
} catch { self.error = error.localizedDescription }
}
return true
}
func requestFileLoad(_ urls: [URL],into letter: String,override: Bool = false) {
guard !urls.isEmpty else { return }
if channel(letter).player.playing,!override { pendingReplacement = .init(deck:letter,song:nil,urls:urls);return }
guard !importing else { status = "Wait for the current DJ import to finish.";return }
importFiles(urls,loadInto:letter,overridePlaying:override)
}
func confirmReplacement() {
guard let request = pendingReplacement else { return };pendingReplacement = nil
if let song = request.song { load(song,into:request.deck,overridePlaying:true) }
else { requestFileLoad(request.urls,into:request.deck,override:true) }
}
}
/// Conservative onset-envelope estimate; silence and steady tones return no BPM.
enum DJBPM {
static func estimate(_ envelope: [Double],rate: Double = 200) -> Double? {
guard envelope.count >= Int(rate*4),rate > 0 else { return nil }
let average = envelope.reduce(0,+)/Double(envelope.count)
guard average > 0.00001 else { return nil }
let onset = envelope.indices.map { i in i == 0 ? 0 : max(0,envelope[i]-envelope[i-1]) }
let energy = onset.reduce(0){$0+$1*$1}
guard sqrt(energy/Double(onset.count)) > average*0.035 else { return nil }
var bestScore = 0.0,bestLag = 0
let lower = Int(rate*60/180),upper = Int(rate*60/70)
for lag in lower...upper {
var product = 0.0,first = 0.0,second = 0.0
for i in lag..<onset.count { product += onset[i]*onset[i-lag];first += onset[i]*onset[i];second += onset[i-lag]*onset[i-lag] }
let score = product/max(0.00000001,sqrt(first*second))
if score > bestScore { bestScore = score;bestLag = lag }
}
guard bestScore > 0.12,bestLag > 0 else { return nil }
return (60*rate/Double(bestLag)*10).rounded()/10
}
static func analyse(_ url: URL,cancelled: () -> Bool = { false }) throws -> Double? {
let file = try AVAudioFile(forReading:url),format = file.processingFormat
guard let buffer = AVAudioPCMBuffer(pcmFormat:format,frameCapacity:16384),format.channelCount > 0 else { return nil }
let hop = max(1,Int(format.sampleRate/200)),limit = Int64(format.sampleRate*90)
var envelope = [Double](),sum = 0.0,count = 0
while file.framePosition < min(file.length,limit) {
if cancelled() { throw CancellationError() }
try file.read(into:buffer)
guard buffer.frameLength > 0,let channels = buffer.floatChannelData else { break }
for i in 0..<Int(buffer.frameLength) {
var sample = 0.0
for c in 0..<Int(format.channelCount) { let value = Double(channels[c][i*Int(buffer.stride)]);sample += value*value }
sum += sample/Double(format.channelCount);count += 1
if count == hop { envelope.append(sqrt(sum/Double(count)));count = 0;sum = 0 }
}
}
return estimate(envelope,rate:format.sampleRate/Double(hop))
}
}
enum DJTrackInfo {
static func artwork(_ song: Song) async -> NSImage? {
let asset = AVURLAsset(url:URL(fileURLWithPath:song.audio))
if let items = try? await asset.load(.commonMetadata),let item = items.first(where:{$0.commonKey == .commonKeyArtwork}),let data = try? await item.load(.dataValue),data.count <= 20_000_000,let image = NSImage(data:data) { return image }
return sidecar(song).flatMap{NSImage(contentsOf:$0)}
}
static func sidecar(_ song: Song) -> URL? {
let sources = [song.originalPath,song.audio].filter{!$0.isEmpty}.map{URL(fileURLWithPath:$0)}
for source in sources {
let folder = source.deletingLastPathComponent(),stem = source.deletingPathExtension().lastPathComponent.lowercased()
guard let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.fileSizeKey,.isRegularFileKey],options:[.skipsHiddenFiles]) else { continue }
let candidates = files.filter { url in
guard ["jpg","jpeg","png"].contains(url.pathExtension.lowercased()),let values = try? url.resourceValues(forKeys:[.fileSizeKey,.isRegularFileKey]) else { return false }
return values.isRegularFile == true && (values.fileSize ?? Int.max) <= 20_000_000
}
func priority(_ url: URL) -> Int { let name = url.deletingPathExtension().lastPathComponent.lowercased();return name == stem ? 0 : ["cover","folder","front","artwork"].contains(name) ? 1 : 2 }
if let first = candidates.sorted(by:{priority($0) == priority($1) ? $0.lastPathComponent < $1.lastPathComponent : priority($0) < priority($1)}).first { return first }
}
return nil
}
}
private struct DJCueEditor: View {
@ObservedObject var system: DJSystem
var body: some View {
let deck = system.channel(system.cueEditor ?? "A")
VStack(alignment:.leading,spacing:18) {
Text("Deck \(deck.letter) · Cue Point").font(.title2.bold())
Text(deck.song?.title ?? "No track").foregroundStyle(.secondary)
HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) }
Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position")
Button("Use Current Playhead") { system.cueDraft = deck.player.time }
Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary)
HStack { Button("Cancel") { system.cueEditing = false };Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:470)
}
}