broken changes

This commit is contained in:
Mark Gregory
2026-11-05 22:31:53 +00:00
parent df8cb6b17d
commit d71c823ee6
3 changed files with 75 additions and 22 deletions
+42 -13
View File
@@ -45,21 +45,12 @@ struct DJLoopWaveInteraction: ViewModifier {
let span: Double
let width: Double
@Binding var frozenStart: Double?
@State private var drag: DJLoopDrag?
func body(content: Content) -> some View {
content.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance:0).onChanged { value in
guard player.canJog,span > 0 else { return }
if drag == nil {
let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil
drag = DJLoopDrag(x:value.startLocation.x,viewport:start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift))
frozenStart = start
}
guard let drag else { return }
if let range = drag.range(x:value.location.x,duration:player.duration) { player.defineLoop(start:range.lowerBound,end:range.upperBound) }
else if drag.mode == .seek { player.seek(drag.viewport+value.location.x*drag.secondsPerPixel) }
}.onEnded { _ in drag = nil;frozenStart = nil })
.onChange(of:player.currentSongID) { _ in drag = nil;frozenStart = nil }
.overlay {
DJWaveMouseSurface(player:player,start:start,span:span,width:width,frozenStart:$frozenStart)
}
.onChange(of:player.currentSongID) { _ in frozenStart = nil }
.onContinuousHover { phase in
switch phase {
case .active(let point):
@@ -78,6 +69,44 @@ struct DJLoopWaveInteraction: ViewModifier {
}
}
// Native tracking survives the nested hosting views used by the deck drop zones.
struct DJWaveMouseSurface: NSViewRepresentable {
let player: Player
let start: Double
let span: Double
let width: Double
@Binding var frozenStart: Double?
func makeNSView(context: Context) -> DJWaveMouseView { DJWaveMouseView() }
func updateNSView(_ view: DJWaveMouseView,context: Context) {
view.player = player;view.viewport = start;view.span = span;view.waveWidth = width
view.freeze = { frozenStart = $0 }
}
}
final class DJWaveMouseView: NSView {
weak var player: Player?
var viewport = 0.0,span = 0.0,waveWidth = 1.0
var freeze: ((Double?) -> Void)?
private var drag: DJLoopDrag?
override var isFlipped: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let player,player.canJog,span > 0 else { return }
let point = convert(event.locationInWindow,from:nil)
let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil
drag = DJLoopDrag(x:point.x,viewport:viewport,span:span,width:waveWidth,loop:loop,create:event.modifierFlags.contains(.shift))
freeze?(viewport);updateDrag(point.x)
}
override func mouseDragged(with event: NSEvent) { updateDrag(convert(event.locationInWindow,from:nil).x) }
override func mouseUp(with event: NSEvent) {
updateDrag(convert(event.locationInWindow,from:nil).x);drag = nil;freeze?(nil)
}
private func updateDrag(_ x: Double) {
guard let player,let drag else { return }
if let range = drag.range(x:x,duration:player.duration) { player.defineLoop(start:range.lowerBound,end:range.upperBound) }
else if drag.mode == .seek { player.seek(drag.viewport+x*drag.secondsPerPixel) }
}
}
struct DJLoopEditor: View {
@ObservedObject var player: Player
let letter: String
+24 -9
View File
@@ -918,6 +918,8 @@ private struct DJPlatter: View {
var markerEnabled = true
@State private var lastAngle: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
ZStack {
Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) }
@@ -929,7 +931,7 @@ private struct DJPlatter: View {
Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37)
Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45)
}
}.rotationEffect(.degrees(player.time*200))
}.rotationEffect(.degrees(displayTime*200))
}.contentShape(Circle())
.gesture(DragGesture(minimumDistance:2).onChanged { drag in
let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2))
@@ -937,6 +939,7 @@ private struct DJPlatter: View {
lastAngle = angle
}.onEnded{_ in lastAngle = nil})
}.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.")
}
}
}
private struct DJWaveform: View {
@@ -945,6 +948,8 @@ private struct DJWaveform: View {
let color: Color
var bpm: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
@@ -952,16 +957,17 @@ private struct DJWaveform: View {
let count = max(1,Int(size.width/2))
for i in 0..<count {
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < player.time/max(0.001,player.duration) ? 0.4 : 0.9)))
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < displayTime/max(0.001,player.duration) ? 0.4 : 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 = player.time/max(0.001,player.duration)*size.width
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 {
@@ -1224,10 +1230,12 @@ private struct DJCentreWaveform: View {
var settings = DJPreferences()
@State private var frozenStart: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
Canvas { context,size in
guard !player.waveform.isEmpty,player.duration > 0 else { return }
let span = settings.waveformSeconds,start = frozenStart ?? (player.time-span/2),count = max(1,Int(size.width/2))
let span = settings.waveformSeconds,start = frozenStart ?? (displayTime-span/2),count = max(1,Int(size.width/2))
for second in 0...8 {
let x = Double(second)/8*size.width
context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0)))
@@ -1235,8 +1243,10 @@ private struct DJCentreWaveform: View {
for i in 0..<count {
let seconds = start+Double(i)/Double(count)*span
guard seconds >= 0,seconds < player.duration else { continue }
let index = min(player.waveform.count-1,Int(seconds/player.duration*Double(player.waveform.count)))
let height = max(1,Double(player.waveform[index])*size.height*0.85)
let sample = min(Double(player.waveform.count-1),seconds/player.duration*Double(player.waveform.count))
let index = Int(sample),next = min(player.waveform.count-1,index+1),fraction = sample-Double(index)
let amplitude = Double(player.waveform[index])*(1-fraction)+Double(player.waveform[next])*fraction
let height = max(1,amplitude*size.height*0.85)
var shade = color
if settings.spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
@@ -1245,10 +1255,12 @@ private struct DJCentreWaveform: View {
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade))
}
DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red))
let playhead = (displayTime-start)/span*size.width
context.fill(Path(CGRect(x:playhead,y:0,width:1.5,height:size.height)),with:.color(.red))
}.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) }
.modifier(DJLoopWaveInteraction(player:player,start:player.time-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart))
.modifier(DJLoopWaveInteraction(player:player,start:displayTime-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time))
}
}
}
@@ -1607,7 +1619,10 @@ enum DJWaveMarkers {
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(.white.opacity(beat % 4 == 0 ? 0.5 : 0.22)))
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 {
+9
View File
@@ -23,6 +23,15 @@ import CoreAudio
@Published var loopEnabled = false
var isShowScreen: Bool { slideImage != nil }
var canJog: Bool { currentSongID != nil && duration > 0 }
/// Read the audio clock for display frames without publishing the entire player at 60 Hz.
var displayTime: Double {
guard playing,node.isPlaying,let file,let render = node.lastRenderTime,
let current = node.playerTime(forNodeTime:render) else { return time }
let now = mach_absolute_time()
let elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime)) : 0
let position = anchor+Double(current.sampleTime)/file.processingFormat.sampleRate+elapsed*tempo
return min(duration,max(0,position))
}
func stopDeck() {
guard canJog else { return }
pauseDeck();loopEnabled = false;seek(0)