336 lines
19 KiB
Swift
336 lines
19 KiB
Swift
import CoreText
|
|
import SwiftUI
|
|
import AppKit
|
|
import QuartzCore
|
|
|
|
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
|
|
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)
|
|
}
|
|
.onStudioChange(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);player?.endScrubbing();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.scrub(to:target) }
|
|
freeze?(drag.viewport+player.displayTime-initialTime)
|
|
NSCursor.closedHand.set()
|
|
} else { player.scrub(to: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 }.help("Use the current playback position as the loop start") }
|
|
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 }.help("Use the current playback position as the loop end") }
|
|
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() }.help("Close without changing the loop");Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction).help("Apply these start/end points and enable looping") }
|
|
}.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() }
|
|
static func dismantleNSView(_ view: DJNativeSpectrumView,coordinator: ()) { view.stopDisplayClock() }
|
|
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 displayClock: AnyObject?
|
|
private var tiles = [Int:CGImage]()
|
|
private var tileKey = ""
|
|
private(set) var waveformTileBuildCount = 0
|
|
private var frozenViewport: Double?
|
|
private var hoverX: Double?
|
|
private var tracking: NSTrackingArea?
|
|
override init(frame: NSRect) {
|
|
super.init(frame:frame);clipsToBounds = true
|
|
freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true }
|
|
}
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
func stopDisplayClock() {
|
|
clock?.invalidate();clock = nil
|
|
if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.invalidate() }
|
|
displayClock = nil
|
|
}
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow();stopDisplayClock()
|
|
guard window != nil else { return }
|
|
if #available(macOS 14.0,*) {
|
|
let link = displayLink(target:self,selector:#selector(displayFrame(_:)))
|
|
link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:120,preferred:60)
|
|
link.add(to:.main,forMode:.common);displayClock = link
|
|
} else {
|
|
let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in self?.refreshFrame() }
|
|
RunLoop.main.add(timer,forMode:.common);clock = timer
|
|
}
|
|
}
|
|
@available(macOS 14.0,*) @objc private func displayFrame(_ link: CADisplayLink) { refreshFrame() }
|
|
private func refreshFrame() {
|
|
guard player?.playing == true,!isHiddenOrHasHiddenAncestor else { return }
|
|
needsDisplay = true
|
|
}
|
|
deinit {
|
|
clock?.invalidate()
|
|
if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.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) {
|
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
|
context.saveGState()
|
|
defer { context.restoreGState() }
|
|
// Cached sections extend beyond the viewport while scrolling. Neither
|
|
// those sections nor their markers may paint into adjacent deck controls.
|
|
context.clip(to:bounds)
|
|
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() }
|
|
}
|
|
drawWaveform(player,start:start,width:width,height:height)
|
|
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 drawWaveform(_ player: Player,start: Double,width: Double,height: Double) {
|
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
|
let scale = window?.backingScaleFactor ?? 2
|
|
let key = "\(player.waveformRevision):\(width):\(height):\(scale):\(span):\(spectralWaveforms):\(color)"
|
|
if tileKey != key { tiles.removeAll();tileKey = key }
|
|
let first = Int(floor(start/span))
|
|
tiles = tiles.filter { (first...first+2).contains($0.key) }
|
|
context.interpolationQuality = .high
|
|
for tile in first...first+2 {
|
|
if tiles[tile] == nil { tiles[tile] = waveformTile(player,tile:tile,width:width,height:height,scale:scale) }
|
|
if let image = tiles[tile] {
|
|
let x = (Double(tile)*span-start)/span*width
|
|
context.draw(image,in:CGRect(x:x,y:0,width:width,height:height))
|
|
}
|
|
}
|
|
}
|
|
private func waveformTile(_ player: Player,tile: Int,width: Double,height: Double,scale: Double) -> CGImage? {
|
|
guard let context = CGContext(data:nil,width:max(1,Int(ceil(width*scale))),height:max(1,Int(ceil(height*scale))),bitsPerComponent:8,bytesPerRow:0,space:CGColorSpaceCreateDeviceRGB(),bitmapInfo:CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
|
|
context.scaleBy(x:scale,y:scale)
|
|
let paths = (0..<32).map { _ in CGMutablePath() },count = max(1,Int(width/2))
|
|
for i in 0..<count {
|
|
let seconds = Double(tile)*span+Double(i)/Double(count)*span
|
|
guard seconds >= 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].addRect(CGRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight))
|
|
}
|
|
for bucket in paths.indices where !paths[bucket].isEmpty {
|
|
let shade = spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color
|
|
context.setFillColor(shade.cgColor);context.addPath(paths[bucket]);context.fillPath()
|
|
}
|
|
let image = context.makeImage()
|
|
if image != nil { waveformTileBuildCount += 1 }
|
|
return image
|
|
}
|
|
private let markerFont = CTFontCreateWithName("Menlo-Bold" as CFString,9,nil)
|
|
private var markerLines = [String:CTLine]()
|
|
private func text(_ value: String,at point: NSPoint,color: NSColor) {
|
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
|
let key = value+"|"+color.description
|
|
let line: CTLine
|
|
if let saved = markerLines[key] { line = saved }
|
|
else {
|
|
let attributes: [NSAttributedString.Key:Any] = [
|
|
NSAttributedString.Key(kCTFontAttributeName as String):markerFont,
|
|
NSAttributedString.Key(kCTForegroundColorAttributeName as String):color.cgColor
|
|
]
|
|
line = CTLineCreateWithAttributedString(NSAttributedString(string:value,attributes:attributes))
|
|
if markerLines.count >= 128 { markerLines.removeAll(keepingCapacity:true) }
|
|
markerLines[key] = line
|
|
}
|
|
context.saveGState();defer { context.restoreGState() }
|
|
context.translateBy(x:point.x,y:point.y+10);context.scaleBy(x:1,y:-1)
|
|
context.textMatrix = .identity;context.textPosition = .zero
|
|
CTLineDraw(line,context)
|
|
}
|
|
}
|