133 lines
7.0 KiB
Swift
133 lines
7.0 KiB
Swift
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<Double>?
|
|
init(x: Double,viewport: Double,span: Double,width: Double,loop: ClosedRange<Double>?,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<Double>? {
|
|
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?
|
|
func body(content: Content) -> some View {
|
|
content.contentShape(Rectangle())
|
|
.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):
|
|
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.")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
@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)
|
|
}
|
|
}
|