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? 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) } .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..= 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) } }