broken changes
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
|
||||
enum DJPasteboardPayload {
|
||||
static func song(_ pasteboard: NSPasteboard) -> SongDragReference? {
|
||||
guard let text = pasteboard.string(forType:.string),text.hasPrefix("KJMix.song.v1:"),
|
||||
let data = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { return nil }
|
||||
return try? JSONDecoder().decode(SongDragReference.self,from:data)
|
||||
}
|
||||
static func files(_ pasteboard: NSPasteboard) -> [URL] {
|
||||
(pasteboard.readObjects(forClasses:[NSURL.self],options:[.urlReadingFileURLsOnly:true]) as? [URL] ?? []).filter(\.isFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
struct DJLibraryDragTitle: NSViewRepresentable {
|
||||
let title: String
|
||||
let reference: SongDragReference
|
||||
let fontSize: Double
|
||||
let select: () -> Void
|
||||
func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() }
|
||||
func updateNSView(_ view: DJLibraryDragView,context: Context) {
|
||||
view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select
|
||||
view.toolTip = "Drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true
|
||||
}
|
||||
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) }
|
||||
}
|
||||
|
||||
final class DJLibraryDragView: NSView {
|
||||
var title = ""
|
||||
var reference: SongDragReference?
|
||||
var fontSize: Double = 11
|
||||
var select: (() -> Void)?
|
||||
private var armed = false
|
||||
private var dragging = false
|
||||
override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) }
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail
|
||||
let font = NSFont.systemFont(ofSize:fontSize,weight:.medium)
|
||||
let rect = NSRect(x:0,y:(bounds.height-ceil(font.ascender-font.descender))/2,width:bounds.width,height:ceil(font.ascender-font.descender)+2)
|
||||
(title as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:NSColor.white,.paragraphStyle:paragraph])
|
||||
}
|
||||
override func mouseDown(with event: NSEvent) { armed = true;dragging = false }
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard armed else { return }
|
||||
dragging = true
|
||||
NSCursor.closedHand.set()
|
||||
for target in DJNativeDropView.targets.allObjects where target.window === window {
|
||||
let inside = !target.isHiddenOrHasHiddenAncestor && target.bounds.contains(target.convert(event.locationInWindow,from:nil))
|
||||
target.layer?.borderWidth = inside ? 2 : 0
|
||||
target.layer?.borderColor = target.tint.cgColor
|
||||
}
|
||||
}
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
defer {
|
||||
armed = false;dragging = false;NSCursor.arrow.set()
|
||||
for target in DJNativeDropView.targets.allObjects { target.layer?.borderWidth = 0 }
|
||||
}
|
||||
guard dragging,let reference,let window else { select?();return }
|
||||
for target in DJNativeDropView.targets.allObjects where target.window === window && !target.isHiddenOrHasHiddenAncestor {
|
||||
if target.bounds.contains(target.convert(event.locationInWindow,from:nil)),target.validSong?(reference) == true {
|
||||
let receive = target.acceptSong
|
||||
DispatchQueue.main.async { receive?(reference) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DJDeckDropZone<Content: View>: NSViewRepresentable {
|
||||
let system: DJSystem
|
||||
let letter: String
|
||||
let content: Content
|
||||
init(system: DJSystem,letter: String,@ViewBuilder content: () -> Content) { self.system = system;self.letter = letter;self.content = content() }
|
||||
func makeNSView(context: Context) -> DJNativeDropView {
|
||||
let view = DJNativeDropView();updateNSView(view,context:context);return view
|
||||
}
|
||||
func updateNSView(_ view: DJNativeDropView,context: Context) {
|
||||
view.host.rootView = AnyView(content.environment(\.colorScheme,context.environment.colorScheme))
|
||||
view.tint = letter == "A" ? .systemCyan : .systemOrange
|
||||
view.acceptSong = { [weak system] reference in
|
||||
guard let system,reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return }
|
||||
if letter == "Queue" { system.automix.enqueue([song]) } else { system.load(song,into:letter) }
|
||||
}
|
||||
view.acceptFiles = letter == "Queue" ? nil : { [weak system] urls in system?.requestFileLoad(urls,into:letter) }
|
||||
view.validSong = { [weak system] reference in reference.library == system?.dragSession }
|
||||
}
|
||||
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJNativeDropView,context: Context) -> CGSize? {
|
||||
.init(width:proposal.width ?? nsView.host.fittingSize.width,height:proposal.height ?? nsView.host.fittingSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
final class DJNativeDropView: NSView {
|
||||
static let targets = NSHashTable<DJNativeDropView>.weakObjects()
|
||||
let host = DJDropHostingView(rootView:AnyView(EmptyView()))
|
||||
var tint = NSColor.systemCyan
|
||||
var acceptSong: ((SongDragReference) -> Void)?
|
||||
var acceptFiles: (([URL]) -> Void)?
|
||||
var validSong: ((SongDragReference) -> Bool)?
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame:frame);Self.targets.add(self);registerForDraggedTypes([.string,.fileURL]);wantsLayer = true
|
||||
host.dropTarget = self;host.registerForDraggedTypes([.string,.fileURL])
|
||||
host.translatesAutoresizingMaskIntoConstraints = false;addSubview(host)
|
||||
NSLayoutConstraint.activate([host.leadingAnchor.constraint(equalTo:leadingAnchor),host.trailingAnchor.constraint(equalTo:trailingAnchor),host.topAnchor.constraint(equalTo:topAnchor),host.bottomAnchor.constraint(equalTo:bottomAnchor)])
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
private func accepts(_ pasteboard: NSPasteboard) -> Bool {
|
||||
if let song = DJPasteboardPayload.song(pasteboard) { return validSong?(song) == true }
|
||||
return !DJPasteboardPayload.files(pasteboard).isEmpty
|
||||
}
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
let allowed = accepts(sender.draggingPasteboard)
|
||||
layer?.borderWidth = allowed ? 2 : 0;layer?.borderColor = tint.cgColor
|
||||
return allowed ? .copy : []
|
||||
}
|
||||
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { accepts(sender.draggingPasteboard) ? .copy : [] }
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { accepts(sender.draggingPasteboard) }
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
layer?.borderWidth = 0
|
||||
if let song = DJPasteboardPayload.song(sender.draggingPasteboard),validSong?(song) == true {
|
||||
let receive = acceptSong
|
||||
// Finish the native dragging session before a load rebuilds any SwiftUI rows.
|
||||
DispatchQueue.main.async { receive?(song) };return true
|
||||
}
|
||||
let files = DJPasteboardPayload.files(sender.draggingPasteboard)
|
||||
guard !files.isEmpty else { return false }
|
||||
let receive = acceptFiles;DispatchQueue.main.async { receive?(files) };return true
|
||||
}
|
||||
override func concludeDragOperation(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
|
||||
}
|
||||
|
||||
// NSHostingView participates in AppKit drag destination lookup itself. Forward
|
||||
// its callbacks so embedded SwiftUI content cannot shadow the deck destination.
|
||||
final class DJDropHostingView: NSHostingView<AnyView> {
|
||||
weak var dropTarget: DJNativeDropView?
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingEntered(sender) ?? [] }
|
||||
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingUpdated(sender) ?? [] }
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) { dropTarget?.draggingExited(sender) }
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.prepareForDragOperation(sender) ?? false }
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.performDragOperation(sender) ?? false }
|
||||
override func concludeDragOperation(_ sender: NSDraggingInfo?) { dropTarget?.concludeDragOperation(sender) }
|
||||
}
|
||||
@@ -158,6 +158,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@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
|
||||
@@ -335,11 +336,12 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
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 target = a.bpm*a.player.tempo,rate = target/b.bpm
|
||||
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)
|
||||
syncActive = true;updatingSync = true;b.player.tempo = rate;updatingSync = false
|
||||
status = String(format:"SYNC ON · Both decks %.1f BPM · A is master",target)
|
||||
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
|
||||
@@ -348,9 +350,17 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
private func maintainSync() {
|
||||
guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return }
|
||||
let rate = a.bpm*a.player.tempo/b.bpm
|
||||
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(b.player.tempo-rate) > 0.00001 { updatingSync = true;b.player.tempo = rate;updatingSync = false }
|
||||
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 }
|
||||
@@ -389,7 +399,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor)))
|
||||
case "Tempo XML":
|
||||
let rate = 1+(value*2-1)*settings.tempoRange/100
|
||||
if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate }
|
||||
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" }
|
||||
@@ -405,7 +415,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
case "Low": p.lowGain = value*24-12
|
||||
case "Filter": p.filter = value*2-1
|
||||
case "Level": d.level = value
|
||||
case "Tempo": if syncActive,d.letter == "B" { a.player.tempo = b.bpm*settings.tempo(at:value)/a.bpm;maintainSync() } else { p.tempo = settings.tempo(at: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)
|
||||
@@ -712,7 +722,7 @@ struct DJConsole: View {
|
||||
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
|
||||
HStack(alignment:.top,spacing:12) {
|
||||
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
|
||||
DJQueuePane(system:system,automix:system.automix).frame(width:310).frame(maxHeight:.infinity)
|
||||
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(width:310).frame(maxHeight:.infinity)
|
||||
}.frame(maxWidth:.infinity,maxHeight:.infinity)
|
||||
if let progress = system.importProgress {
|
||||
HStack {
|
||||
@@ -772,14 +782,13 @@ struct DJConsole: View {
|
||||
ForEach(visible) { song in
|
||||
HStack(spacing:8) {
|
||||
Image(systemName:"music.note").foregroundStyle(.secondary)
|
||||
Text(song.title).font(.system(size:system.settings.libraryFont,weight:.medium)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading)
|
||||
DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selected = song.id}).frame(maxWidth:.infinity,alignment:.leading)
|
||||
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
|
||||
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
|
||||
Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue")
|
||||
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
|
||||
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
|
||||
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
|
||||
.onDrag { system.dragReference(song.id).provider() }
|
||||
.contextMenu { playlistMenu(song) }
|
||||
}
|
||||
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
|
||||
@@ -852,7 +861,7 @@ private struct DJDeckSurfaces {
|
||||
private var color: Color { DJStyle.color(deck.letter) }
|
||||
var tempoStrip: some View {
|
||||
VStack(spacing:5) {
|
||||
Button("SYNC") { system.sync(deck) }.font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(color,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Toggle both decks to deck A’s BPM; click again to restore their original tempos").accessibilityIdentifier("dj.\(deck.letter).sync")
|
||||
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")
|
||||
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit())
|
||||
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:52,height:94)
|
||||
@@ -1084,20 +1093,20 @@ struct DJContinuousConsole: View {
|
||||
var body: some View {
|
||||
VStack(spacing:0) {
|
||||
HStack(spacing:0) {
|
||||
DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA)
|
||||
DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }
|
||||
Divider()
|
||||
DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB)
|
||||
DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }
|
||||
}.frame(height:92).background(Color.black.opacity(0.4))
|
||||
Divider()
|
||||
HStack(spacing:0) {
|
||||
DJConsoleWing(deck:system.a,player:system.a.player,system:system)
|
||||
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:322)
|
||||
Divider()
|
||||
VStack(spacing:1) {
|
||||
DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"A") }
|
||||
DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"B") }
|
||||
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(maxWidth:.infinity).background(.black)
|
||||
Divider()
|
||||
DJConsoleWing(deck:system.b,player:system.b.player,system:system)
|
||||
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:322)
|
||||
}.frame(height:198).background(Color(white:0.12))
|
||||
Divider()
|
||||
HStack(spacing:12) {
|
||||
@@ -1139,7 +1148,7 @@ private struct DJTrackHeader: View {
|
||||
}
|
||||
if deck.letter == "B" { artwork }
|
||||
}.padding(10).frame(maxWidth:.infinity)
|
||||
.contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
|
||||
|
||||
}
|
||||
private var artwork: some View {
|
||||
ZStack {
|
||||
@@ -1171,7 +1180,7 @@ private struct DJConsoleWing: View {
|
||||
if deck.letter == "A" { face.tonePanel }
|
||||
if deck.letter == "B" { face.tempoStrip }
|
||||
}.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
|
||||
.contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
|
||||
|
||||
}
|
||||
}
|
||||
private struct DJConsoleTransport: View {
|
||||
|
||||
Reference in New Issue
Block a user