import SwiftUI import AVFoundation import CoreAudio import CoreMIDI import UniformTypeIdentifiers import Combine /// Equal-power crossfade, independent of the karaoke master and singer preferences. enum DJMix { static func weights(_ position: Double,curve: String = "Equal power") -> (Double, Double) { let x = position.isFinite ? min(1,max(0,position)) : 0.5 if curve == "Linear" { return (1-x,x) } return (cos(x * .pi / 2),sin(x * .pi / 2)) } static func syncRate(bpm: Double, otherBPM: Double, otherRate: Double) -> Double? { guard bpm.isFinite,otherBPM.isFinite,otherRate.isFinite,bpm >= 40,bpm <= 240,otherBPM >= 40,otherBPM <= 240 else { return nil } let result = otherBPM * otherRate / bpm return (0.5...2).contains(result) ? result : nil } } struct DJGraphicEQSettings: Codable, Equatable { var enabled = false var preset = "Flat" var gains = [Double](repeating:0,count:10) static let presets: [(String,[Double])] = [ ("Flat",[0,0,0,0,0,0,0,0,0,0]), ("Rock",[4,3,2,0,-2,-1,1,3,4,3]), ("Pop",[-1,1,3,4,2,0,-1,-1,1,2]), ("Jazz",[3,2,1,1,-1,-1,0,2,3,3]), ("Vocal",[-4,-3,-2,0,2,4,4,2,0,-2]), ("Bass Boost",[6,5,4,2,0,0,0,0,0,0]), ("Treble Boost",[0,0,0,0,0,1,2,3,4,5]) ] var sanitized: Self { var value = self value.gains = (0..<10).map { gains.indices.contains($0) && gains[$0].isFinite ? min(12,max(-12,gains[$0])) : 0 } return value } } struct DJPreferences: Codable, Equatable { var autoPlayOnLoad = false var resetEQOnLoad = false var protectPlayingDeck = true var tempoRange: Double = 16 var invertTempo = true var crossfaderCurve = "Equal power" var headroomDB: Double = 0 var spectralWaveforms = true var waveformSeconds: Double = 8 var showTimeGrid = true var platterMarker = true var dimInactiveDeck = false var libraryFont: Double = 11 var libraryRowHeight: Double = 28 var autoPlayHotCue = false func tempo(at position: Double) -> Double { let x = min(1,max(0,position));return 1+((invertTempo ? 1-x : x)*2-1)*tempoRange/100 } func position(for tempo: Double) -> Double { let x = min(1,max(0,(tempo-1)/(2*tempoRange/100)+0.5));return invertTempo ? 1-x : x } var sanitized: Self { var p = self;p.tempoRange = [8.0,16,50].contains(tempoRange) ? tempoRange : 16 p.headroomDB = [0.0,3,6,9].contains(headroomDB) ? headroomDB : 0 p.waveformSeconds = [4.0,8,16,32].contains(waveformSeconds) ? waveformSeconds : 8 p.libraryFont = libraryFont.isFinite ? min(16,max(10,libraryFont)) : 11 p.libraryRowHeight = libraryRowHeight.isFinite ? min(44,max(24,libraryRowHeight)) : 28 if !["Equal power","Linear"].contains(crossfaderCurve) { p.crossfaderCurve = "Equal power" };return p } } @MainActor final class DJChannel: ObservableObject { let player: Player let letter: String @Published private(set) var song: Song? @Published var bpm: Double = 120 @Published var bpmConfirmed = false @Published var bpmEstimated = false @Published var analysingBPM = false @Published var artwork: NSImage? private var infoTask: Task? private var analysisTask: Task? @Published var gain: Double = 0 { didSet { player.inputGain = gain } } @Published var level: Double = 0.8 { didSet { updateLevel() } } @Published var loopBeats = 8 { didSet { guard oldValue != loopBeats,loopBeats > 0,player.loopEnabled,let start = player.loopIn,let end = player.loopOut else { return } let length = bpmConfirmed ? Double(loopBeats)*60/bpm : (end-start)*Double(loopBeats)/Double(max(1,oldValue)) player.defineLoop(start:start,end:min(player.duration,start+length)) } } var master: Double = 0.8 { didSet { if master != oldValue { updateLevel() } } } var crossWeight: Double = 1 { didSet { if crossWeight != oldValue { updateLevel() } } } private var taps = [Double]() init(_ letter: String,waveformCache: URL? = nil) { self.letter = letter;player = Player(waveformCacheDirectory:waveformCache,persistsMix:false);player.audibleScrubbing = true;player.autoFade = false;updateLevel() } func updateLevel() { let value = min(1,max(0,level * master * crossWeight));if player.volume != value { player.volume = value } } func load(_ song: Song) throws { var audioOnly = song;audioOnly.cdg = nil do { try player.load(audioOnly,startPlaying:false) } catch { self.song = nil;throw error } self.song = song;bpmConfirmed = false;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song) } func updateTrackMetadata(_ song: Song) { self.song = song;player.title = song.title } func unload() { infoTask?.cancel();analysisTask?.cancel();player.stop();song = nil;bpmConfirmed = false;artwork = nil;analysingBPM = false } private func loadTrackInfo(_ song: Song) { infoTask?.cancel();analysisTask?.cancel();artwork = nil;analysingBPM = true infoTask = Task { @MainActor [weak self] in let image = await DJTrackInfo.artwork(song) guard !Task.isCancelled,let self,self.song?.id == song.id else { return } self.artwork = image let task = Task.detached(priority:.utility) { try? DJBPM.analyse(URL(fileURLWithPath:song.audio),cancelled:{Task.isCancelled}) };self.analysisTask = task let bpm = await task.value guard !Task.isCancelled,self.song?.id == song.id else { return } self.analysingBPM = false if !self.bpmConfirmed,let bpm { self.bpm = bpm;self.bpmEstimated = true;self.bpmConfirmed = true } } } deinit { infoTask?.cancel();analysisTask?.cancel() } func tap(at time: Double = ProcessInfo.processInfo.systemUptime) { if let last = taps.last,time-last > 2 { taps = [] } taps.append(time);taps = Array(taps.suffix(8)) guard taps.count >= 3,let first = taps.first,let last = taps.last,last > first else { return } let value = 60 * Double(taps.count-1)/(last-first) if (40...240).contains(value) { bpm = (value*10).rounded()/10;bpmConfirmed = true;bpmEstimated = false } } func beatLoop() { if player.loopEnabled { player.clearLoop();return } guard bpmConfirmed,player.canJog else { return } let start = player.time,end = min(player.duration,start+Double(loopBeats)*60/bpm) player.defineLoop(start:start,end:end) } } struct DJAudioDevice: Identifiable, Equatable { var id: AudioDeviceID var uid: String var name: String var outputChannels = 2 var inputChannels = 0 var stereoPairs: [Int] { Array(stride(from:0,to:max(0,outputChannels-1),by:2)) } static func channelCount(_ id: AudioDeviceID,scope: AudioObjectPropertyScope = kAudioDevicePropertyScopeOutput) -> Int { var address = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreamConfiguration,mScope:scope,mElement:kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout.size else { return 0 } let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout.alignment);defer { memory.deallocate() } guard AudioObjectGetPropertyData(id,&address,0,nil,&size,memory) == noErr else { return 0 } return UnsafeMutableAudioBufferListPointer(memory.assumingMemoryBound(to:AudioBufferList.self)).reduce(0) { $0+Int($1.mNumberChannels) } } static func outputs() -> [Self] { allDevices().filter { $0.outputChannels > 0 } } static func allDevices() -> [Self] { var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size) == noErr else { return [] } var ids = [AudioDeviceID](repeating:0,count:Int(size)/MemoryLayout.size) let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) } guard result == noErr else { return [] } return ids.compactMap { id in let channels = channelCount(id),inputs = channelCount(id,scope:kAudioDevicePropertyScopeInput) guard channels > 0 || inputs > 0 else { return nil } func string(_ selector: AudioObjectPropertySelector) -> String? { var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var text: Unmanaged?;var bytes = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil } return text?.takeRetainedValue() as String? } return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels,inputChannels:inputs) } } static func defaultOutput() -> AudioDeviceID? { var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var id = AudioDeviceID(0),size = UInt32(MemoryLayout.size) return AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,&id) == noErr && id != 0 ? id : nil } } @MainActor final class DJSystem: ObservableObject { @Published private(set) var storageInSettings = true @Published var cleanupPlan: DJCleanupPlan? @Published private(set) var storageAction: String? private func beginMaintenance(_ title: String,work: @escaping @Sendable (@escaping () -> Bool,@escaping (ImportProgress) -> Void) throws -> ImportProgress,completion: (() -> Void)? = nil) { guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return } storageAction = title;movingStorage = true;storageProgress = .init(current:title) storageTask = Task.detached(priority:.utility) { [weak self] in var latest = ImportProgress(current:title) do { latest = try work({Task.isCancelled},{ value in latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } }) } catch is CancellationError { latest.cancelled = true;latest.current = "Cancelled; incomplete work stopped" } catch { latest.failure = error.localizedDescription;latest.current = "Library maintenance needs attention" } latest.finished = true;let result = latest await MainActor.run { [weak self] in guard let self else { return };self.movingStorage = false;self.storageTask = nil if !result.cancelled,result.failure == nil { completion?() } self.storageProgress = result } } } func removeLibraryTracks(_ ids: Set,fromSettings: Bool = false) { guard !ids.isEmpty else { return } guard ![a.player,b.player,preview].contains(where:{$0.playing && ($0.currentSongID.map{ids.contains($0)} ?? false)}) else { error = "Stop the affected deck before removing its track.";return } guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return } automaticWaveforms.cancel() storageInSettings = fromSettings automix.stop("Library update") for deck in [a,b] where deck.song.map({ids.contains($0.id)}) == true { deck.unload() } if preview.currentSongID.map({ids.contains($0)}) == true { stopPreview() } let database = root.appendingPathComponent("Library.sqlite"),removingAll = !songs.isEmpty && Set(songs.map(\.id)).isSubset(of:ids) beginMaintenance("Removing Library Tracks",work:{ cancelled,_ in try DJLibraryMaintenance.removeTracks(ids,database:database,cancelled:cancelled) return ImportProgress(completed:ids.count,total:ids.count,current:"Removed \(ids.count) library tracks. Media files were kept.",finished:true,logLines:["[REMOVED] \(ids.count) library entries. Original and managed media files were kept."]) },completion:{ [weak self] in guard let self else { return } if removingAll { self.automix.clear();self.favourites.removeAll() } else { self.automix.removeSongs(ids);self.favourites.subtract(ids) } self.perform { try self.library?.setSetting("DJ.Favourites.v1",self.favourites.sorted().map(String.init).joined(separator:","));try self.refresh() } }) } func findUnusedFiles() { guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return } guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback before cleaning DJ storage.";return } let database = root.appendingPathComponent("Library.sqlite"),folder = root storageInSettings = true;storageAction = "Finding Unused Files";movingStorage = true;storageProgress = .init(current:"Finding unused DJ files…") storageTask = Task.detached(priority:.utility) { [weak self] in do { let plan = try DJLibraryMaintenance.cleanupPlan(database:database,root:folder,cancelled:{Task.isCancelled}) { value in Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } } await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.cleanupPlan = plan;self?.storageProgress = nil } } catch { let cancelled = error is CancellationError,message = error.localizedDescription await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.storageProgress = .init(current:cancelled ? "Scan cancelled" : "Cleanup needs attention",finished:true,cancelled:cancelled,failure:cancelled ? nil : message) } } } } func cleanUnusedFiles(_ plan: DJCleanupPlan) { let database = root.appendingPathComponent("Library.sqlite"),folder = root beginMaintenance("Cleaning Unused Files",work:{ cancelled,progress in try DJLibraryMaintenance.clean(plan,database:database,root:folder,cancelled:cancelled,progress:progress) }) cleanupPlan = nil } @Published var copyImportedMedia = false { didSet { perform { try library?.setSetting("DJ.CopyImportedMedia.v1",copyImportedMedia ? "1" : "0") } } } private var referenceAccess = [String:URL]() private var referenceLocations = [MediaLocation]() private func ensureReferenceAccess(_ path: String) { for location in referenceLocations where path == location.path || path.hasPrefix(location.path+"/") { guard referenceAccess[location.path] == nil,let bookmark = location.bookmark else { continue } var stale = false if let url = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale),url.startAccessingSecurityScopedResource() { referenceAccess[location.path] = url } } } @Published private(set) var mediaDirectory: URL @Published private(set) var waveformDirectory: URL @Published private(set) var movingStorage = false @Published var storageProgress: ImportProgress? private var storageTask: Task? private var storageAccess: URL? private func loadStorage() throws { let location = try library.flatMap{try DJStorageMove.location(in:$0)} var folder = location.map{URL(fileURLWithPath:$0.path)} ?? root if let bookmark = location?.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { folder = resolved } } if storageAccess != folder { storageAccess?.stopAccessingSecurityScopedResource();storageAccess = folder.startAccessingSecurityScopedResource() ? folder : nil } mediaDirectory = folder.appendingPathComponent("Media");waveformDirectory = folder.appendingPathComponent("Waveforms") for player in [a.player,b.player,preview] { player.useWaveformCache(at:waveformDirectory) } } func chooseStorage() { guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return } guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop both decks and headphone preview before moving DJ files.";return } let panel = NSOpenPanel();panel.title = "Move DJ Media and Waveforms";panel.prompt = "Move Here" panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true panel.message = "Choose a folder on your external drive. DJ media and saved waveforms move into Media and Waveforms folders here. The database and playlists stay on this Mac." panel.begin { [weak self] response in guard response == .OK,let url = panel.url else { return };Task { @MainActor in self?.moveStorage(to:url) } } } func moveStorage(to destination: URL) { guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return } let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root automaticWaveforms.cancel() a.unload();b.unload();stopPreview();storageInSettings = true;storageAction = nil;movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…") storageTask = Task.detached(priority:.utility) { [weak self] in let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } } var latest = ImportProgress() do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } } } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" } catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" } latest.finished = true;let result = latest await MainActor.run { [weak self] in guard let self else { return };self.movingStorage = false;self.storageTask = nil self.perform { try self.loadStorage();try self.refresh() } for (id,deck) in [(loadedA,self.a),(loadedB,self.b)] { if let id,let song = self.songs.first(where:{$0.id == id}) { self.perform { try deck.load(song) } } } self.storageProgress = result } } } func cancelStorageMove() { storageTask?.cancel();storageProgress?.current = "Cancelling… Originals and verified copies will be kept." } @Published var namesBusy = false private var graphicSaveTask: Task? @Published var graphicEQ = DJGraphicEQSettings() { didSet { applyGraphicEQ() graphicSaveTask?.cancel() let url = root.appendingPathComponent("DJGraphicEQ.json"),value = graphicEQ.sanitized graphicSaveTask = Task { @MainActor in do { try await Task.sleep(nanoseconds:200_000_000);try JSONEncoder().encode(value).write(to:url,options:.atomic) } catch { } } } } private func applyGraphicEQ() { for player in [a.player,b.player,preview] { player.applyGraphicEQ(graphicEQ.sanitized.gains,enabled:graphicEQ.enabled) } } func chooseGraphicPreset(_ name: String) { guard let gains = DJGraphicEQSettings.presets.first(where:{$0.0 == name})?.1 else { return } graphicEQ = .init(enabled:true,preset:name,gains:gains) } @Published var automaticWaveformProgress: WaveformJobProgress? let automaticWaveforms = PostImportWaveformQueue() private func queueImportedWaveforms(_ ids: Set) { automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:referenceLocations,cache:waveformDirectory,update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in guard let self else { return } for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() } }) } @Published private(set) var waveformJob: WaveformJobProgress? @Published private(set) var generatingWaveforms = false private var waveformTask: Task? func generateWaveforms(_ ids: Set? = nil,force: Bool = false) { guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return } let tracks = songs.filter { ids == nil || ids!.contains($0.id) } guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return } let cache = waveformDirectory,locations = referenceLocations generatingWaveforms = true;waveformJob = .init(total:tracks.count) waveformTask = Task.detached(priority:.utility) { [weak self] in let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,progress: { update in Task { @MainActor [weak self] in self?.waveformJob = update } }) await MainActor.run { [weak self] in guard let self else { return } self.waveformJob = result;self.generatingWaveforms = false;self.waveformTask = nil for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() } } } } func cancelWaveforms() { waveformTask?.cancel();waveformJob?.current = "Cancelling…" } func dismissWaveforms() { if !generatingWaveforms { waveformJob = nil } } @Published private(set) var favourites = Set() let a: DJChannel let b: DJChannel let preview = Player(persistsMix:false) let midi = DJMIDI() lazy var automix = DJAutomix(system:self) @Published var crossfader: Double = 0.5 { didSet { applyMix() } } @Published var master: Double = 0.8 { didSet { applyMix() } } @Published private(set) var syncActive = false private(set) var syncLeader = "A" private var syncOriginal: (Double,Double)? private var syncObservers = Set() private var updatingSync = false @Published var pendingReplacement: DJReplacement? @Published var cueEditor: String? @Published var cueDraft: Double = 0 @Published var cueEditing = false @Published var loopEditor: String? @Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } } @Published private(set) var previewDeck: String? @Published var devices = [DJAudioDevice]() @Published private(set) var detectedDevices = [DJAudioDevice]() @Published var masterUID = "" @Published var cueUID = "" @Published private(set) var routing = DJAudioRouting() private var audioDeviceMonitor: DJAudioDeviceMonitor? @Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } } @Published var status = "Load music into A or B. Karaoke keeps its own mixer and output." @Published var error: String? let root: URL private(set) var library: Library? let dragSession = UUID().uuidString @Published var songs = [Song]() @Published var playlists = [PersonPlace]() @Published var importProgress: ImportProgress? @Published var lastImportReport: ImportProgress? @Published var importLogVisible = false @Published private(set) var importing = false private var importTask: Task? private var importGeneration = UUID() private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]() init(storageURL: URL? = nil) { root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ") mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms") a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms")) preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix() Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in Task { @MainActor [weak self] in self?.maintainSync() } }.store(in:&syncObservers) Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in Task { @MainActor [weak self] in self?.updateControllerFeedback() } }.store(in:&syncObservers) Publishers.CombineLatest4(a.player.$hotCues,b.player.$hotCues,a.player.$loopEnabled,b.player.$loopEnabled).sink { [weak self] _ in Task { @MainActor [weak self] in self?.updateControllerFeedback() } }.store(in:&syncObservers) perform { try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) library = try Library(url:root.appendingPathComponent("Library.sqlite"));copyImportedMedia = try library?.setting("DJ.CopyImportedMedia.v1") == "1";try loadStorage();try refresh() if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized } applyGraphicEQ() favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)}) if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized } if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) } applyMix() } audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() } } private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } } func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [];referenceLocations = try library?.mediaLocations() ?? [] } func toggleFavourite(_ song: Int64) { var updated = favourites if !updated.insert(song).inserted { updated.remove(song) } perform { try library?.setSetting("DJ.Favourites.v1",updated.sorted().map(String.init).joined(separator:",")) favourites = updated } } func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } } func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } } func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);try refresh() } } func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } } func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) } func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) { guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return } importing = true;importProgress = .init();importLogVisible = true let importID = UUID();importGeneration = importID let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id} importTask = Task.detached(priority:.utility) { [weak self] in let access = urls.filter{$0.startAccessingSecurityScopedResource()} defer { for url in access { url.stopAccessingSecurityScopedResource() } } var latest = ImportProgress() var importedIDs = [Int64]() do { let db = try Library(url:database) if !copyMedia { for url in urls { if Task.isCancelled { throw CancellationError() } let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil)) _ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true) } } importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in latest = value Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } } } } catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" } catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" } latest.finished = true;let result = latest,loadedIDs = importedIDs await MainActor.run { [weak self] in guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result self.lastImportReport = result self.perform { try self.refresh() } if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } } else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) } if !result.cancelled { self.queueImportedWaveforms(Set(loadedIDs).union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) } self.continueFileLoads() } } } func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } } func continueFileLoads() { guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return } let next = queuedFileLoads.removeFirst() requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song) } func cancelReplacement() { pendingReplacement = nil;continueFileLoads() } deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource();for url in referenceAccess.values { url.stopAccessingSecurityScopedResource() } } func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b } private(set) var nextLibraryDeck = "A" func loadFromLibraryDoubleClick(_ song: Song) { guard pendingReplacement == nil else { return } let target = nextLibraryDeck nextLibraryDeck = target == "A" ? "B" : "A" if channel(target).player.playing { pendingReplacement = .init(deck:target,song:song,urls:[]) } else { load(song,into:target) } } func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) { guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return } perform { ensureReferenceAccess(song.audio) let deck = channel(letter) if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return } if !automated { automix.manualTakeover() } if syncActive { disableSync() } if previewDeck == letter { stopPreview() } let route = routing.route(letter) // Configure HAL before load starts the engine, rather than restarting it immediately afterwards. do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel);try deck.load(song) } catch { deck.unload();throw error } if settings.resetEQOnLoad { deck.player.resetTone() } if settings.autoPlayOnLoad && !automated { deck.player.toggle() } status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops" } } func refreshDevices() { let all = DJAudioDevice.allDevices(),outputs = all.filter{$0.outputChannels > 0} if detectedDevices != all { detectedDevices = all } if devices != outputs { devices = 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) { var next = routing;next.main.uid = uid;next.main.firstChannel = 0;applyRouting(next) } func applyRouting(_ next: DJAudioRouting) { let previous = routing perform { try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) automix.manualTakeover() let previewActive = previewDeck != nil do { for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } if previewActive,!next.cue.uid.isEmpty { try preview.routeOutput(try device(next.cue.uid),firstChannel:next.cue.firstChannel) } try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic) routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix() if next.cue.uid.isEmpty { stopPreview() } status = "Audio routing applied · \(next.mode) mixer" } catch { for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } } if previewActive,!previous.cue.uid.isEmpty,let output = try? device(previous.cue.uid) { try? preview.routeOutput(output,firstChannel:previous.cue.firstChannel) } throw error } } } private func validateCue() throws { var config = routing;config.cue.uid = cueUID try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) } func previewLibrary(_ song: Song) { ensureReferenceAccess(song.audio) perform { guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") } let output = try device(cueUID) try validateCue() stopPreview();var audio = song;audio.cdg = nil try preview.load(audio,startPlaying:false);try preview.routeOutput(output,firstChannel:routing.cue.firstChannel);preview.toggle();previewDeck = "Library" } } func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() } 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) try validateCue() stopPreview();var audio = song;audio.cdg = nil try preview.load(audio,startPlaying:false) try preview.routeOutput(output,firstChannel:routing.cue.firstChannel) 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;updateControllerFeedback() status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position" } } func sync(_ deck: DJChannel) { if syncActive { disableSync();return } guard a.player.canJog,b.player.canJog else { status = "Load both decks before enabling Sync.";return } guard a.bpmConfirmed,b.bpmConfirmed else { status = "Waiting for BPM analysis. Set or tap BPM on both decks if a tempo cannot be detected.";return } let follower = deck.letter == "A" ? b : a let target = deck.bpm*deck.player.tempo,rate = target/follower.bpm guard rate.isFinite,(0.5...2).contains(rate) else { status = "These tracks require a tempo outside the supported 50–200% range.";return } syncOriginal = (a.player.tempo,b.player.tempo) syncLeader = deck.letter;syncActive = true;updatingSync = true;follower.player.tempo = rate;updatingSync = false status = String(format:"SYNC ON · Both decks %.1f BPM · %@ is master",target,deck.letter) } func disableSync() { guard syncActive else { return };syncActive = false if let original = syncOriginal { a.player.tempo = original.0;b.player.tempo = original.1 } syncOriginal = nil;status = "SYNC OFF · Both decks restored to their pre-sync tempos" } private func maintainSync() { guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return } let leader = channel(syncLeader),follower = syncLeader == "A" ? b : a let rate = leader.bpm*leader.player.tempo/follower.bpm guard rate.isFinite,(0.5...2).contains(rate) else { disableSync();return } if abs(follower.player.tempo-rate) > 0.00001 { updatingSync = true;follower.player.tempo = rate;updatingSync = false } } private func setDeckTempo(_ rate: Double,deck: DJChannel) { if syncActive,deck.letter != syncLeader { let leader = channel(syncLeader) leader.player.tempo = deck.bpm*rate/leader.bpm maintainSync() } else { deck.player.tempo = rate } } func toggleLoop(_ deck: DJChannel) { guard deck.player.canJog else { status = "Load a track first.";return } guard deck.player.loopEnabled || deck.player.time < deck.player.duration-0.1 else { status = "Move the playhead before the end of the track to set a loop.";return } guard deck.player.loopEnabled || deck.bpmConfirmed else { status = "Waiting for BPM analysis. Enter or tap the track BPM, or define a loop with IN and OUT.";return } deck.beatLoop();status = deck.player.loopEnabled ? "Deck \(deck.letter) · \(deck.loopBeats)-beat loop active" : "Deck \(deck.letter) · Loop off" } func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true } func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false } private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck,aCues:a.player.hotCues.map{$0 != nil},bCues:b.player.hotCues.map{$0 != nil},aLoop:a.player.loopEnabled,bLoop:b.player.loopEnabled) } func hotCue(_ index: Int,deck: DJChannel) { let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil deck.player.hotCue(index) if existing,settings.autoPlayHotCue,!deck.player.playing { deck.player.toggle() } } func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) { if name == "Controller feedback" { updateControllerFeedback();return } if name == "Crossfader" { automix.manualTakeover();crossfader = min(1,max(0,value));return } if name == "Master" { master = min(1,max(0,value));return } if name == "Cue volume" { cueLevel = 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 if ["Play","Stop","Cue","Cue button","Cue play","Sync","Load","Tempo","Tempo XML","Jog","Jog ticks"].contains(parts[1]) { automix.manualTakeover() } switch parts[1] { case "Play": p.toggle() case "Stop": p.stopDeck() case "Cue": p.returnToCue() case "Set cue": p.setCue() case "Cue button": if p.playing { p.returnToCue() } else { p.setCue() } case "Cue play": p.returnToCue();p.toggle() case "Loop off": p.clearLoop() case "Loop in": p.markLoopIn() case "Loop out": p.markLoopOut() case "Loop double","Loop half": let factor = parts[1] == "Loop double" ? 2.0 : 0.5 d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor))) case "Tempo XML": let rate = 1+(value*2-1)*settings.tempoRange/100 setDeckTempo(rate,deck:d) case "Loop 1","Loop 2","Loop 4","Loop 8","Loop 16","Loop 32": let beats = Int(parts[1].split(separator:" ").last!) ?? 8 if p.loopEnabled,d.loopBeats != beats { d.loopBeats = beats;status = "Deck \(d.letter) · \(beats)-beat loop active" } else { d.loopBeats = beats;toggleLoop(d) } case "Clear cue 1","Clear cue 2","Clear cue 3","Clear cue 4": p.hotCue((Int(parts[1].suffix(1)) ?? 1)-1,clear:true) case "Sync": sync(d) case "Loop": toggleLoop(d) case "Headphones": startPreview(d) case "Load": if let selected { load(selected,into:d.letter) } case "Gain": d.gain = value*24-12 case "High": p.highGain = value*24-12 case "Mid": p.midGain = value*24-12 case "Low": p.lowGain = value*24-12 case "Filter": p.filter = value*2-1 case "Level": d.level = value case "Tempo": setDeckTempo(settings.tempo(at:value),deck:d) case "Jog ticks": p.jog(value*2/242) case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2) case "Hot cue 1": hotCue(0,deck:d) case "Hot cue 2": hotCue(1,deck:d) case "Hot cue 3": hotCue(2,deck:d) case "Hot cue 4": hotCue(3,deck:d) default: break } } } // Streaming MIDI 1.0 decoder: preserves running status across packets; ignores clock and SysEx. struct DJMIDIMessage: Equatable { var status: UInt8;var data1: UInt8;var data2: UInt8 } struct DJMIDIDecoder { private var status: UInt8 = 0 private var data = [UInt8]() mutating func feed(_ bytes: [UInt8]) -> [DJMIDIMessage] { var result = [DJMIDIMessage]() for byte in bytes { if byte >= 0xF8 { continue } if byte & 0x80 != 0 { status = byte < 0xF0 ? byte : 0;data = [];continue } guard status != 0 else { continue };data.append(byte) let count = [0xC0,0xD0].contains(status & 0xF0) ? 1 : 2 if data.count == count { if [0x80,0x90,0xB0,0xE0].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 private(set) var profiles = [String:String]() @Published var xmlCatalog = DJXMLProfile.bundled @Published private(set) var xmlAssignments = [String:DJXMLProfile]() @Published var autoDetect = true { didSet { defaults.set(autoDetect,forKey:"DJ.MIDIAutoDetect.v1");if autoDetect { refreshSources() } } } private var pickupValues = [String:Double](),pickedUp = Set() var currentValue: ((String) -> Double?)? var activeXML: DJXMLProfile? { profiles[String(sourceID)]?.hasPrefix("XML:") == true ? xmlAssignments[String(sourceID)] : nil } private var mixtrack = MixtrackGoProtocol() private var outputPort = MIDIPortRef(0),outputDestination = MIDIEndpointRef(0) private var closing = false private var terminationObserver: AnyCancellable? private var initializationTask: Task? private var lastFeedback = [UInt8]() @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,catalog: [DJXMLProfile] = DJXMLProfile.bundled) { self.defaults = defaults;self.xmlCatalog = catalog autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved } if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) } for (source,profile) in xmlAssignments { if let updated = xmlCatalog.first(where:{$0.id == profile.id}) { xmlAssignments[source] = updated } } profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:] if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved } guard connect else { return } terminationObserver = NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification).sink { [weak self] _ in self?.shutdown() } 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 } MIDIOutputPortCreate(client,"DJ feedback" as CFString,&outputPort) let input = MIDIInputPortCreateWithBlock(client,"DJ controls" as CFString,&port) { [weak self] list,_ in var bytes = [UInt8]() // Walk the original variable-length packet list, never a copied 256-byte tuple. var packet = UnsafeRawPointer(list).advanced(by:MemoryLayout.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self) for _ in 0...offset(of: \.data)!) bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length))) packet = UnsafePointer(MIDIPacketNext(packet)) } let received = bytes Task { @MainActor in self?.receive(received) } } if input != noErr { status = "MIDI input unavailable (\(input))" } refreshSources() } func refreshSources() { sources = (0..? MIDIObjectGetIntegerProperty(endpoint,kMIDIPropertyUniqueID,&uid) MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&name) return Source(id:uid,endpoint:endpoint,name:name?.takeRetainedValue() as String? ?? "MIDI source") } if connected != 0,!sources.contains(where:{$0.endpoint == connected}) { disconnect();status = "Controller disconnected · waiting to reconnect" } if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber, sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) } if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil { let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil } if matches.count == 1,let match = matches.first { select(match.id) } else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" } } } private func disconnect() { initializationTask?.cancel();initializationTask = nil;lastFeedback = [] if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = [] } func select(_ id: Int32) { guard !closing else { return } disconnect() if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return } 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;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name) if autoDetect,profiles[String(id)] == nil { if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) } else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() } } else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) } else if profiles[String(id)] == "Mixtrack Go" { configureMixtrackMappings();onAction?("Controller feedback",0,false) } } else { status = "Could not connect MIDI source (\(result))" } } func receive(_ bytes: [UInt8]) { guard !closing else { return } for message in decoder.feed(bytes) { let type = message.status & 0xF0 if let profile = activeXML,profile.adapter == nil,learning == nil { if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue } for binding in profile.bindings where binding.matches(message) { let value = binding.value(message) if binding.pickup,binding.mode == "Absolute",let target = currentValue?(binding.action),!pickedUp.contains(binding.key) { let previous = pickupValues[binding.key];pickupValues[binding.key] = value guard abs(value-target) < 0.025 || previous.map({ ($0-target)*(value-target) <= 0 }) == true else { continue } pickedUp.insert(binding.key) } if binding.mode != "Absolute",value == 0 { continue } onAction?(binding.action,value,binding.mode != "Absolute") } continue } 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 } if mixtrackEnabled,let events = mixtrack.consume(message) { for event in events { if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) } else { onAction?(event.action,event.value,event.relative) } } onAction?("Controller feedback",0,false) 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","Cue volume","Browse"].contains(map.action) if !continuous && message.data2 == 0 { continue } onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute") } } var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" } private func configureMixtrackMappings() { let preset = MixtrackGoProtocol.mappings(source:sourceID) let ids = Set(preset.map(\.id)) mappings.removeAll { ids.contains($0.id) };mappings += preset;mixtrack = .init();save();initializeMixtrack() } func installMixtrackGo() { profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1") configureMixtrackMappings() status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pads and LED feedback" onAction?("Controller feedback",0,false) } func shutdown() { resetController();closing = true } func resetController() { initializationTask?.cancel();initializationTask = nil;lastFeedback = [];learning = nil if mixtrackEnabled { sendBytes(MixtrackGoProtocol.shutdownBytes) } else if let outputs = activeXML?.outputs { for output in outputs { sendBytes([output.status,output.number,0]) } } mixtrack = .init() } func useCustomProfile() { initializationTask?.cancel();lastFeedback = [];profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" } func mergeXML(_ imported: [DJXMLProfile]) { let ids = Set(imported.map(\.id));xmlCatalog.removeAll { ids.contains($0.id) };xmlCatalog += imported xmlCatalog.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } func addXMLProfiles(_ imported: [DJXMLProfile]) { mergeXML(imported) if let data = try? JSONEncoder().encode(xmlCatalog) { defaults.set(data,forKey:"DJ.XMLCatalog.v1") } status = "Imported \(imported.count) XML profiles · select one to review and apply" if autoDetect { refreshSources() } } func applyXML(_ profile: DJXMLProfile) { guard !profile.bindings.isEmpty else { status = "This profile has no supported standalone MIDI controls · see compatibility report";return } xmlAssignments[String(sourceID)] = profile;profiles[String(sourceID)] = "XML:"+profile.id defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1") if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") } pickupValues = [:];pickedUp = [];learning = nil if profile.adapter == "Mixtrack Go" { configureMixtrackMappings() } onAction?("Controller feedback",0,false) status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report" } private func initializeMixtrack() { initializationTask?.cancel();lastFeedback = [] sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]) sendBytes([0xF0,0x00,0x20,0x7F,0x03,0x01,0xF7]) let source = sourceID,destination = outputDestination // The controller can clear LEDs as its startup demo stops. Refresh after that transition. initializationTask = Task { [weak self] in do { try await Task.sleep(nanoseconds:2_000_000_000) } catch { return } guard let self,self.sourceID == source,self.outputDestination == destination,self.mixtrackEnabled else { return } self.onAction?("Controller feedback",0,false);self.sendBytes(self.lastFeedback) } } private func selectFeedbackOutput(_ name: String) { outputDestination = 0 for i in 0..? MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&result) let candidate = (result?.takeRetainedValue() as String? ?? "").lowercased() if candidate == name.lowercased() { outputDestination = endpoint;break } } } func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) { guard !closing else { return } if !mixtrackEnabled { guard let outputs = activeXML?.outputs else { return } for output in outputs { let parts = output.action.split(separator:" ",maxSplits:1).map(String.init) guard parts.count == 2 else { continue } let isA = parts[0] == "A",loaded = isA ? aLoaded : bLoaded,cues = isA ? aCues : bCues let active: Bool switch parts[1] { case "Play": active = isA ? aPlaying : bPlaying case "Cue button": active = loaded case "Sync": active = sync case "Headphones": active = cue == parts[0] case "Loop": active = isA ? aLoop : bLoop default: let index = (Int(parts[1].split(separator:" ").last ?? "0") ?? 0)-1 active = cues.indices.contains(index) && cues[index] } sendBytes(output.packet(active:active)) } return } lastFeedback = mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop) sendBytes(lastFeedback) } private func sendBytes(_ bytes: [UInt8]) { guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return } var list = MIDIPacketList() withUnsafeMutablePointer(to:&list) { pointer in let packet = MIDIPacketListInit(pointer) _ = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout.size,packet,0,$0.count,$0.baseAddress!) } MIDISend(outputPort,outputDestination,pointer) } } 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 { initializationTask?.cancel();if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } } } private enum DJStyle { static let background = Color(red:0.045,green:0.055,blue:0.075) static let panel = Color(red:0.085,green:0.10,blue:0.13) static let a = Color(red:0.18,green:0.85,blue:0.76) static let b = Color(red:0.69,green:0.55,blue:1.0) 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) } } /// Observe user scrolling, rather than row appearance (List preloads offscreen rows). private struct DJLibraryPageScroll: NSViewRepresentable { var page: Int let next: () -> Void func makeNSView(context: Context) -> Observer { Observer() } func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach();view.setPage(page) } final class Observer: NSView { var next: (() -> Void)? private weak var scroll: NSScrollView? private var observation: NSObjectProtocol? private var endObservation: NSObjectProtocol? private var currentPage = 0 func setPage(_ page: Int) { guard page != currentPage else { return };currentPage = page DispatchQueue.main.async { [weak self] in guard let scroll = self?.scroll else { return } scroll.contentView.scroll(to:.zero);scroll.reflectScrolledClipView(scroll.contentView);self?.lastY = 0 } } private var advanced = false private var lastY: CGFloat = 0 override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } } func attach() { guard window != nil,scroll == nil else { return } func find(_ view: NSView) -> NSScrollView? { if let scroll = view as? NSScrollView { return scroll } for child in view.subviews { if let found = find(child) { return found } } return nil } var parent = superview while let view = parent { if let found = find(view) { scroll = found;break } parent = view.superview } guard let scroll else { return } lastY = scroll.contentView.bounds.origin.y observation = NotificationCenter.default.addObserver(forName:NSScrollView.didLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in guard let self,let scroll = self.scroll,let document = scroll.documentView else { return } let y = scroll.contentView.bounds.origin.y let forward = y > self.lastY+0.1 self.lastY = y let bottom = document.bounds.height-scroll.contentView.bounds.height guard !self.advanced,forward,bottom > 1,y >= bottom-2 else { return } self.advanced = true DispatchQueue.main.async { [weak self] in self?.next?() } } endObservation = NotificationCenter.default.addObserver(forName:NSScrollView.didEndLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in self?.advanced = false;self?.lastY = self?.scroll?.contentView.bounds.origin.y ?? 0 } } deinit { if let observation { NotificationCenter.default.removeObserver(observation) } if let endObservation { NotificationCenter.default.removeObserver(endObservation) } } } } private struct DJQueueButton: View { @ObservedObject var automix: DJAutomix let open: () -> Void var body: some View { Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") } .tint(automix.enabled ? .green : .accentColor).help("Open the DJ queue, reorder tracks and control Automix") } } private struct DJLibrarySnapshot: View, Equatable { let revision: UUID let selection: Set let selected: Int64? let page: Int let busy: Bool let font: Double let height: Double @ViewBuilder let content: () -> Content static func == (lhs: Self,rhs: Self) -> Bool { lhs.revision == rhs.revision && lhs.selection == rhs.selection && lhs.selected == rhs.selected && lhs.page == rhs.page && lhs.busy == rhs.busy && lhs.font == rhs.font && lhs.height == rhs.height } var body: some View { content() } } enum DJCollection: String,CaseIterable,Identifiable { case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata" var id: String { rawValue } var icon: String { switch self { case .all:return "music.note.list";case .favourites:return "star.fill";case .rated:return "rosette";case .unrated:return "star";case .missingArtist:return "tag" } } func includes(_ song: Song,favourites: Set) -> Bool { switch self { case .all:return true case .favourites:return favourites.contains(song.id) case .rated:return song.rating >= 4 case .unrated:return song.rating == 0 case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty } } } enum DJLibrarySort: String,CaseIterable,Identifiable { case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order" var id: String { rawValue } } enum DJLibraryBrowser { static func rows(_ songs: [Song],collection: DJCollection,favourites: Set,playlist: [Int64]? = nil,query: String = "",sort: DJLibrarySort = .title,ascending: Bool = true) -> [Song] { let source: [Song] if let playlist { let lookup = Dictionary(uniqueKeysWithValues:songs.map{($0.id,$0)});source = playlist.compactMap{lookup[$0]} } else { source = songs } let filtered = source.filter { song in guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false } return collection.includes(song,favourites:favourites) } guard sort != .source else { let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered return ascending ? ordered : Array(ordered.reversed()) } return filtered.sorted { lhs,rhs in let comparison: ComparisonResult switch sort { case .title:comparison = lhs.title.localizedStandardCompare(rhs.title) case .artist:comparison = lhs.artist.localizedStandardCompare(rhs.artist) case .album:comparison = lhs.album.localizedStandardCompare(rhs.album) case .duration:comparison = lhs.duration == rhs.duration ? .orderedSame : lhs.duration < rhs.duration ? .orderedAscending : .orderedDescending case .rating:comparison = lhs.rating == rhs.rating ? .orderedSame : lhs.rating < rhs.rating ? .orderedAscending : .orderedDescending case .source:comparison = .orderedSame } if comparison == .orderedSame { return ascending ? lhs.id < rhs.id : lhs.id > rhs.id } return ascending ? comparison == .orderedAscending : comparison == .orderedDescending } } } struct DJConsole: View { @ObservedObject var system: DJSystem @State private var removingIDs = Set() @State private var confirmTrackRemoval = false @State private var reinterpreting = false @State private var reinterpretIDs = Set() @State private var editingTrack: Song? @State private var graphicEQVisible = false @State private var waveformDetails = false @State private var collection = DJCollection.all @State private var librarySort = DJLibrarySort.title @State private var ascending = true @State private var collectionCounts = [DJCollection:Int]() @AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true @AppStorage("DJStudio.performanceHeight") private var performanceHeight = 454.0 @AppStorage("DJStudio.sidebarWidth") private var sidebarWidth = 184.0 @State private var performanceDragStart: Double? @State private var sidebarDragStart: Double? @State private var queueVisible = false @State private var query = "" @State private var playlist: Int64? @State private var playlistIDs = [Int64]() @State private var selection = Set() @State private var selected: Int64? @State private var playlistEditor = false @State private var playlistName = "" @State private var renaming = false @State private var page = 0 @State private var libraryRevision = UUID() @State private var filteredTracks = [Song]() private var songs: [Song] { filteredTracks } private func updateFilteredTracks() { libraryRevision = UUID() filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending) collectionCounts = Dictionary(uniqueKeysWithValues:DJCollection.allCases.map { scope in (scope,system.songs.lazy.filter{!$0.audio.hasPrefix("showscreen:") && scope.includes($0,favourites:system.favourites)}.count) }) selection.formIntersection(Set(filteredTracks.map(\.id))) page = min(page,max(0,(filteredTracks.count+49)/50-1)) if let selected,!selection.contains(selected) { self.selected = nil } } private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } } private func selectTrack(_ id: Int64) { let modifiers = NSEvent.modifierFlags if modifiers.contains(.shift),let anchor = selected,let first = songs.firstIndex(where:{$0.id == anchor}),let last = songs.firstIndex(where:{$0.id == id}) { let range = Set(songs[min(first,last)...max(first,last)].map(\.id)) if modifiers.contains(.command) { selection.formUnion(range) } else { selection = range } } else if modifiers.contains(.command) { if !selection.insert(id).inserted { selection.remove(id) } selected = id } else { selection = [id];selected = id } } private func selectAllTracks() { selection = Set(songs.map(\.id));selected = songs.first?.id } private func contextTracks(_ song: Song) -> [Song] { selection.contains(song.id) ? selectedTracks : [song] } 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 { VStack(spacing:10) { header GeometryReader { geometry in VStack(spacing:0) { DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(height:performanceSize(geometry.size.height)) RoundedRectangle(cornerRadius:2).fill(Color.white.opacity(0.16)).frame(width:44,height:3).frame(maxWidth:.infinity).frame(height:10).contentShape(Rectangle()) .help("Drag to resize the performance area; double-click to reset") .onTapGesture(count:2) { performanceHeight = 454 } .gesture(DragGesture(coordinateSpace:.global).onChanged { value in if performanceDragStart == nil { performanceDragStart = performanceSize(geometry.size.height) } performanceHeight = min(max(418,(performanceDragStart ?? 454)+value.translation.height),min(580,max(418,geometry.size.height-220))) }.onEnded { _ in performanceDragStart = nil }) .accessibilityLabel("Resize DJ performance area") DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity) } } }.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top) .background(DJStyle.background).foregroundStyle(.white) .preferredColorScheme(.dark) .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in Button("Cancel",role:.cancel) { system.cancelReplacement() } Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } .sheet(isPresented:$system.importLogVisible) { if let progress = system.importing ? system.importProgress : system.lastImportReport { ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false },waveformStatus:system.automaticWaveformProgress.map { progress in BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) }) .interactiveDismissDisabled(system.importing) } } .sheet(isPresented:Binding(get:{system.storageProgress != nil && !system.storageInSettings},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) { if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) } } .confirmationDialog("Remove selected tracks from DJ Studio?",isPresented:$confirmTrackRemoval,titleVisibility:.visible) { Button("Remove \(removingIDs.count) Tracks",role:.destructive) { system.removeLibraryTracks(removingIDs) } } message: { Text("Library entries and their queue/playlist links are removed. Media files and referenced originals are kept.") } .sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:system,ids:reinterpretIDs) } .sheet(item:$editingTrack) { DJTrackMetadataDialog(system:system,song:$0) } .sheet(isPresented:$graphicEQVisible) { DJGraphicEQDialog(system:system) } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } .sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) } .sheet(isPresented:Binding(get:{system.waveformJob != nil},set:{if !$0 { system.dismissWaveforms() }})) { if let progress = system.waveformJob { VStack(alignment:.leading,spacing:20) { Label("DJ Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(DJStyle.a) Text(system.generatingWaveforms ? "Preparing saved waveforms in the background." : "Completed waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary) WaveformProgressCard(progress:progress,running:system.generatingWaveforms,studio:"DJ",cancel:{system.cancelWaveforms()},details:{waveformDetails.toggle()},dismiss:{system.dismissWaveforms()}) if waveformDetails || !progress.messages.isEmpty { Text("Analysis Log").font(.headline) ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180) } HStack { Text("Cancelling keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) Spacer() if !system.generatingWaveforms { Button("Done") { system.dismissWaveforms() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) } } }.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark) .interactiveDismissDisabled(system.generatingWaveforms) } } .sheet(isPresented:$queueVisible) { VStack(spacing:16) { DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity) HStack { Spacer();Button("Done") { queueVisible = false }.keyboardShortcut(.defaultAction) } }.padding(24).frame(width:620,height:560).background(DJStyle.background).preferredColorScheme(.dark) } .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() } .onStudioChange(of:selected) { _ in connectMIDI() } .onStudioChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } } .onStudioChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] } .onStudioChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() } .onStudioChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() } .onStudioChange(of:query) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:collection) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() } .onStudioChange(of:sidebarWidth) { _ in libraryRevision = UUID() } .onStudioChange(of:system.favourites) { _ in updateFilteredTracks() } } private func performanceSize(_ available: Double) -> Double { max(0,min(max(418,performanceHeight),min(580,max(0,available-220)))) } private var header: some View { HStack(spacing:14) { ZStack { RoundedRectangle(cornerRadius:10).fill(DJStyle.a.opacity(0.14));Image(systemName:"waveform.path").font(.system(size:20,weight:.semibold)).foregroundStyle(DJStyle.a) }.frame(width:40,height:40) VStack(alignment:.leading,spacing:3) { Text("DJ STUDIO").font(.system(size:15,weight:.bold,design:.rounded)).tracking(2);Text("PERFORMANCE WORKSPACE").font(.system(size:8,weight:.medium)).tracking(1.8).foregroundStyle(.secondary) } Spacer() HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") } Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) Button { system.importLogVisible = true } label: { Image(systemName:"doc.text") }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).disabled(system.importProgress == nil).help("Open the import log and background waveform progress") Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)).help("Open the ten-band graphic equaliser and sound presets for both decks") DJQueueButton(automix:system.automix,open:{queueVisible = true}) if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") } }.controlSize(.small).padding(.horizontal,2).padding(.bottom,2) } private var libraryTitle: String { playlist.flatMap { id in system.playlists.first{$0.id == id}?.name } ?? collection.rawValue } private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 } private var collectionSidebar: some View { ScrollView { VStack(alignment:.leading,spacing:0) { Text("COLLECTION").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary).padding(.horizontal,16).padding(.top,18).padding(.bottom,10) ForEach(DJCollection.allCases) { scope in collectionRow(scope.rawValue,icon:scope.icon,count:collectionCounts[scope] ?? 0,active:playlist == nil && collection == scope) { chooseCollection(scope) } } HStack { Text("PLAYLISTS").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary);Spacer();Button { renaming = false;playlistName = "";playlistEditor = true } label: { Image(systemName:"plus") }.buttonStyle(.plain).help("Create playlist") }.padding(.horizontal,16).padding(.top,24).padding(.bottom,8) VStack(spacing:2) { ForEach(system.playlists) { list in collectionRow(list.name,icon:"music.note",count:nil,active:playlist == list.id) { collection = .all;playlist = list.id;librarySort = .source;ascending = true;refreshPlaylist();page = 0 } .contextMenu { Button("Rename Playlist…") { playlist = list.id;renaming = true;playlistName = list.name;playlistEditor = true } Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:list.id) } } } } if system.playlists.isEmpty { Text("Build your next set with playlists.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,16).padding(.bottom,18) } Spacer(minLength:0) }.padding(.bottom,12) }.frame(minWidth:156,idealWidth:176,maxWidth:210,maxHeight:.infinity).background(DJStyle.background) } private func collectionRow(_ name: String,icon: String,count: Int?,active: Bool,action: @escaping () -> Void) -> some View { Button(action:action) { HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } }.font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7)) }.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "").help("Show \(name)") } private var browser: some View { GeometryReader { geometry in HStack(spacing:0) { if sidebarVisible { collectionSidebar.frame(width:min(max(156,sidebarWidth),210)) Rectangle().fill(Color.white.opacity(0.07)).frame(width:6).contentShape(Rectangle()) .help("Drag to resize collections; double-click to reset") .onTapGesture(count:2) { sidebarWidth = 184 } .gesture(DragGesture(coordinateSpace:.global).onChanged { value in if sidebarDragStart == nil { sidebarDragStart = sidebarWidth } sidebarWidth = min(210,max(156,(sidebarDragStart ?? 184)+value.translation.width)) }.onEnded { _ in sidebarDragStart = nil }) .accessibilityLabel("Resize DJ collection sidebar") } VStack(spacing:0) { HStack(spacing:12) { VStack(alignment:.leading,spacing:3) { Text(libraryTitle).font(.system(size:17,weight:.semibold));Text("\(songs.count.formatted()) tracks").font(.caption).foregroundStyle(.secondary) } Spacer(minLength:8) StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340).help("Filter this collection or playlist by title, artist, album or other track metadata") Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)).help("Import audio files or folders, including subfolders; already imported tracks are skipped") }.padding(14) HStack(spacing:10) { Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155).help("Sort the visible tracks; playlist/import order preserves the original sequence") Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order") LibrarySelectionBadge(count:selection.count) Spacer(minLength:4) Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty).help("Add every selected track to the DJ queue") HStack(spacing:8) { Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0).help("Previous library page") Text("\(min(page,pages-1)+1) / \(pages)").font(.system(size:10).monospacedDigit()).fixedSize() Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1).help("Next library page") }.buttonStyle(.plain) }.controlSize(.small).padding(.horizontal,14).padding(.bottom,10) Divider().overlay(Color.white.opacity(0.05)) ScrollViewReader { reader in Table(visible,selection:$selection) { TableColumn("Fav") { song in Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)").help(system.favourites.contains(song.id) ? "Remove this track from Favourites" : "Add this track to Favourites") }.width(38) TableColumn("Title") { song in DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}) .frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight) }.width(min:150,ideal:260,max:1200) TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:90,ideal:150,max:800) TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:80,ideal:120,max:800) TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100) TableColumn("Rating") { song in GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } })) }.width(min:95,ideal:105,max:140) }.font(.system(size:system.settings.libraryFont)) .contextMenu(forSelectionType:Int64.self) { ids in if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) } else { Button("Select All Songs") { selectAllTracks() } } } .scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8)) .onCommand(#selector(NSResponder.selectAll(_:))) { selectAllTracks() } .background(DJLibraryPageScroll(page:page) { if page < pages-1 { page += 1 } }) .onStudioChange(of:page) { _ in selected = visible.first?.id;selection = Set(visible.first.map{[$0.id]} ?? []) if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } } } } if let song = selectedSong { HStack(spacing:10) { Image(systemName:"music.note").foregroundStyle(DJStyle.a) VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:11,weight:.semibold)).lineLimit(1);Text(song.artist.isEmpty ? "Artist not set" : song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).help("Edit the selected track’s title, artist, album and metadata") Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)).help("Load the selected track into deck A; a playing deck is protected") Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)).help("Load the selected track into deck B; a playing deck is protected") }.padding(10).background(DJStyle.background) } }.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel) } }.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07))) } @ViewBuilder private func playlistMenu(_ song: Song) -> some View { Button("Remove Selected Tracks…",role:.destructive) { removingIDs = Set(contextTracks(song).map(\.id));confirmTrackRemoval = true } Divider() Button("Song Info / Artist…") { editingTrack = song } Button("Reinterpret Selected Names…") { reinterpretIDs = Set(contextTracks(song).map(\.id));reinterpreting = true }.disabled(system.namesBusy) Button("Undo Last Name Reinterpretation") { Task { do { try await system.undoNameChanges() } catch { system.error = error.localizedDescription } } }.disabled(system.namesBusy) Divider() Button("Load into Deck A") { system.load(song,into:"A") } Button("Load into Deck B") { system.load(song,into:"B") } Divider() Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) } Button("Select All Songs") { selectAllTracks() }.keyboardShortcut("a",modifiers:.command) Button("Add \(contextTracks(song).count) Selected Tracks to DJ Queue") { system.automix.enqueue(contextTracks(song)) } Menu("Waveforms") { Button("Generate Waveforms for \(contextTracks(song).count) Selected Tracks") { system.generateWaveforms(Set(contextTracks(song).map(\.id))) } Button("Generate Waveform for This Track") { system.generateWaveforms([song.id]) } Button("Generate Waveforms for Loaded Decks") { system.generateWaveforms(Set([system.a.song?.id,system.b.song?.id].compactMap{$0})) }.disabled(system.a.song == nil && system.b.song == nil) Button("Generate Waveforms for All DJ Library Tracks") { system.generateWaveforms() } Divider() Button("Regenerate Selected Waveforms") { system.generateWaveforms(Set(contextTracks(song).map(\.id)),force:true) } Button("Regenerate This Waveform") { system.generateWaveforms([song.id],force:true) } Button("Regenerate All DJ Library Waveforms") { system.generateWaveforms(force:true) } }.disabled(system.generatingWaveforms || system.importing) 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)} ?? [];updateFilteredTracks() } private func connectMIDI() { system.midi.currentValue = { [weak system] action in guard let system else { return nil } if action == "Crossfader" { return system.crossfader } if action == "Master" { return system.master } if action == "Cue volume" { return system.cueLevel } let parts = action.split(separator:" ",maxSplits:1).map(String.init) guard parts.count == 2 else { return nil };let d = system.channel(parts[0]),p = d.player switch parts[1] { case "Tempo XML": return (p.tempo-1)/(2*system.settings.tempoRange/100)+0.5 case "High": return (p.highGain+12)/24 case "Mid": return (p.midGain+12)/24 case "Low": return (p.lowGain+12)/24 case "Filter": return (p.filter+1)/2 case "Gain": return (d.gain+12)/24 case "Level": return d.level default: return nil } } system.midi.onAction = { [weak system] name,value,relative in guard let system else { return } if name == "Browse" { guard value != 0 else { return } let rows = songs;guard !rows.isEmpty else { return } let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count) let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1))) selected = rows[index].id;selection = [rows[index].id];page = index/50 } else if name == "Preview selected",let selectedSong { system.previewLibrary(selectedSong) } else { system.action(name,value:value,relative:relative,selected:selectedSong) } } } private func importMedia() { let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true if panel.runModal() == .OK { system.importFiles(panel.urls) } } } private struct DJDeckSurfaces { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem private var color: Color { DJStyle.color(deck.letter) } var tempoStrip: some View { VStack(spacing:3) { Button("SYNC") { system.sync(deck) }.foregroundStyle(system.syncActive ? color : Color.secondary).font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(system.syncActive ? color : nil,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Match the other deck to this deck’s BPM; click again to restore both original tempos").accessibilityIdentifier("dj.\(deck.letter).sync") Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo") DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:44,height:74) HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) }.help("Decrease tempo by 0.1 percentage points");Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) }.help("Increase tempo by 0.1 percentage points") }.buttonStyle(.plain).font(.system(size:15,weight:.medium)) }.frame(width:62).disabled(!player.canJog) } var tonePanel: some View { HStack(alignment:.top,spacing:10) { VStack(spacing:8) { DJKnob(label:"HIGH",value:$player.highGain,range:-12...12,reset:0,color:color) DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color) DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color) } VStack(spacing:8) { DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)}) HStack(spacing:3) { VStack(spacing:5) { ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) } }.accessibilityLabel("Deck output meter").help("Live output peak level for this deck") DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:70) } Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary) Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off") } }.frame(width:110) } } struct DJKnob: NSViewRepresentable { let label: String @Binding var value: Double let range: ClosedRange let reset: Double let color: Color var format: (Double) -> String = {String(format:"%+.0f dB",$0)} func makeCoordinator() -> Coordinator { Coordinator(value:$value) } func makeNSView(context: Context) -> DJRotaryControl { let view = DJRotaryControl();view.changed = { [weak coordinator = context.coordinator] value in coordinator?.value.wrappedValue = value };return view } func updateNSView(_ view: DJRotaryControl,context: Context) { context.coordinator.value = $value view.label = label;view.value = value;view.range = range;view.defaultValue = reset;view.colour = NSColor(color);view.isEnabled = context.environment.isEnabled view.setAccessibilityElement(true);view.setAccessibilityRole(.slider);view.setAccessibilityLabel(label);view.setAccessibilityValue(format(value)) view.setAccessibilityCustomActions([NSAccessibilityCustomAction(name:"Reset to default",handler:{[weak view] in view?.resetToDefault();return true})]) view.toolTip = "\(label): \(format(value)). Drag vertically; double-click to reset." view.needsDisplay = true } func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJRotaryControl,context: Context) -> CGSize? { CGSize(width:36,height:40) } final class Coordinator { var value: Binding init(value: Binding) { self.value = value } } } /// Native click counting keeps a double-click reset independent of drag recognition. final class DJRotaryControl: NSControl { var label = "",value: Double = 0,defaultValue: Double = 0 var range: ClosedRange = -12...12 var colour = NSColor.systemTeal var changed: ((Double) -> Void)? private var dragStart: (point: NSPoint,value: Double)? override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } func resetToDefault() { guard isEnabled else { return };dragStart = nil;set(defaultValue) } private func set(_ next: Double) { value = min(range.upperBound,max(range.lowerBound,next));needsDisplay = true;changed?(value) } override func mouseDown(with event: NSEvent) { guard isEnabled else { return } window?.makeFirstResponder(self) if event.clickCount >= 2 { resetToDefault();return } dragStart = (convert(event.locationInWindow,from:nil),value) } override func mouseDragged(with event: NSEvent) { guard isEnabled,let start = dragStart else { return } let point = convert(event.locationInWindow,from:nil) set(start.value-Double(point.y-start.point.y)*(range.upperBound-range.lowerBound)/120) } override func mouseUp(with event: NSEvent) { dragStart = nil } override func keyDown(with event: NSEvent) { if event.keyCode == 126 || event.keyCode == 124 { set(value+(range.upperBound-range.lowerBound)/100) } else if event.keyCode == 125 || event.keyCode == 123 { set(value-(range.upperBound-range.lowerBound)/100) } else if event.keyCode == 49 { resetToDefault() } else { super.keyDown(with:event) } } override func accessibilityPerformIncrement() -> Bool { guard isEnabled else { return false };set(value+(range.upperBound-range.lowerBound)/100);return true } override func accessibilityPerformDecrement() -> Bool { guard isEnabled else { return false };set(value-(range.upperBound-range.lowerBound)/100);return true } override func draw(_ dirtyRect: NSRect) { let centre = NSPoint(x:bounds.midX,y:14.5) let circle = NSBezierPath(ovalIn:NSRect(x:centre.x-14.5,y:0,width:29,height:29)) NSColor.black.withAlphaComponent(0.35).setFill();circle.fill() NSColor.white.withAlphaComponent(0.12).setStroke();circle.lineWidth = 2;circle.stroke() let arc = NSBezierPath();arc.appendArc(withCenter:centre,radius:14,startAngle:135,endAngle:405,clockwise:false) colour.withAlphaComponent(isEnabled ? 0.4 : 0.15).setStroke();arc.lineWidth = 2;arc.stroke() let fraction = min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) let angle = (-135+fraction*270) * .pi/180 let marker = NSBezierPath();marker.move(to:NSPoint(x:centre.x+sin(angle)*5,y:centre.y-cos(angle)*5));marker.line(to:NSPoint(x:centre.x+sin(angle)*11,y:centre.y-cos(angle)*11));marker.lineWidth = 2;marker.lineCapStyle = .round colour.withAlphaComponent(isEnabled ? 1 : 0.3).setStroke();marker.stroke() let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center (label as NSString).draw(in:NSRect(x:0,y:30,width:bounds.width,height:10),withAttributes:[.font:NSFont.systemFont(ofSize:8,weight:.medium),.foregroundColor:NSColor.secondaryLabelColor,.paragraphStyle:paragraph]) } } private struct DJPlatter: NSViewRepresentable { let player: Player let letter: String let color: Color var markerEnabled = true func makeNSView(context: Context) -> DJPlatterView { DJPlatterView() } func updateNSView(_ view: DJPlatterView,context: Context) { view.player = player;view.colour = NSColor(color);view.marker = markerEnabled;view.needsDisplay = true view.setAccessibilityElement(true);view.setAccessibilityLabel("Deck \(letter) jog platter") view.toolTip = "Drag around the platter to scrub (2 seconds per turn)" } static func dismantleNSView(_ view: DJPlatterView,coordinator: ()) { view.stopClock() } } private final class DJPlatterView: NSView { weak var player: Player? var colour = NSColor.systemCyan var marker = true private var clock: AnyObject? private var timer: Timer? private var angle: Double? override var isFlipped: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } func stopClock() { timer?.invalidate();timer = nil;if #available(macOS 14,* ) { (clock as? CADisplayLink)?.invalidate() };clock = nil } override func viewDidMoveToWindow() { super.viewDidMoveToWindow();stopClock();guard window != nil else { return } if #available(macOS 14,*) { let link = displayLink(target:self,selector:#selector(frameTick));link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:60,preferred:60);link.add(to:.main,forMode:.common);clock = link } else { let tick = Timer(timeInterval:1/60,repeats:true) { [weak self] _ in self?.frameTick() };RunLoop.main.add(tick,forMode:.common);timer = tick } } @objc private func frameTick() { if player?.playing == true,!isHiddenOrHasHiddenAncestor { needsDisplay = true } } override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } let side = min(bounds.width,bounds.height),centre = CGPoint(x:bounds.midX,y:bounds.midY) let disc = CGRect(x:centre.x-side/2+2,y:centre.y-side/2+2,width:side-4,height:side-4) context.setFillColor(NSColor.black.withAlphaComponent(0.55).cgColor);context.fillEllipse(in:disc) context.setStrokeColor(colour.withAlphaComponent(0.4).cgColor);context.setLineWidth(3);context.strokeEllipse(in:disc) context.setStrokeColor(NSColor.white.withAlphaComponent(0.06).cgColor);context.setLineWidth(1) for scale in [0.70,0.80,0.90] { let radius = side*scale/2;context.strokeEllipse(in:CGRect(x:centre.x-radius,y:centre.y-radius,width:radius*2,height:radius*2)) } guard marker else { return } context.translateBy(x:centre.x,y:centre.y);context.rotate(by:(player?.displayTime ?? 0)*200 * .pi/180) context.setFillColor(colour.cgColor);context.fill(CGRect(x:-2.5,y:-side*0.37-13,width:5,height:26)) context.setFillColor(NSColor.white.cgColor);context.fillEllipse(in:CGRect(x:-2.5,y:-side*0.45-2.5,width:5,height:5)) } override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow,from:nil);angle = atan2(p.y-bounds.midY,p.x-bounds.midX) } override func mouseDragged(with event: NSEvent) { let p = convert(event.locationInWindow,from:nil),next = atan2(p.y-bounds.midY,p.x-bounds.midX) if let angle,let player { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.scrub(to:player.time+delta / .pi) } angle = next;needsDisplay = true } override func mouseUp(with event: NSEvent) { angle = nil;player?.endScrubbing() } deinit { timer?.invalidate();if #available(macOS 14,*) { (clock as? CADisplayLink)?.invalidate() } } } struct DJWaveform: View { @State private var frozenStart: Double? @ObservedObject var player: Player let color: Color var bpm: Double? var body: some View { let displayTime = player.displayTime GeometryReader { geometry in Canvas { context,size in let values = player.waveform guard !values.isEmpty else { return } let count = max(1,Int(size.width/2)) var played = Path(),remaining = Path() for i in 0.. Coordinator { Coordinator(value:$value) } func makeNSView(context: Context) -> NSSlider { let slider = NSSlider(value:value,minValue:0,maxValue:1,target:context.coordinator,action:#selector(Coordinator.changed(_:))) slider.cell = DJHardwareFaderCell() slider.target = context.coordinator;slider.action = #selector(Coordinator.changed(_:)) slider.frame = NSRect(x:0,y:0,width:vertical ? 52 : 240,height:vertical ? 94 : 36) slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." slider.setContentHuggingPriority(.defaultLow,for:.horizontal) return slider } func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." } func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) } final class Coordinator: NSObject { var value: Binding init(value: Binding) { self.value = value } @objc func changed(_ sender: NSSlider) { value.wrappedValue = sender.doubleValue } } } private final class DJHardwareFaderCell: NSSliderCell { override func drawBar(inside rect: NSRect,flipped: Bool) { let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0) NSColor(calibratedWhite:0.36,alpha:1).setStroke() for i in 0...8 { let mark = NSBezierPath() if vertical { let y = rect.minY+rect.height*Double(i)/8;mark.move(to:NSPoint(x:rect.midX-21,y:y));mark.line(to:NSPoint(x:rect.midX+21,y:y)) } else { let x = rect.minX+rect.width*Double(i)/8;mark.move(to:NSPoint(x:x,y:rect.midY-13));mark.line(to:NSPoint(x:x,y:rect.midY+13)) } mark.lineWidth = 1;mark.stroke() } let groove = vertical ? NSRect(x:rect.midX-2,y:rect.minY,width:4,height:rect.height) : NSRect(x:rect.minX,y:rect.midY-2,width:rect.width,height:4) NSColor(calibratedWhite:0.025,alpha:1).setFill();NSBezierPath(roundedRect:groove,xRadius:2,yRadius:2).fill() } override func drawTickMarks() {} override func drawKnob(_ rect: NSRect) { let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0) let cap = vertical ? NSRect(x:rect.midX-21,y:rect.midY-6,width:42,height:12) : NSRect(x:rect.midX-6,y:rect.midY-14,width:12,height:28) let path = NSBezierPath(roundedRect:cap,xRadius:4,yRadius:4) NSGradient(starting:NSColor(calibratedWhite:0.60,alpha:1),ending:NSColor(calibratedWhite:0.30,alpha:1))?.draw(in:path,angle:90) NSColor(calibratedWhite:0.75,alpha:0.6).setStroke() let marker = NSBezierPath();if vertical { marker.move(to:NSPoint(x:cap.minX+6,y:cap.midY));marker.line(to:NSPoint(x:cap.maxX-6,y:cap.midY)) } else { marker.move(to:NSPoint(x:cap.midX,y:cap.minY+5));marker.line(to:NSPoint(x:cap.midX,y:cap.maxY-5)) };marker.lineWidth = 1;marker.stroke() } } struct DJContinuousConsole: View { @ObservedObject var system: DJSystem let loadA: () -> Void let loadB: () -> Void var body: some View { VStack(spacing:8) { HStack(spacing:10) { DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10)) DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10)) }.frame(height:82) VStack(spacing:2) { DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) } DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) } }.frame(minHeight:64,maxHeight:.infinity).background(.black).clipShape(RoundedRectangle(cornerRadius:8)).clipped() HStack(alignment:.top,spacing:10) { performance(system.a) mixer.frame(width:292) performance(system.b) }.frame(height:252) }.accessibilityIdentifier("dj.performance.workspace") } private func performance(_ deck: DJChannel) -> some View { VStack(spacing:6) { HStack { Text("DECK \(deck.letter)").foregroundStyle(DJStyle.color(deck.letter));Spacer();Text("PERFORMANCE").foregroundStyle(.secondary) }.font(.system(size:9,weight:.bold)).tracking(1.2) DJDeckDropZone(system:system,letter:deck.letter) { DJConsoleWing(deck:deck,player:deck.player,system:system) } Divider().overlay(.white.opacity(0.04)) DJConsoleTransport(deck:deck,player:deck.player,system:system) }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top) .background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12)) .overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(DJStyle.color(deck.letter)).frame(height:2).padding(.horizontal,14) } } private var mixer: some View { VStack(spacing:8) { HStack { Text("CHANNEL MIXER");Spacer();Text(system.routing.mode.uppercased()).foregroundStyle(.secondary) }.font(.system(size:8,weight:.bold)).tracking(1) HStack(alignment:.top,spacing:12) { DJMixerChannel(deck:system.a,player:system.a.player,system:system) Divider().frame(height:152) DJMixerChannel(deck:system.b,player:system.b.player,system:system) }.font(.system(size:9,weight:.bold)) HStack(spacing:8) { Text("A").foregroundStyle(DJStyle.a) DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:30).disabled(system.routing.mode == "External") Text("B").foregroundStyle(DJStyle.b) Button { system.crossfader = 0.5 } label: { Image(systemName:"scope") }.buttonStyle(.plain).help("Centre crossfader") }.font(.system(size:10,weight:.semibold)) }.padding(12).frame(maxHeight:.infinity,alignment:.top).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12)) } } private struct DJMixerChannel: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player let system: DJSystem var body: some View { VStack(spacing:4) { Text(deck.letter).foregroundStyle(DJStyle.color(deck.letter)) DJDeckSurfaces(deck:deck,player:player,system:system).tonePanel } } } private struct DJTrackHeader: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem let load: () -> Void private var color: Color { DJStyle.color(deck.letter) } var body: some View { HStack(spacing:10) { if deck.letter == "A" { artwork } VStack(alignment:.leading,spacing:5) { HStack { VStack(alignment:.leading,spacing:2) { Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1) HStack(spacing:6) { Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1) TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM").help("Set the track’s original BPM to enable beat sync and beat loops") Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary) Button("Tap") { deck.tap() }.controlSize(.mini).help("Tap along with the beat to set this track’s BPM") }.font(.system(size:10)).disabled(!player.canJog) } Spacer(minLength:4) Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()).help("Track time remaining") Button("Load",action:load).controlSize(.mini).help("Load the selected DJ library track into deck \(deck.letter)") } DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22).help("Track overview: click or drag to seek to a position in the song") } if deck.letter == "B" { artwork } }.padding(10).frame(maxWidth:.infinity) } private var artwork: some View { ZStack { RoundedRectangle(cornerRadius:4).fill(color.opacity(0.12)) if let image = deck.artwork { Image(nsImage:image).resizable().scaledToFill() } else { Image(systemName:"music.note").font(.system(size:26)).foregroundStyle(color) } }.frame(width:62,height:62).clipped().clipShape(RoundedRectangle(cornerRadius:4)) } } private struct DJConsoleWing: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem var body: some View { let face = DJDeckSurfaces(deck:deck,player:player,system:system) HStack(spacing:18) { face.tempoStrip Spacer(minLength:0) ZStack { DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:112,height:112) VStack(spacing:3) { Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:19,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter)) Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) }.frame(width:76,height:76).background(DJStyle.background,in:Circle()).allowsHitTesting(false) } Spacer(minLength:0) }.frame(maxWidth:.infinity).frame(height:130).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1) } } private struct DJConsoleTransport: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem private var playButton: some View { Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) } .foregroundStyle(player.playing ? Color.green : Color.white).help("Play or pause deck \(deck.letter)").accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play") } private var cueButton: some View { Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22) .help("Pause and return to the main cue at \(DJStyle.stamp(player.cuePosition)). Right-click to set or clear it.") .contextMenu { Button("Set Cue Here") { player.setCue() };Button("Clear Main Cue") { player.clearCue() } } } private var loopButtons: some View { HStack(spacing:0) { Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }.help("Halve the loop length; updates an active loop at its next boundary") Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle a \(deck.loopBeats)-beat loop; requires confirmed BPM") Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }.help("Double the loop length; updates an active loop at its next boundary") } } private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) .help("Toggle deck \(deck.letter) headphone preview using the configured cue output") } private var clearMarkers: some View { Menu { Menu("Clear Individual Hot Cue") { ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) } } Button("Clear All Hot Cues") { player.clearHotCues() }.disabled(player.hotCues.allSatisfy{$0 == nil}) Button("Clear Main Cue") { player.clearCue() }.disabled(player.cuePosition == 0) Button("Clear Loop Markers") { player.clearLoop() }.disabled(player.loopIn == nil && player.loopOut == nil) Divider() Button("Clear All Cue & Loop Markers") { player.clearDeckMarkers() } } label: { Image(systemName:"eraser") } .help("Clear individual hot cues, all hot cues, the main cue, loop markers or all markers without unloading the track") .accessibilityLabel("Deck \(deck.letter) clear markers") } var body: some View { VStack(spacing:6) { HStack(spacing:8) { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }.controlSize(.regular) HStack(spacing:4) { ForEach(0..<4) { index in Button("\(index+1)") { if NSEvent.modifierFlags.contains(.option) { player.hotCue(index,clear:true) } else { system.hotCue(index,deck:deck) } }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)) .help(player.hotCues[index].map{"Hot cue \(index+1) at \(DJStyle.stamp($0)). Click to jump; Option-click or right-click to clear."} ?? "Click to set hot cue \(index+1) at the current position. Option-click or right-click clears it.") .contextMenu { Button("Clear Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) } } Menu { Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) } Button("Set Cue Here") { player.setCue() } Button("Clear Main Cue") { player.clearCue() } Button("Edit Loop…") { system.loopEditor = deck.letter } } label: { Image(systemName:"flag") }.help("Set, edit or clear the main cue; open the loop start/end editor") Button("IN") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop start at the current position. Right-click to clear the loop markers.") .contextMenu { Button("Clear Loop Markers") { player.clearLoop() } } Button("OUT") { player.markLoopOut() }.foregroundStyle(player.loopOut == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop end and enable the loop. Set IN first; right-click to clear both markers.") .contextMenu { Button("Clear Loop Markers") { player.clearLoop() } } clearMarkers Menu { Button("Stop Deck") { player.stopDeck() } Button("Exit Loop / Clear Loop Markers") { player.clearLoop() } Button("Nudge Back") { player.jog(-0.05) } Button("Nudge Forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") }.help("Stop the deck, exit its loop or nudge playback by 0.05 seconds") Spacer(minLength:0) }.controlSize(.mini) }.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog) } } private struct DJCentreWaveform: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player let letter: String let color: Color var settings = DJPreferences() var body: some View { DJScrollingSpectrum(player:player,bpm:deck.bpmConfirmed ? deck.bpm : nil,color:color,seconds:settings.waveformSeconds,label:letter,accessibilityName:"Deck \(letter) scrolling waveform",showTimeGrid:settings.showTimeGrid,spectralWaveforms:settings.spectralWaveforms) } } // The same audio-clock drawing and native mouse interaction serve both studios. struct DJScrollingSpectrum: View { @ObservedObject var player: Player var bpm: Double? var color: Color = .cyan var seconds: Double = 16 var label = "KARAOKE" var accessibilityName = "Karaoke scrolling waveform" var showTimeGrid = true var spectralWaveforms = true var body: some View { DJNativeSpectrum(player:player,bpm:bpm,color:NSColor(color),seconds:seconds,label:label,showTimeGrid:showTimeGrid,spectralWaveforms:spectralWaveforms) .clipped() .accessibilityLabel(accessibilityName).accessibilityValue(DJStyle.stamp(player.time)) .help("Hold the left button and drag left to advance or right to rewind. Shift-drag creates a loop; drag green loop edges to resize or its centre to move it.") } } struct DJPreferencesPane: View { @ObservedObject var system: DJSystem @State private var confirmClear = false let category: String var body: some View { Group { if category == "Devices" { DJAudioDevicesPane(system:system).padding(26) } else { ScrollView { VStack(alignment:.leading,spacing:26) { switch category { case "General": general case "Sound": sound case "Library": library case "Appearance": appearance case "MIDI": midi default: advanced } }.padding(26).frame(maxWidth:.infinity,alignment:.leading) } } } .sheet(isPresented:Binding(get:{system.storageInSettings && (system.storageProgress != nil || system.cleanupPlan != nil)},set:{if !$0,!system.movingStorage { system.storageProgress = nil;system.cleanupPlan = nil }})) { if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:system.storageAction == nil,activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) } else if let plan = system.cleanupPlan { DJCleanupConfirmation(system:system,plan:plan) } } .confirmationDialog("Clear the entire DJ library?",isPresented:$confirmClear,titleVisibility:.visible) { Button("Remove All \(system.songs.count) Tracks",role:.destructive) { system.removeLibraryTracks(Set(system.songs.map(\.id)),fromSettings:true) } } message: { Text("All DJ track entries and queue entries are removed. Playlists remain empty. Audio files, saved waveforms and original source folders are kept; Clean Unused Files can remove unreferenced managed copies afterwards.") } .alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } } private var general: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Song Loading") { VStack(alignment:.leading,spacing:14) { Toggle("Start playback when loading a track",isOn:$system.settings.autoPlayOnLoad) Toggle("Reset EQ and filter on song load",isOn:$system.settings.resetEQOnLoad) Toggle("Protect active deck",isOn:$system.settings.protectPlayingDeck) Text("Protect active deck requires pausing a playing deck before replacing its track.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Sync Mode") { VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Deck A master");Text("Both decks follow deck A’s effective BPM while Sync is active. A second click restores their previous tempos. BPM can be estimated from audio, entered or tapped; estimates may need correction. Beat-grid phase alignment is not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Tempo") { HStack(spacing:24) { Picker("Slider range",selection:$system.settings.tempoRange) { Text("±8%").tag(8.0);Text("±16%").tag(16.0);Text("±50%").tag(50.0) }.frame(width:250);Toggle("Invert slider",isOn:$system.settings.invertTempo) }.padding(12) } } } private var sound: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Mixer") { VStack(alignment:.leading,spacing:16) { Picker("Crossfader curve",selection:$system.settings.crossfaderCurve) { Text("Equal power").tag("Equal power");Text("Linear").tag("Linear") }.frame(width:380) LabeledContent("EQ type",value:"Three-band shelf / parametric EQ") LabeledContent("Filter",value:"Low-pass / high-pass · centre bypass") }.padding(12) } PreferencesSection("Audio Output") { VStack(alignment:.leading,spacing:12) { Picker("Output headroom",selection:$system.settings.headroomDB) { Text("None").tag(0.0);Text("−3 dB").tag(3.0);Text("−6 dB").tag(6.0);Text("−9 dB").tag(9.0) }.frame(width:350) Text("Headroom reduces both DJ decks before the master mix. An audio limiter and automatic gain analysis are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } } } private var library: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Library Maintenance") { VStack(alignment:.leading,spacing:14) { HStack { Button("Clear DJ Library…",role:.destructive) { confirmClear = true }.disabled(system.songs.isEmpty);Button("Clean Unused Files…") { system.findUnusedFiles() } } Text("Clear removes library entries while keeping your files. Cleanup previews unused DJ-managed copies and stale waveforms before moving them to Trash. Referenced original files are excluded.").font(.caption).foregroundStyle(.secondary) }.padding(12).disabled(system.movingStorage || system.importing || system.generatingWaveforms || system.namesBusy) } PreferencesSection("Import Mode") { VStack(alignment:.leading,spacing:12) { Picker("Media files",selection:$system.copyImportedMedia) { Text("Reference files in their original location").tag(false);Text("Copy files into DJ storage").tag(true) }.pickerStyle(.radioGroup) Text("Referencing avoids duplicate media. Keep the source drive connected. Saved waveforms stay in your selected waveform folder. Audio inside ZIP archives is extracted into DJ storage.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Media and Waveform Storage") { VStack(alignment:.leading,spacing:14) { LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) } LabeledContent("Waveforms") { Text(system.waveformDirectory.path).textSelection(.enabled) } Button("Move Media and Waveforms…") { system.chooseStorage() }.disabled(system.movingStorage || system.importing || system.generatingWaveforms) Text("Choose a folder on an external drive. Files are copied and verified before originals are removed. The DJ database and playlists stay on this Mac. Stop playback before moving files.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Independent DJ Collection") { VStack(alignment:.leading,spacing:14) { LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)") Text(system.root.path).font(.callout).textSelection(.enabled) Button("Show DJ library in Finder") { NSWorkspace.shared.open(system.root) } Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. BPM is estimated from audio on load; key analysis, Spotify playback and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Library Display") { VStack(spacing:14) { HStack { Text("Font size").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryFont,in:10...16,step:1);Text("\(Int(system.settings.libraryFont)) pt").frame(width:45) };HStack { Text("Row height").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryRowHeight,in:24...44,step:2);Text("\(Int(system.settings.libraryRowHeight)) pt").frame(width:45) } }.padding(12) } } } private var appearance: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Jog Wheels") { Toggle("Attach rotating tape marker",isOn:$system.settings.platterMarker).padding(12) } PreferencesSection("Waveforms") { VStack(alignment:.leading,spacing:14) { Toggle("Use frequency colours in scrolling waveforms",isOn:$system.settings.spectralWaveforms) Toggle("Show time grid",isOn:$system.settings.showTimeGrid) Toggle("Dim inactive deck",isOn:$system.settings.dimInactiveDeck) Picker("Visible window",selection:$system.settings.waveformSeconds) { ForEach([4.0,8,16,32],id:\.self) { Text("\(Int($0)) seconds").tag($0) } }.frame(width:340) }.padding(12) } } } private var midi: some View { VStack(alignment:.leading,spacing:20) { PreferencesSection("MIDI Controller") { Text("Select a source and learn controls using the MIDI button above the DJ console. Mappings save per controller. Standard note buttons and 7-bit CC controls are supported; jog encoders can use relative modes.").padding(12) } DJMappingSheet(midi:system.midi,embedded:true) } } private var advanced: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Cue Points & Loops") { VStack(alignment:.leading,spacing:14) { Toggle("Start playback when triggering a hot cue",isOn:$system.settings.autoPlayHotCue) Text("Hot cues and loop points belong to the loaded track session. Manual IN/OUT loops and BPM-based beat loops are supported. Beat-grid quantization and saving cue/loop data per song are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Playback") { Text("Platter dragging performs seek-based jogging. DVS, touch scratching, recording, Neural Mix and cloud synchronization are not implemented.").font(.callout).foregroundStyle(.secondary).padding(12) } } } } private struct DJConsoleButtonStyle: ButtonStyle { let accent: Color? let active: Bool @Environment(\.controlSize) private var size @Environment(\.isEnabled) private var enabled init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active } func makeBody(configuration: Configuration) -> some View { configuration.label.padding(.horizontal,size == .mini ? 5 : 7).padding(.vertical,size == .mini ? 3 : 6) .background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5)) .overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2)) .opacity(enabled ? 1 : 0.35) } } struct DJReplacement: Identifiable { let id = UUID() let deck: String let song: Song? let urls: [URL] var title: String { song?.title ?? urls.first?.lastPathComponent ?? "track" } } @MainActor extension DJSystem { func acceptDrop(_ providers: [NSItemProvider],into letter: String) -> Bool { let files = providers.filter{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)} if !files.isEmpty { Task { @MainActor in do { let urls = try await DJFinderDrop.urls(files);requestFileLoad(urls,into:letter) } catch { self.error = error.localizedDescription } } return true } guard let provider = providers.first(where:{$0.canLoadObject(ofClass:NSString.self)}) else { return false } Task { @MainActor in do { let reference = try await SongDragReference.load(provider) guard reference.library == dragSession,let song = songs.first(where:{$0.id == reference.song}) else { throw LibraryError.message("Drag from the DJ library or drop an audio file from Finder.") } load(song,into:letter) } catch { self.error = error.localizedDescription } } return true } func requestFileLoad(_ urls: [URL],into letter: String,override: Bool = false) { guard !urls.isEmpty else { return } if channel(letter).player.playing,!override { pendingReplacement = .init(deck:letter,song:nil,urls:urls);return } if importing { queuedFileLoads.append((urls,letter,override,channel(letter).song?.id)) status = "Queued file drop for deck \(letter) · waiting for the current import";return } importFiles(urls,loadInto:letter,overridePlaying:override) } func confirmReplacement(_ captured: DJReplacement? = nil) { guard let request = captured ?? pendingReplacement else { return };pendingReplacement = nil if let song = request.song { load(song,into:request.deck,overridePlaying:true);continueFileLoads() } else { requestFileLoad(request.urls,into:request.deck,override:true) } } } /// Conservative onset-envelope estimate; silence and steady tones return no BPM. enum DJBPM { static func estimate(_ envelope: [Double],rate: Double = 200) -> Double? { guard envelope.count >= Int(rate*4),rate > 0 else { return nil } let average = envelope.reduce(0,+)/Double(envelope.count) guard average > 0.00001 else { return nil } let onset = envelope.indices.map { i in i == 0 ? 0 : max(0,envelope[i]-envelope[i-1]) } let energy = onset.reduce(0){$0+$1*$1} guard sqrt(energy/Double(onset.count)) > average*0.035 else { return nil } var bestScore = 0.0,bestLag = 0 let lower = Int(rate*60/180),upper = Int(rate*60/70) for lag in lower...upper { var product = 0.0,first = 0.0,second = 0.0 for i in lag.. bestScore { bestScore = score;bestLag = lag } } guard bestScore > 0.12,bestLag > 0 else { return nil } return (60*rate/Double(bestLag)*10).rounded()/10 } static func analyse(_ url: URL,cancelled: () -> Bool = { false }) throws -> Double? { try WaveformWorkload.shared.acquire(cancelled:cancelled) defer { WaveformWorkload.shared.release() } let file = try AVAudioFile(forReading:url),format = file.processingFormat guard let buffer = AVAudioPCMBuffer(pcmFormat:format,frameCapacity:16384),format.channelCount > 0 else { return nil } let hop = max(1,Int(format.sampleRate/200)),limit = Int64(format.sampleRate*90) var envelope = [Double](),sum = 0.0,count = 0 while file.framePosition < min(file.length,limit) { if cancelled() { throw CancellationError() } if WaveformWorkload.shared.playbackActive { Thread.sleep(forTimeInterval:0.001) } try file.read(into:buffer) guard buffer.frameLength > 0,let channels = buffer.floatChannelData else { break } for i in 0.. NSImage? { let asset = AVURLAsset(url:URL(fileURLWithPath:song.audio)) if let items = try? await asset.load(.commonMetadata),let item = items.first(where:{$0.commonKey == .commonKeyArtwork}),let data = try? await item.load(.dataValue),data.count <= 20_000_000,let image = NSImage(data:data) { return image } return sidecar(song).flatMap{NSImage(contentsOf:$0)} } static func sidecar(_ song: Song) -> URL? { let sources = [song.originalPath,song.audio].filter{!$0.isEmpty}.map{URL(fileURLWithPath:$0)} for source in sources { let folder = source.deletingLastPathComponent(),stem = source.deletingPathExtension().lastPathComponent.lowercased() guard let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.fileSizeKey,.isRegularFileKey],options:[.skipsHiddenFiles]) else { continue } let candidates = files.filter { url in guard ["jpg","jpeg","png"].contains(url.pathExtension.lowercased()),let values = try? url.resourceValues(forKeys:[.fileSizeKey,.isRegularFileKey]) else { return false } return values.isRegularFile == true && (values.fileSize ?? Int.max) <= 20_000_000 } func priority(_ url: URL) -> Int { let name = url.deletingPathExtension().lastPathComponent.lowercased();return name == stem ? 0 : ["cover","folder","front","artwork"].contains(name) ? 1 : 2 } if let first = candidates.sorted(by:{priority($0) == priority($1) ? $0.lastPathComponent < $1.lastPathComponent : priority($0) < priority($1)}).first { return first } } return nil } } private struct DJCueEditor: View { @ObservedObject var system: DJSystem var body: some View { let deck = system.channel(system.cueEditor ?? "A") VStack(alignment:.leading,spacing:18) { Text("Deck \(deck.letter) · Cue Point").font(.title2.bold()) Text(deck.song?.title ?? "No track").foregroundStyle(.secondary) HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) } Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position") Button("Use Current Playhead") { system.cueDraft = deck.player.time }.help("Use the current playback position as the main cue") Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary) HStack { Button("Cancel") { system.cueEditing = false }.help("Close without changing the cue");Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction).help("Save the main cue without moving or pausing playback") } }.padding(24).frame(width:470) } } /// Protocol facts adapted from josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx. /// XML is authoritative for the right Mode button (95 00); the JS table has a conflicting entry. struct MixtrackGoProtocol { static var shutdownBytes: [UInt8] { var bytes: [UInt8] = [0x9E,0x7F,0] for i in 0..<2 { for note in [0,1,2,3,4,5,0x46,0x48,0x54] { bytes += [UInt8(0x90+i),UInt8(note),0] } bytes += [UInt8(0x80+i),0x1B,0,0x9F,UInt8(2+i),0,0x9F,UInt8(0x48+i),0] for note in Array(1...4)+Array(0x14...0x17)+Array(0x1C...0x1F) { bytes += [UInt8(0x94+i),UInt8(note),0] } } return bytes+[0x9F,0x46,0,0x9F,0x47,0] } struct Event { var action: String;var value: Double = 1;var relative = false } var modes = [0,0] var lowEQ = false private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false] static func mappings(source: Int32) -> [DJMIDIMapping] { var result = [DJMIDIMapping]() func add(_ status: UInt8,_ number: UInt8,_ action: String,_ mode: String = "Absolute") { result.append(.init(source:source,status:status,number:number,action:action,mode:mode)) } add(0xBF,0x0A,"Master");add(0xBF,0x0C,"Cue volume");add(0xBF,0x08,"Crossfader") add(0xBF,0x00,"Browse","Relative signed");add(0x9F,0x07,"Preview selected") for i in 0..<2 { let d = i == 0 ? "A" : "B",note = UInt8(0x90+i),cc = UInt8(0xB0+i) add(0x9F,UInt8(0x02+i),d+" Load") add(cc,0x16,d+" Level");add(cc,0x06,d+" Jog ticks","Relative signed") add(note,0x1B,d+" Headphones");add(note,0x00,d+" Play");add(note,0x01,d+" Cue button");add(note,0x02,d+" Sync") add(note,0x03,d+" Sync");add(note,0x04,d+" Cue play");add(note,0x05,d+" Cue play") } return result } mutating func consume(_ message: DJMIDIMessage) -> [Event]? { let status = message.status,number = message.data1,value = message.data2 if status == 0x9F,number == 0x48 { lowEQ.toggle();return [.init(action:"Notice: Knobs now control "+(lowEQ ? "Low EQ" : "Filter"))] } if [UInt8(0xB0),0xB1].contains(status) { let index = Int(status & 1),deck = index == 0 ? "A" : "B" if number == 0x09 || number == 0x29 { if number == 0x09 { msb[index] = Int(value);gotMSB[index] = true } else { lsb[index] = Int(value);gotLSB[index] = true } guard gotMSB[index],gotLSB[index] else { return [] } let raw = (msb[index] << 7) | lsb[index],signed = max(-1,min(1,Double(raw-8192)/8191)) return [.init(action:deck+" Tempo",value:0.5+signed/2)] } if number == 0x1A { return [.init(action:deck+(lowEQ ? " Low" : " Filter"),value:Double(value)/127)] } } if [UInt8(0x94),0x95].contains(status) { let index = Int(status & 1),deck = index == 0 ? "A" : "B" if number == 0 { modes[index] = (modes[index]+1)%5;return [.init(action:"Notice: "+deck+" pad mode · "+["Hot cues","Loops","FX (unsupported)","Sampler (unsupported)","Stems (unsupported)"][modes[index]])] } let shifted = (0x1C...0x1F).contains(number),first: UInt8 = shifted ? 0x1C : 0x14 guard (first...(first+3)).contains(number) else { return nil } let pad = Int(number-first) if modes[index] == 0 { return [.init(action:deck+(shifted ? " Clear cue " : " Hot cue ")+String(pad+1))] } if modes[index] == 1 { return [.init(action:deck+(shifted ? " Loop off" : " Loop "+String(1 << pad)))] } return [.init(action:"Notice: This pad mode requires an FX, sampler or stems engine that is not implemented.")] } if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] } return nil } func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) -> [UInt8] { var bytes = [UInt8]() for i in 0..<2 { let status = UInt8(0x90+i),pad = UInt8(0x94+i),loaded = i == 0 ? aLoaded : bLoaded,playing = i == 0 ? aPlaying : bPlaying bytes += [status,0x00,playing ? 127 : (loaded ? 1 : 0),status,0x01,loaded ? 1 : 0,status,0x02,sync ? 127 : 0] bytes += [UInt8(0x80+i),0x1B,cue == (i == 0 ? "A" : "B") ? 127 : 1] bytes += [status,0x04,playing ? 127 : 1,status,0x05,loaded ? 127 : 1,status,0x03,sync ? 127 : 1,0x9F,UInt8(0x02+i),loaded ? 127 : 1] for index in 0..<4 { let cues = i == 0 ? aCues : bCues,loop = i == 0 ? aLoop : bLoop let on = modes[i] == 0 ? (cues.indices.contains(index) && cues[index]) : modes[i] == 1 && loop bytes += [pad,UInt8(0x14+index),on ? 127 : 1] } for mode in 0..<4 { let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3 bytes += [pad,UInt8(mode+1),on ? 127 : 1] } } return bytes } } enum DJFinderDrop { static func urls(_ providers: [NSItemProvider]) async throws -> [URL] { var urls = [URL]() for provider in providers { let data: Data? = await withCheckedContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in continuation.resume(returning:data) } } if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { urls.append(url);continue } let url: URL = try await withCheckedThrowingContinuation { continuation in provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) } else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) } else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) } } } urls.append(url) } return urls } } // BPM-relative guides start at source time zero; these are not analysed downbeats. enum DJWaveMarkers { static func beats(start: Double,span: Double,bpm: Double?,width: Double) -> [Int] { guard let bpm,bpm.isFinite,bpm > 0,start.isFinite,span.isFinite,span > 0,width > 0 else { return [] } let interval = 60/bpm,first = max(0,Int(ceil(start/interval))),last = Int(floor((start+span)/interval)) guard last >= first else { return [] } let strideBy = max(1,Int(ceil(span/interval/max(1,width/5)))) return Array(stride(from:first,through:last,by:strideBy)) } static func draw(context: inout GraphicsContext,size: CGSize,start: Double,span: Double,bpm: Double?,loop: ClosedRange?) { guard span > 0 else { return } if let bpm { for beat in beats(start:start,span:span,bpm:bpm,width:size.width) { let x = (Double(beat)*60/bpm-start)/span*size.width context.fill(Path(CGRect(x:x,y:0,width:beat % 4 == 0 ? 1.2 : 0.6,height:size.height)),with:.color(beat % 4 == 0 ? .yellow.opacity(0.85) : .white.opacity(0.4))) if beat % 4 == 0,size.height >= 45 { context.draw(Text("\(beat/4+1)").font(.system(size:9,weight:.semibold)).foregroundColor(.yellow),at:CGPoint(x:x+5,y:9),anchor:.leading) } } } if let loop { let left = max(0,(loop.lowerBound-start)/span*size.width),right = min(size.width,(loop.upperBound-start)/span*size.width) if right > left { let rect = CGRect(x:left,y:0,width:right-left,height:size.height) context.fill(Path(rect),with:.color(.green.opacity(0.22))) context.stroke(Path(rect.insetBy(dx:0.8,dy:0.8)),with:.color(.green),lineWidth:1.5) } for (time,label) in [(loop.lowerBound,"IN"),(loop.upperBound,"OUT")] { let x = (time-start)/span*size.width if x >= 0,x <= size.width { context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green)) context.fill(Path(CGRect(x:x-3,y:size.height/2-7,width:7,height:14)),with:.color(.green)) context.fill(Path(CGRect(x:x,y:size.height/2-5,width:1,height:10)),with:.color(.black)) context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7)) } } } } } private struct DJGraphicEQDialog: View { @ObservedObject var system: DJSystem @Environment(\.dismiss) private var dismiss private let labels = ["31","62","125","250","500","1k","2k","4k","8k","16k"] var body: some View { VStack(alignment:.leading,spacing:22) { Label("DJ Graphic Equaliser",systemImage:"slider.vertical.3").font(.title2.bold()).foregroundStyle(DJStyle.a) Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary) HStack { Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch).help("Enable or bypass the shared ten-band equaliser") Spacer() Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) { ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) } if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") } }.frame(width:230) Button("Reset") { system.chooseGraphicPreset("Flat") }.help("Return every graphic EQ band to 0 dB") } HStack(alignment:.top,spacing:14) { ForEach(0..<10,id:\.self) { index in VStack(spacing:9) { Text(String(format:"%+.0f",system.graphicEQ.gains[index])).font(.caption.monospacedDigit()).foregroundStyle(DJStyle.a) DJHardwareFader(value:Binding(get:{(system.graphicEQ.gains[index]+12)/24},set:{ value in var next = system.graphicEQ;next.gains[index] = value*24-12;next.preset = "Custom";system.graphicEQ = next }),label:"Graphic EQ \(labels[index]) hertz",vertical:true).frame(width:40,height:150) Text(labels[index]).font(.caption) } } }.padding(18).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12)) HStack { Text("±12 dB · Changes apply immediately · Saved automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } }.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark) } } private struct DJTrackMetadataDialog: View { @ObservedObject var system: DJSystem let song: Song @State private var title = "" @State private var artist = "" @State private var album = "" @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment:.leading,spacing:18) { Text("DJ Song Info").font(.title2.bold()) TextField("Title",text:$title).textFieldStyle(.roundedBorder) TextField("Artist",text:$artist).textFieldStyle(.roundedBorder) TextField("Album",text:$album).textFieldStyle(.roundedBorder) Text("Changes update your DJ library; the original file is kept.").font(.caption).foregroundStyle(.secondary) HStack { Spacer();Button("Cancel") { dismiss() };Button("Save") { system.perform { try system.library?.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:song.label,rating:song.rating) try system.refresh() if let updated = system.songs.first(where:{$0.id == song.id}) { for deck in [system.a,system.b] where deck.song?.id == song.id { deck.updateTrackMetadata(updated) } } dismiss() } }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } }.padding(26).frame(width:480).onAppear { title = song.title;artist = song.artist;album = song.album } } } private struct DJCleanupConfirmation: View { @ObservedObject var system: DJSystem let plan: DJCleanupPlan var body: some View { VStack(alignment:.leading,spacing:18) { Label("Clean DJ Library Storage",systemImage:"trash").font(.title2.bold()) Text("\(plan.files.count) unused files · \(ByteCountFormatter.string(fromByteCount:plan.bytes,countStyle:.file))").font(.headline) Text("Only unreferenced DJ-managed media and stale waveform caches are listed. Original source folders and files still referenced by the library are excluded.").font(.callout).foregroundStyle(.secondary) ScrollView { LazyVStack(alignment:.leading,spacing:6) { ForEach(Array(plan.files.prefix(100))) { Text($0.url.path).font(.caption.monospaced()).textSelection(.enabled) } } }.frame(height:210) if plan.files.count > 100 { Text("Showing the first 100 files.").font(.caption).foregroundStyle(.secondary) } HStack { Button("Cancel") { system.cleanupPlan = nil };Spacer();Button("Move Unused Files to Trash",role:.destructive) { system.cleanUnusedFiles(plan) }.buttonStyle(.borderedProminent).disabled(plan.files.isEmpty) } }.padding(26).frame(width:680).preferredColorScheme(.dark) } }