737 lines
48 KiB
Swift
737 lines
48 KiB
Swift
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)
|
||
}
|
||
}
|