performance updates

This commit is contained in:
Mark Gregory
2026-11-06 07:44:08 +00:00
parent d71c823ee6
commit 9e610bd6f2
6 changed files with 182 additions and 144 deletions
+140 -8
View File
@@ -44,11 +44,12 @@ struct DJLoopWaveInteraction: ViewModifier {
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,frozenStart:$frozenStart)
DJWaveMouseSurface(player:player,start:start,span:span,width:width,relativeSeek:relativeSeek,frozenStart:$frozenStart)
}
.onChange(of:player.currentSongID) { _ in frozenStart = nil }
.onContinuousHover { phase in
@@ -60,12 +61,12 @@ struct DJLoopWaveInteraction: ViewModifier {
case .start,.end: NSCursor.resizeLeftRight.set()
case .move: NSCursor.openHand.set()
case .create: NSCursor.crosshair.set()
case .seek: NSCursor.arrow.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.")
.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.")
}
}
@@ -75,18 +76,21 @@ struct DJWaveMouseSurface: NSViewRepresentable {
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.player = player;view.viewport = start;view.span = span;view.waveWidth = width;view.relativeSeek = relativeSeek
view.freeze = { frozenStart = $0 }
}
}
final class DJWaveMouseView: NSView {
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) {
@@ -94,16 +98,25 @@ final class DJWaveMouseView: NSView {
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)
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)
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 { player.seek(drag.viewport+x*drag.secondsPerPixel) }
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) }
}
}
}
@@ -130,3 +143,122 @@ struct DJLoopEditor: View {
}.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..<count {
let seconds = start+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].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])
}
}
+24 -37
View File
@@ -942,23 +942,26 @@ private struct DJPlatter: View {
}
}
}
private struct DJWaveform: View {
struct DJWaveform: View {
@State private var frozenStart: Double?
@ObservedObject var player: Player
let color: Color
var bpm: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
guard !values.isEmpty else { return }
let count = max(1,Int(size.width/2))
var played = Path(),remaining = Path()
for i in 0..<count {
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < displayTime/max(0.001,player.duration) ? 0.4 : 0.9)))
let rect = CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)
if Double(i)/Double(count) < displayTime/max(0.001,player.duration) { played.addRect(rect) } else { remaining.addRect(rect) }
}
context.fill(played,with:.color(color.opacity(0.4)))
context.fill(remaining,with:.color(color.opacity(0.9)))
DJWaveMarkers.draw(context:&context,size:size,start:0,span:player.duration,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
let x = displayTime/max(0.001,player.duration)*size.width
@@ -967,7 +970,6 @@ private struct DJWaveform: View {
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
.modifier(DJLoopWaveInteraction(player:player,start:0,span:player.duration,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
}
}
}
private struct DJMappingSheet: View {
@@ -1226,41 +1228,26 @@ private struct DJCentreWaveform: View {
@ObservedObject var player: Player
let letter: String
let color: Color
private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil }
var settings = DJPreferences()
@State private var frozenStart: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
Canvas { context,size in
guard !player.waveform.isEmpty,player.duration > 0 else { return }
let span = settings.waveformSeconds,start = frozenStart ?? (displayTime-span/2),count = max(1,Int(size.width/2))
for second in 0...8 {
let x = Double(second)/8*size.width
context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0)))
}
for i in 0..<count {
let seconds = start+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 height = max(1,amplitude*size.height*0.85)
var shade = color
if settings.spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
shade = Color(hue:Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72,saturation:0.85,brightness:0.95)
}
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade))
}
DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
let playhead = (displayTime-start)/span*size.width
context.fill(Path(CGRect(x:playhead,y:0,width:1.5,height:size.height)),with:.color(.red))
}.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) }
.modifier(DJLoopWaveInteraction(player:player,start:displayTime-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time))
}
DJScrollingSpectrum(player:player,bpm:deck.bpmConfirmed ? deck.bpm : nil,color:color,seconds:settings.waveformSeconds,label:letter,accessibilityName:"Deck \(letter) scrolling waveform",showTimeGrid:settings.showTimeGrid,spectralWaveforms:settings.spectralWaveforms)
}
}
// The same audio-clock drawing and native mouse interaction serve both studios.
struct DJScrollingSpectrum: View {
@ObservedObject var player: Player
var bpm: Double?
var color: Color = .cyan
var seconds: Double = 16
var label = "KARAOKE"
var accessibilityName = "Karaoke scrolling waveform"
var showTimeGrid = true
var spectralWaveforms = true
var body: some View {
DJNativeSpectrum(player:player,bpm:bpm,color:NSColor(color),seconds:seconds,label:label,showTimeGrid:showTimeGrid,spectralWaveforms:spectralWaveforms)
.accessibilityLabel(accessibilityName).accessibilityValue(DJStyle.stamp(player.time))
.help("Hold the left button and drag left to advance or right to rewind. Shift-drag creates a loop; drag green loop edges to resize or its centre to move it.")
}
}
+14 -96
View File
@@ -1700,110 +1700,21 @@ private struct NowPlayingWaveform: View {
@ObservedObject var player: Player
@State private var mixer = false
@State private var zoom: Double = 1
@State private var hoverX: CGFloat?
@State private var scrubStart: Double?
@State private var resumeAfterScrub = false
@State private var scrubSong: Int64?
private var span: Double { max(0.001,player.duration/zoom) }
private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) }
private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 }
@State private var bpm: Double?
private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) }
private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) }
private func bandColor(_ index: Int) -> Color {
guard player.waveformBands.indices.contains(index) else { return .cyan }
let band = player.waveformBands[index]
let largest = max(0.000001,max(band.low,max(band.mid,band.high)))
let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest)
return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high))
}
var body: some View {
VStack(alignment:.leading,spacing:5) {
Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94))
.lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title)
GeometryReader { geometry in
Canvas { context,size in
let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2
var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid))
context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5)
if player.duration > 0 {
let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600
let first = floor(windowStart/(step/4))*(step/4)
for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) {
let x = size.width*(t-windowStart)/span
if x < 0 { continue }
let major = abs(t/step-(t/step).rounded()) < 0.001
var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5)
if major && x < size.width-25 {
context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading)
}
}
}
if !player.waveform.isEmpty {
let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count)
for bar in 0..<count {
let total = player.waveform.count
let left = windowStart+Double(bar)/Double(count)*span,right = windowStart+Double(bar+1)/Double(count)*span
let start = min(total-1,max(0,Int(left/max(player.duration,0.001)*Double(total))))
let end = min(total,max(start+1,Int(right/max(player.duration,0.001)*Double(total))))
let level = player.waveform[start..<min(end,player.waveform.count)].max() ?? 0
let height = max(0.7,CGFloat(level)*(plotHeight-3)),x = CGFloat(bar)*step
let path = Path(CGRect(x:x,y:mid-height/2,width:max(0.7,step-0.7),height:height))
context.fill(path,with:.color(bandColor(start).opacity(x <= size.width*progress ? 1 : 0.88)))
}
if let hoverX {
let hx = min(size.width,max(0,hoverX))
var hover = Path();hover.move(to:CGPoint(x:hx,y:top));hover.addLine(to:CGPoint(x:hx,y:size.height))
context.stroke(hover,with:.color(.white.opacity(0.7)),style:StrokeStyle(lineWidth:1,dash:[2,2]))
let time = windowStart+Double(hx/size.width)*span
context.draw(Text(preciseClock(time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.white),at:CGPoint(x:min(size.width-28,max(28,hx)),y:3))
}
} else if player.waveformLoading {
context.draw(Text("Analysing waveform…").font(.system(size:9)).foregroundColor(.gray),at:CGPoint(x:size.width/2,y:mid))
}
if player.duration > 0 {
let x = min(size.width-2,max(2,size.width*progress))
context.fill(Path(CGRect(x:0,y:top,width:x,height:plotHeight)),with:.color(.black.opacity(0.18)))
var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(head,with:.color(.black.opacity(0.9)),lineWidth:6)
context.stroke(head,with:.color(.cyan.opacity(0.3)),lineWidth:5)
context.stroke(head,with:.color(.white),lineWidth:2)
var pointer = Path();pointer.move(to:CGPoint(x:x-5,y:top));pointer.addLine(to:CGPoint(x:x+5,y:top));pointer.addLine(to:CGPoint(x:x,y:top+7));pointer.closeSubpath()
context.fill(pointer,with:.color(.cyan))
let labelX = min(size.width-30,max(30,x))
context.fill(Path(roundedRect:CGRect(x:labelX-29,y:0,width:58,height:13),cornerRadius:3),with:.color(.cyan))
context.draw(Text(preciseClock(player.time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.black),at:CGPoint(x:labelX,y:6))
}
}.contentShape(Rectangle()).background(WaveformJogWheel(player:player))
.onContinuousHover { phase in switch phase { case .active(let point):hoverX = point.x;case .ended:hoverX = nil } }
.gesture(DragGesture(minimumDistance:0).onChanged { value in
guard player.duration > 0,geometry.size.width > 0 else { return }
if scrubStart == nil {
scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing
if player.playing { player.toggle() }
}
guard scrubSong == player.currentSongID else { return }
hoverX = value.location.x
let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span
if abs(target-player.time) > 0.02 { player.seek(target) }
}.onEnded { value in
if scrubStart == nil {
if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) }
return
}
let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span
if scrubSong == player.currentSongID {
player.seek(target)
if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() }
}
scrubStart = nil;resumeAfterScrub = false;scrubSong = nil
}).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.")
.accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))")
.accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } }
}.frame(height:60)
DJWaveform(player:player,color:.cyan,bpm:bpm).frame(height:14)
DJScrollingSpectrum(player:player,bpm:bpm,seconds:16/zoom)
.frame(height:46).background(.black)
.background(WaveformJogWheel(player:player))
.accessibilityAdjustableAction { direction in player.seek(player.time+(direction == .increment ? 5 : -5)) }
HStack(spacing:5) {
Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4)
Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1)
if let bpm { Text(String(format:"· %.1f BPM~",bpm*player.tempo)).monospacedDigit() }
if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) }
Spacer(minLength:0)
Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out")
@@ -1822,6 +1733,13 @@ private struct NowPlayingWaveform: View {
.background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10))
.overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1))
.environment(\.colorScheme,.dark)
.task(id:player.currentSongID) {
bpm = nil
guard let source = player.sourceAudioURL,!player.isShowScreen else { return }
let analysis = Task.detached(priority:.utility) { try? DJBPM.analyse(source,cancelled:{Task.isCancelled}) }
let result = await withTaskCancellationHandler(operation:{ await analysis.value },onCancel:{ analysis.cancel() })
if !Task.isCancelled { bpm = result }
}
}
}
private struct ToneKnob: View {
+1
View File
@@ -170,6 +170,7 @@ import CoreAudio
@Published private(set) var waveformSaved = false
private let waveformCacheDirectory: URL
private var audioURL: URL?
var sourceAudioURL: URL? { audioURL }
private var waveformTask: Task<Void, Never>?
private var waveformGeneration = UUID()
@Published var playing = false