import SwiftUI import AppKit struct DJLoopDrag { enum Mode { case seek, start, end, move, create } let mode: Mode let viewport: Double let secondsPerPixel: Double let anchor: Double let original: ClosedRange? init(x: Double,viewport: Double,span: Double,width: Double,loop: ClosedRange?,create: Bool) { self.viewport = viewport;secondsPerPixel = span/max(1,width);anchor = viewport+x*secondsPerPixel;original = loop if create { mode = .create } else if let loop { let left = abs(anchor-loop.lowerBound),right = abs(anchor-loop.upperBound),tolerance = min(8*secondsPerPixel,(loop.upperBound-loop.lowerBound)/4) if min(left,right) <= tolerance { mode = left <= right ? .start : .end } else if loop.contains(anchor) { mode = .move } else { mode = .seek } } else { mode = .seek } } func range(x: Double,duration: Double) -> ClosedRange? { guard duration >= 0.1,secondsPerPixel > 0 else { return nil } let point = min(duration,max(0,viewport+x*secondsPerPixel)) switch mode { case .seek: return nil case .create: let first = min(duration,max(0,anchor)),start = min(duration-0.1,min(first,point)) return start...min(duration,max(start+0.1,max(first,point))) case .start: guard let original else { return nil };return min(point,original.upperBound-0.1)...original.upperBound case .end: guard let original else { return nil };return original.lowerBound...max(original.lowerBound+0.1,point) case .move: guard let original else { return nil } let length = original.upperBound-original.lowerBound let start = min(duration-length,max(0,original.lowerBound+(viewport+x*secondsPerPixel-anchor))) return start...(start+length) } } } struct DJLoopWaveInteraction: ViewModifier { @ObservedObject var player: Player let start: Double let span: Double let width: Double var relativeSeek = false @Binding var frozenStart: Double? func body(content: Content) -> some View { content.contentShape(Rectangle()) .overlay { DJWaveMouseSurface(player:player,start:start,span:span,width:width,relativeSeek:relativeSeek,frozenStart:$frozenStart) } .onChange(of:player.currentSongID) { _ in frozenStart = nil } .onContinuousHover { phase in switch phase { case .active(let point): let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil let mode = DJLoopDrag(x:point.x,viewport:frozenStart ?? start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift)).mode switch mode { case .start,.end: NSCursor.resizeLeftRight.set() case .move: NSCursor.openHand.set() case .create: NSCursor.crosshair.set() case .seek: (relativeSeek ? NSCursor.openHand : NSCursor.arrow).set() } case .ended: NSCursor.arrow.set() } } .help("Drag green IN/OUT edges to resize; drag inside the loop to move it. Shift-drag to define a new loop. Drag outside it to seek. In the large spectrum, hold the left button and drag left to advance or right to rewind.") } } // 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 var relativeSeek = false @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.relativeSeek = relativeSeek view.freeze = { frozenStart = $0 } } } class DJWaveMouseView: NSView { weak var player: Player? var viewport = 0.0,span = 0.0,waveWidth = 1.0 var freeze: ((Double?) -> Void)? var relativeSeek = false private var drag: DJLoopDrag? private var initialTime = 0.0,initialX = 0.0 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)) initialTime = player.displayTime;initialX = point.x freeze?(viewport) if !relativeSeek || drag?.mode != .seek { 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);NSCursor.openHand.set() } 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 { if relativeSeek { let target = min(player.duration,max(0,initialTime-(x-initialX)*drag.secondsPerPixel)) if abs(player.time-target) > 0.0001 { player.seek(target) } freeze?(drag.viewport+target-initialTime) NSCursor.closedHand.set() } else { player.seek(drag.viewport+x*drag.secondsPerPixel) } } } } struct DJLoopEditor: View { @ObservedObject var player: Player let letter: String @State private var start: Double @State private var end: Double @Environment(\.dismiss) private var dismiss init(player: Player,letter: String) { self.player = player;self.letter = letter let first = player.loopIn ?? min(max(0,player.time),max(0,player.duration-0.1)) _start = State(initialValue:first);_end = State(initialValue:player.loopOut ?? min(player.duration,first+4)) } private var valid: Bool { start.isFinite && end.isFinite && start >= 0 && end <= player.duration && end-start >= 0.1-1e-9 } var body: some View { VStack(alignment:.leading,spacing:18) { Text("Deck \(letter) · Loop Points").font(.title2.bold()) HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time } } HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time } } Text(valid ? String(format:"Loop length: %.3f seconds",end-start) : "End must be at least 0.1 seconds after start, within the track.").font(.caption).foregroundStyle(valid ? Color.secondary : Color.orange) Text("On either waveform, drag the green edges to resize or the loop region to move. Shift-drag defines a new loop. Playback continues while editing.").font(.callout).foregroundStyle(.secondary) HStack { Button("Cancel") { dismiss() };Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction) } }.padding(24).frame(width:540) } } // Display-link-style redraws stay inside AppKit, keeping 60 Hz spectrum motion // independent of SwiftUI layout, the karaoke library and lyric accessibility. struct DJNativeSpectrum: NSViewRepresentable { let player: Player let bpm: Double? let color: NSColor let seconds: Double let label: String let showTimeGrid: Bool let spectralWaveforms: Bool func makeNSView(context: Context) -> DJNativeSpectrumView { DJNativeSpectrumView() } func updateNSView(_ view: DJNativeSpectrumView,context: Context) { view.player = player;view.bpm = bpm;view.color = color;view.span = seconds view.label = label;view.showTimeGrid = showTimeGrid;view.spectralWaveforms = spectralWaveforms view.setAccessibilityElement(true);view.setAccessibilityRole(.image) view.setAccessibilityLabel(label == "KARAOKE" ? "Karaoke scrolling waveform" : "Deck \(label) scrolling waveform") view.setAccessibilityValue(String(format:"%d:%02d",Int(max(0,player.time))/60,Int(max(0,player.time))%60)) view.relativeSeek = true;view.waveWidth = view.bounds.width;view.needsDisplay = true } } final class DJNativeSpectrumView: DJWaveMouseView { var bpm: Double? var color = NSColor.systemCyan var label = "" var showTimeGrid = true,spectralWaveforms = true private var clock: Timer? private var frozenViewport: Double? private var hoverX: Double? private var tracking: NSTrackingArea? override init(frame: NSRect) { super.init(frame:frame) freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidMoveToWindow() { super.viewDidMoveToWindow();clock?.invalidate();clock = nil if window != nil { let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in guard let self,self.player?.playing == true,!self.isHiddenOrHasHiddenAncestor else { return } self.needsDisplay = true } RunLoop.main.add(timer,forMode:.common);clock = timer } } deinit { clock?.invalidate() } override func updateTrackingAreas() { super.updateTrackingAreas();if let tracking { removeTrackingArea(tracking) } let area = NSTrackingArea(rect:.zero,options:[.activeInKeyWindow,.inVisibleRect,.mouseMoved,.mouseEnteredAndExited],owner:self) addTrackingArea(area);tracking = area } override func resetCursorRects() { addCursorRect(bounds,cursor:.openHand) } override func mouseMoved(with event: NSEvent) { hoverX = convert(event.locationInWindow,from:nil).x;needsDisplay = true } override func mouseExited(with event: NSEvent) { hoverX = nil;needsDisplay = true } override func mouseDown(with event: NSEvent) { viewport = (player?.displayTime ?? 0)-span/2;waveWidth = bounds.width super.mouseDown(with:event) } override func draw(_ dirtyRect: NSRect) { NSColor.black.setFill();bounds.fill() guard let player,player.duration > 0,span > 0,!player.waveform.isEmpty else { return } let time = player.displayTime,start = frozenViewport ?? (time-span/2),width = bounds.width,height = bounds.height guard width > 0,height > 0 else { return } if showTimeGrid { NSColor.white.withAlphaComponent(0.15).setFill() for tick in 0...8 { NSRect(x:Double(tick)/8*width,y:0,width:0.5,height:height).fill() } } let paths = (0..<32).map { _ in NSBezierPath() },count = max(1,Int(width/2)) for i in 0..= 0,seconds < player.duration else { continue } 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 barHeight = max(1,amplitude*height*0.85) var bucket = 0 if spectralWaveforms,player.waveformBands.indices.contains(index) { let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high) bucket = min(31,max(0,Int((Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72)*31))) } paths[bucket].appendRect(NSRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight)) } for bucket in paths.indices where !paths[bucket].isEmpty { (spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color).setFill();paths[bucket].fill() } if let bpm { for beat in DJWaveMarkers.beats(start:start,span:span,bpm:bpm,width:width) { let x = (Double(beat)*60/bpm-start)/span*width,major = beat % 4 == 0 (major ? NSColor.systemYellow.withAlphaComponent(0.85) : NSColor.white.withAlphaComponent(0.4)).setFill() NSRect(x:x,y:0,width:major ? 1.2 : 0.6,height:height).fill() if major,height >= 45 { text("\(beat/4+1)",at:NSPoint(x:x+5,y:2),color:.systemYellow) } } } if player.loopEnabled,let first = player.loopIn,let last = player.loopOut { let left = max(0,(first-start)/span*width),right = min(width,(last-start)/span*width) if right > left { let rect = NSRect(x:left,y:0,width:right-left,height:height) NSColor.systemGreen.withAlphaComponent(0.22).setFill();rect.fill() NSColor.systemGreen.setStroke();let border = NSBezierPath(rect:rect.insetBy(dx:0.8,dy:0.8));border.lineWidth = 1.5;border.stroke() } for (point,label) in [(first,"IN"),(last,"OUT")] { let x = (point-start)/span*width if x >= 0,x <= width { NSColor.systemGreen.setFill();NSRect(x:x,y:0,width:2,height:height).fill();NSRect(x:x-3,y:height/2-7,width:7,height:14).fill() text(label,at:NSPoint(x:min(width-24,max(0,x)),y:2),color:.systemGreen) } } } NSColor.systemRed.setFill();NSRect(x:(time-start)/span*width,y:0,width:1.5,height:height).fill() text(label+String(format:" · %.1f s",span),at:NSPoint(x:5,y:4),color:color) if let hoverX { let seconds = max(0,start+hoverX/width*span) text(String(format:"%d:%05.2f",Int(seconds)/60,seconds.truncatingRemainder(dividingBy:60)),at:NSPoint(x:max(0,width-65),y:max(0,height-14)),color:.white) } } private func text(_ value: String,at point: NSPoint,color: NSColor) { (value as NSString).draw(at:point,withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:9,weight:.semibold),.foregroundColor:color]) } }