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 @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 } .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: 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.") } } 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) } }