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
+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 {