Files
KMix_Pro/KJamsClone/DJWorkstation.swift
T
2026-11-05 12:51:29 +00:00

737 lines
48 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
/// Equal-power crossfade, independent of the karaoke master and singer preferences.
enum DJMix {
static func weights(_ position: Double) -> (Double, Double) {
let x = position.isFinite ? min(1,max(0,position)) : 0.5
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
}
}
@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 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;bpm = 120;taps = []
}
func unload() { player.stop();song = nil;bpmConfirmed = false }
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 }
}
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)
guard end-start >= 0.1 else { return }
let playing = player.playing
if playing { player.toggle() }
player.markLoopIn();player.seek(end);player.markLoopOut();player.seek(start)
if playing { player.toggle() }
}
}
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 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 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()
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
}
}
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]) {
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()
do {
let db = try Library(url:database)
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
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 }
}
}
}
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);a.master = master;b.master = master;a.crossWeight = weights.0;b.crossWeight = weights.1 }
func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
func load(_ song: Song,into letter: String) {
perform {
let deck = channel(letter)
// Silence first; loading never starts public playback.
if previewDeck == letter { stopPreview() }
try deck.load(song)
if !masterUID.isEmpty {
do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error }
}
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) {
let other = deck.letter == "A" ? b : a
guard deck.bpmConfirmed,other.bpmConfirmed,let rate = DJMix.syncRate(bpm:deck.bpm,otherBPM:other.bpm,otherRate:other.player.tempo) else { status = "Set or tap both BPM values first (40–240). Sync range: 50–200%.";return }
deck.player.tempo = rate;status = "\(deck.letter) tempo matched to \(other.letter). Align beats with jog; no automatic beat grid."
}
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": d.beatLoop()
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": p.tempo = 0.84+value*0.32
case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2)
case "Hot cue 1": p.hotCue(0)
case "Hot cue 2": p.hotCue(1)
case "Hot cue 3": p.hotCue(2)
case "Hot cue 4": p.hotCue(3)
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 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
HStack(alignment:.top,spacing:20) {
DJDeckPanel(deck:system.a,system:system,loadSelected:{loadSelected("A")})
mixer.frame(width:105)
DJDeckPanel(deck:system.b,system:system,loadSelected:{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)
.environment(\.djResolveSong) { reference in
guard reference.library == system.dragReference(reference.song).library else { return nil }
return system.songs.first{$0.id == reference.song && !$0.audio.hasPrefix("showscreen:")}
}
.sheet(isPresented:$routing) { DJRoutingSheet(model:model,system:system) }
.sheet(isPresented:$mapping) { DJMappingSheet(midi:system.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 { routing = true } label: { Label("Audio Routing",systemImage:"hifispeaker.fill") }
Button { mapping = true } label: { Label("MIDI",systemImage:"pianokeys") }
}.controlSize(.small)
}
private var mixer: some View {
VStack(spacing:10) {
Text("MASTER").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary)
DJKnob(label:"Level",value:$system.master,range:0...1,reset:0.8,color:.green,format:{String(format:"%.0f%%",$0*100)})
DJOutputMeter(a:system.a.player,b:system.b.player)
Text("CROSSFADER").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary)
Slider(value:$system.crossfader,in:0...1).accessibilityLabel("DJ crossfader").tint(.white)
HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("•") { system.crossfader = 0.5 }.buttonStyle(.plain).help("Centre crossfader");Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.caption.bold())
Button("Stop both") { system.a.player.stopDeck();system.b.player.stopDeck();system.stopPreview() }.controlSize(.small)
}.padding(.top,8)
}
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:11,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")
}.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 DJDeckPanel: View {
@ObservedObject var deck: DJChannel
@ObservedObject var system: DJSystem
let loadSelected: () -> Void
var body: some View { DJDeckFace(deck:deck,player:deck.player,system:system,loadSelected:loadSelected) }
}
private struct DJDeckFace: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
let loadSelected: () -> Void
private var color: Color { DJStyle.color(deck.letter) }
var body: some View {
VStack(spacing:7) {
HStack {
Text("DECK \(deck.letter)").font(.system(size:11,weight:.bold)).foregroundStyle(color)
Text(player.playing ? "PLAYING" : player.canJog ? "READY" : "EMPTY").font(.system(size:8,weight:.bold)).foregroundStyle(player.playing ? .green : .secondary)
Spacer()
Button("Load \(deck.letter)",action:loadSelected).controlSize(.mini).help("Load the selected DJ library track, paused")
}
HStack {
VStack(alignment:.leading,spacing:2) { Text(deck.song?.title ?? "Import and load a DJ track").font(.system(size:12,weight:.semibold)).lineLimit(1);Text(deck.song?.artist ?? "Separate DJ music library").font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1) }
Spacer();Text("\(DJStyle.stamp(player.time)) / \(DJStyle.stamp(player.duration))").font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
}
DJWaveform(player:player,color:color).frame(height:36)
HStack(spacing:28) {
if deck.letter == "B" { tonePanel }
DJPlatter(player:player,letter:deck.letter,color:color).frame(width:105,height:105)
if deck.letter == "A" { tonePanel }
}.frame(maxWidth:.infinity).padding(.vertical,3)
transport
}.padding(12).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8))
.overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(color).frame(height:2).padding(.horizontal,8) }
.onDrop(of:["public.text"],isTargeted:nil) { providers in
guard let first = providers.first else { return false }
Task { @MainActor in
do {
let reference = try await SongDragReference.load(first)
// Match the current library's drag session before accepting an internal reference.
guard let song = systemLibrarySong(reference) else { system.error = "Import this track into the DJ library first, then drag it from the DJ browser.";return }
system.load(song,into:deck.letter)
} catch { system.error = error.localizedDescription }
}
return true
}
}
// Supplied by the library via a small environment closure, avoiding paths in drag payloads.
@Environment(\.djResolveSong) private var resolveSong
private func systemLibrarySong(_ reference: SongDragReference) -> Song? { resolveSong(reference) }
private var tonePanel: some View {
HStack(spacing:10) {
VStack(spacing:5) {
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:5) {
DJKnob(label:"GAIN",value:$deck.gain,range:-12...12,reset:0,color:color)
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
DJKnob(label:"LEVEL",value:$deck.level,range:0...1,reset:0.8,color:color,format:{String(format:"%.0f%%",$0*100)})
}
}.frame(width:100)
}
private var transport: some View {
VStack(spacing:5) {
HStack(spacing:5) {
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").frame(width:20) }.tint(player.playing ? .green : color).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
Button("Cue") { player.returnToCue() }.tint(.orange)
Button("Set") { player.setCue() }.help("Set cue at current position")
Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.help("Stop and return to start")
Button("Sync") { system.sync(deck) }.help("Match tempo to the other deck using confirmed BPM; jog to align beats")
Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.tint(system.previewDeck == deck.letter ? .green : .gray).help("Independent headphone preview on a separate output")
Spacer(minLength:0)
Button("Tap") { deck.tap() }.help("Tap at least three beats to set source BPM")
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true}),format:.number.precision(.fractionLength(1))).frame(width:45).textFieldStyle(.roundedBorder).accessibilityLabel("Deck \(deck.letter) source BPM")
Text(deck.bpmConfirmed ? "BPM" : "BPM?").font(.system(size:8)).foregroundStyle(.secondary)
}.disabled(!player.canJog)
HStack(spacing:5) {
ForEach(0..<4) { i in
Button("\(i+1)") { player.hotCue(i) }.tint(player.hotCues[i] == nil ? .gray : color).contextMenu { Button("Clear hot cue") { player.hotCue(i,clear:true) } }.help("Hot cue \(i+1): click to set or jump; right-click to clear")
}
Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() }
Button(player.loopEnabled ? "Exit Loop" : "Loop") { if player.loopEnabled { player.clearLoop() } else { deck.beatLoop() } }.tint(player.loopEnabled ? .green : .gray).help("Beat loop uses confirmed BPM; manual IN/OUT works without BPM")
Picker("Loop beats",selection:$deck.loopBeats) { ForEach([1,2,4,8,16,32],id:\.self) { Text("\($0)").tag($0) } }.labelsHidden().frame(width:48)
Spacer(minLength:0)
Button("−") { player.jog(-0.05) };Button("+") { player.jog(0.05) }
}.disabled(!player.canJog)
HStack(spacing:5) {
Text("TEMPO").font(.system(size:8)).foregroundStyle(.secondary)
Slider(value:$player.tempo,in:0.5...2).tint(color).accessibilityLabel("Deck \(deck.letter) tempo")
Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).help("Reset tempo")
}
}.buttonStyle(.bordered).controlSize(.mini)
}
}
private struct DJResolveKey: EnvironmentKey { static let defaultValue: (SongDragReference) -> Song? = { _ in nil } }
private extension EnvironmentValues { var djResolveSong: (SongDragReference) -> Song? { get { self[DJResolveKey.self] } set { self[DJResolveKey.self] = newValue } } }
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())
.gesture(DragGesture(minimumDistance:0).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})
.onTapGesture(count:2) { value = reset }
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
@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)
Capsule().fill(color).frame(width:3,height:12).offset(y:-43)
}.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 DJOutputMeter: View {
@ObservedObject var a: Player
@ObservedObject var b: Player
var body: some View {
VStack(spacing:3) {
Text("DECK PEAKS").font(.system(size:8)).foregroundStyle(.secondary)
ForEach(0..<2) { index in
let level = Double(index == 0 ? max(a.leftLevel,a.rightLevel) : max(b.leftLevel,b.rightLevel))
GeometryReader { geometry in ZStack(alignment:.leading) { Rectangle().fill(.white.opacity(0.06));Rectangle().fill(level > 0.95 ? .red : .green).frame(width:geometry.size.width*min(1,max(0,level))) } }.frame(height:5)
}
}.accessibilityLabel("Deck output peak meters")
}
}
private struct DJRoutingSheet: View {
@ObservedObject var model: AppModel
@ObservedObject var system: DJSystem
@State private var karaokeUID = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:18) {
HStack { Text("Audio Routing").font(.title2.bold());Spacer();Button("Refresh Devices") { system.refreshDevices() } }
Picker("DJ master",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("Not assigned").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) }
}
HStack { Text("Headphone level");Slider(value:$system.cueLevel,in:0...1) }
Picker("Karaoke output",selection:$karaokeUID) {
Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) }
}.onChange(of:karaokeUID) { uid in
system.perform {
guard let id = uid.isEmpty ? DJAudioDevice.defaultOutput() : system.devices.first(where:{$0.uid == uid})?.id else { throw LibraryError.message("Karaoke output unavailable") }
try model.player.routeOutput(id)
}
}
Text("Headphone preview starts independently at the selected deck’s current position, before its fader and crossfader. Use a different output device from DJ master. It does not follow later deck seeks, EQ or tempo changes; restart preview to refresh.").font(.callout).foregroundStyle(.secondary)
Text("Each device uses its default stereo pair. Separate master/cue channel pairs on one multichannel controller, aggregate-device setup and phase-locked cue monitoring are not implemented. Background music keeps its existing system output. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary)
Text("DJ music can play alongside karaoke. Stop the DJ decks when a singer starts if you do not want both audible.").font(.caption)
HStack { Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:590).onAppear { system.refreshDevices() }
}
}
private struct DJMappingSheet: View {
@ObservedObject var midi: DJMIDI
@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)
HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:660)
}
}