@@ -0,0 +1,736 @@
import SwiftUI
import AVFoundation
import CoreAudio
import CoreMIDI
// / E q u a l - p o w e r c r o s s f a d e , i n d e p e n d e n t o f t h e k a r a o k e m a s t e r a n d s i n g e r p r e f e r e n c e s .
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 )
// S i l e n c e f i r s t ; l o a d i n g n e v e r s t a r t s p u b l i c p l a y b a c k .
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
}
}
}
// S t r e a m i n g M I D I 1 . 0 d e c o d e r : p r e s e r v e s r u n n i n g s t a t u s a c r o s s p a c k e t s ; i g n o r e s c l o c k a n d S y s E x .
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 ] ( )
// W a l k t h e o r i g i n a l v a r i a b l e - l e n g t h p a c k e t l i s t , n e v e r a c o p i e d 2 5 6 - b y t e t u p l e .
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 )
// M a t c h t h e c u r r e n t l i b r a r y ' s d r a g s e s s i o n b e f o r e a c c e p t i n g a n i n t e r n a l r e f e r e n c e .
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
}
}
// S u p p l i e d b y t h e l i b r a r y v i a a s m a l l e n v i r o n m e n t c l o s u r e , a v o i d i n g p a t h s i n d r a g p a y l o a d s .
@ 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 )
}
}