2413 lines
166 KiB
Swift
2413 lines
166 KiB
Swift
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<Void,Never>?
|
||
private var analysisTask: Task<Double?,Never>?
|
||
@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<AudioBufferList>.size else { return 0 }
|
||
let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout<AudioBufferList>.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<AudioDeviceID>.size)
|
||
let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) }
|
||
guard result == noErr else { return [] }
|
||
return ids.compactMap { id in
|
||
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<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.size)
|
||
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
|
||
return text?.takeRetainedValue() as String?
|
||
}
|
||
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels,inputChannels:inputs)
|
||
}
|
||
}
|
||
static func defaultOutput() -> AudioDeviceID? {
|
||
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
|
||
var id = AudioDeviceID(0),size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||
return AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,&id) == noErr && id != 0 ? id : nil
|
||
}
|
||
}
|
||
|
||
@MainActor final class DJSystem: ObservableObject {
|
||
@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<Int64>,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<Void,Never>?
|
||
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<Void,Never>?
|
||
@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<Int64>) {
|
||
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<Void,Never>?
|
||
func generateWaveforms(_ ids: Set<Int64>? = 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<Int64>()
|
||
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<AnyCancellable>()
|
||
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<Void,Never>?
|
||
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<String>()
|
||
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<Void,Never>?
|
||
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<MIDIPacketList>.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self)
|
||
for _ in 0..<list.pointee.numPackets {
|
||
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.offset(of: \.data)!)
|
||
bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)))
|
||
packet = UnsafePointer(MIDIPacketNext(packet))
|
||
}
|
||
let received = bytes
|
||
Task { @MainActor in self?.receive(received) }
|
||
}
|
||
if input != noErr { status = "MIDI input unavailable (\(input))" }
|
||
refreshSources()
|
||
}
|
||
func refreshSources() {
|
||
sources = (0..<MIDIGetNumberOfSources()).map { index in
|
||
let endpoint = MIDIGetSource(index);var uid: Int32 = 0;var name: Unmanaged<CFString>?
|
||
MIDIObjectGetIntegerProperty(endpoint,kMIDIPropertyUniqueID,&uid)
|
||
MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&name)
|
||
return Source(id:uid,endpoint:endpoint,name:name?.takeRetainedValue() as String? ?? "MIDI source")
|
||
}
|
||
if connected != 0,!sources.contains(where:{$0.endpoint == connected}) {
|
||
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..<MIDIGetNumberOfDestinations() {
|
||
let endpoint = MIDIGetDestination(i);var result: Unmanaged<CFString>?
|
||
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<MIDIPacketList>.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<Content: View>: View, Equatable {
|
||
let revision: UUID
|
||
let selection: Set<Int64>
|
||
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<Int64>) -> 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<Int64>,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<Int64>()
|
||
@State private var confirmTrackRemoval = false
|
||
@State private var reinterpreting = false
|
||
@State private var reinterpretIDs = Set<Int64>()
|
||
@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<Int64>()
|
||
@State private var selected: Int64?
|
||
@State private var playlistEditor = false
|
||
@State private var playlistName = ""
|
||
@State private var renaming = false
|
||
@State private var page = 0
|
||
@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<Double>
|
||
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<Double>
|
||
init(value: Binding<Double>) { 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<Double> = -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..<count {
|
||
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
|
||
let rect = CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)
|
||
if Double(i)/Double(count) < displayTime/max(0.001,player.duration) { played.addRect(rect) } else { remaining.addRect(rect) }
|
||
}
|
||
context.fill(played,with:.color(color.opacity(0.4)))
|
||
context.fill(remaining,with:.color(color.opacity(0.9)))
|
||
DJWaveMarkers.draw(context:&context,size:size,start:0,span:player.duration,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
|
||
|
||
let x = displayTime/max(0.001,player.duration)*size.width
|
||
context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white))
|
||
}.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3))
|
||
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
|
||
.modifier(DJLoopWaveInteraction(player:player,start:0,span:player.duration,width:geometry.size.width,frozenStart:$frozenStart))
|
||
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
|
||
}
|
||
}
|
||
private struct DJMappingSheet: View {
|
||
@ObservedObject var midi: DJMIDI
|
||
var embedded = false
|
||
@State private var action = "A Play"
|
||
@State private var xmlSelection = ""
|
||
@State private var xmlSearch = ""
|
||
@State private var report: DJXMLProfile?
|
||
private var selectedXML: DJXMLProfile? { midi.xmlCatalog.first { $0.id == xmlSelection } }
|
||
private var filteredXML: [DJXMLProfile] { midi.xmlCatalog.filter { xmlSearch.isEmpty || $0.name.localizedCaseInsensitiveContains(xmlSearch) || $0.id == xmlSelection } }
|
||
@Environment(\.dismiss) private var dismiss
|
||
private let commands = ["Play","Stop","Cue","Set cue","Sync","Loop","Headphones","Load","Gain","High","Mid","Low","Filter","Level","Tempo","Jog","Hot cue 1","Hot cue 2","Hot cue 3","Hot cue 4"]
|
||
private var actions: [String] { ["Crossfader","Master"]+["A","B"].flatMap{deck in commands.map{deck+" "+$0}} }
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } }
|
||
Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } }
|
||
HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green) } }
|
||
Toggle("Automatically detect and reconnect supported controllers",isOn:$midi.autoDetect)
|
||
VStack(alignment:.leading,spacing:8) {
|
||
HStack { Text("XML Controller Profiles").font(.headline);Spacer();Button("Import XML…") { midi.importXMLFiles() } }
|
||
TextField("Find a controller",text:$xmlSearch).textFieldStyle(.roundedBorder)
|
||
Picker("Profile",selection:$xmlSelection) {
|
||
Text("Choose a profile").tag("")
|
||
ForEach(filteredXML) { Text($0.name).tag($0.id) }
|
||
}
|
||
HStack {
|
||
Button("Apply XML Profile") { if let selectedXML { midi.applyXML(selectedXML) } }.disabled(midi.sourceID == 0 || selectedXML?.bindings.isEmpty != false)
|
||
Button("Compatibility Report") { report = selectedXML }.disabled(selectedXML == nil)
|
||
Button("Use Custom Learn") { midi.useCustomProfile() }
|
||
}
|
||
if let active = midi.activeXML { Text("Active: \(active.name) · \(active.bindings.count) supported controls").font(.caption).foregroundStyle(.green) }
|
||
}
|
||
Text("Mixtrack Go preset uses the supplied Mixxx protocol mapping, including 14-bit tempo and hot-cue/loop pad modes. Other controllers can use MIDI Learn.").font(.caption).foregroundStyle(.secondary)
|
||
HStack {
|
||
Picker("Action",selection:$action) { ForEach(actions,id:\.self) { Text($0).tag($0) } }
|
||
Picker("Mode",selection:$midi.learnMode) { Text("Absolute").tag("Absolute");Text("Relative signed").tag("Relative signed");Text("Relative offset").tag("Relative offset") }.frame(width:160)
|
||
}
|
||
HStack {
|
||
Button(midi.learning == nil ? "Learn Next Control" : "Cancel Learning") { if midi.learning == nil { midi.learning = action } else { midi.learning = nil } }.disabled(midi.sourceID == 0)
|
||
if let learning = midi.learning { Text("Move or press the control for \(learning)…").foregroundStyle(.orange) }
|
||
}
|
||
Text(midi.status).font(.caption)
|
||
List {
|
||
if let active = midi.activeXML {
|
||
ForEach(Array(active.bindings.enumerated()),id:\.offset) { _,binding in
|
||
HStack { Text(binding.action);Spacer();Text("Ch \((binding.status & 15)+1) · \(binding.number) · \(binding.mode)").foregroundStyle(.secondary) }
|
||
}
|
||
} else {
|
||
ForEach(midi.mappings.filter{$0.source == midi.sourceID}) { map in
|
||
HStack {
|
||
Text(map.action).frame(width:110,alignment:.leading)
|
||
Text("\(map.status & 0xF0 == 0xB0 ? "CC" : "Note") \(map.number) · ch \((map.status & 0x0F)+1)").foregroundStyle(.secondary)
|
||
Spacer();Text(map.mode).font(.caption).foregroundStyle(.secondary)
|
||
Button { midi.remove(map.id) } label: { Image(systemName:"trash") }.buttonStyle(.borderless).accessibilityLabel("Remove mapping for \(map.action)")
|
||
}
|
||
}
|
||
}
|
||
}.frame(height:200)
|
||
Text("Mappings save per MIDI source ID. Custom learn supports note buttons and 7-bit CCs; the Mixtrack Go preset also handles its 14-bit tempo. Use relative mode only for Jog encoders. Load uses the selected DJ library row. Button releases are ignored. The Mixtrack Go preset sends transport/sync/mode LED feedback when a matching output is found. Stems, sampler, FX modes and vinyl scratching are not implemented.").font(.caption).foregroundStyle(.secondary)
|
||
if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } }
|
||
}.padding(24).frame(width:660)
|
||
.onAppear { if let active = midi.activeXML { xmlSelection = active.id } }
|
||
.sheet(item:$report) { profile in
|
||
VStack(alignment:.leading,spacing:16) {
|
||
Text("Mapping Compatibility").font(.title2.bold())
|
||
ScrollView { Text(profile.report).font(.system(size:12)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }
|
||
HStack { Spacer();Button("Done") { report = nil }.keyboardShortcut(.defaultAction) }
|
||
}.padding(24).frame(width:700,height:540)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Native mouse/keyboard/accessibility behaviour with a rectangular mixer-fader cap.
|
||
struct DJHardwareFader: NSViewRepresentable {
|
||
@Binding var value: Double
|
||
let label: String
|
||
var vertical = false
|
||
func makeCoordinator() -> 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<Double>
|
||
init(value: Binding<Double>) { 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..<onset.count { product += onset[i]*onset[i-lag];first += onset[i]*onset[i];second += onset[i-lag]*onset[i-lag] }
|
||
let score = product/max(0.00000001,sqrt(first*second))
|
||
if score > 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..<Int(buffer.frameLength) {
|
||
var sample = 0.0
|
||
for c in 0..<Int(format.channelCount) { let value = Double(channels[c][i*Int(buffer.stride)]);sample += value*value }
|
||
sum += sample/Double(format.channelCount);count += 1
|
||
if count == hop { envelope.append(sqrt(sum/Double(count)));count = 0;sum = 0 }
|
||
}
|
||
}
|
||
return estimate(envelope,rate:format.sampleRate/Double(hop))
|
||
}
|
||
}
|
||
|
||
enum DJTrackInfo {
|
||
static func artwork(_ song: Song) async -> 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<Double>?) {
|
||
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)
|
||
}
|
||
}
|