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.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?;var bytes = UInt32(MemoryLayout.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.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? 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.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self) for _ in 0...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..? 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 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.. 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) } }