removed unwanted bar

This commit is contained in:
Mark Gregory
2026-11-06 09:51:44 +00:00
parent b1868ee2d3
commit 794fa595d7
7 changed files with 285 additions and 37 deletions
+23 -4
View File
@@ -1,3 +1,4 @@
import CoreText
import SwiftUI
import AppKit
import QuartzCore
@@ -105,7 +106,7 @@ class DJWaveMouseView: NSView {
}
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);NSCursor.openHand.set()
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 }
@@ -113,10 +114,10 @@ class DJWaveMouseView: NSView {
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) }
if abs(player.time-target) > 0.0001 { player.scrub(to:target) }
freeze?(drag.viewport+target-initialTime)
NSCursor.closedHand.set()
} else { player.seek(drag.viewport+x*drag.secondsPerPixel) }
} else { player.scrub(to:drag.viewport+x*drag.secondsPerPixel) }
}
}
}
@@ -310,7 +311,25 @@ final class DJNativeSpectrumView: DJWaveMouseView {
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) {
(value as NSString).draw(at:point,withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:9,weight:.semibold),.foregroundColor:color])
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)
}
}
+112 -22
View File
@@ -48,7 +48,7 @@ struct DJPreferences: Codable, Equatable {
}
@MainActor final class DJChannel: ObservableObject {
let player = Player(persistsMix:false)
let player: Player
let letter: String
@Published private(set) var song: Song?
@Published var bpm: Double = 120
@@ -70,7 +70,7 @@ struct DJPreferences: Codable, Equatable {
var master: Double = 0.8 { didSet { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
private var taps = [Double]()
init(_ letter: String) { self.letter = letter;player.autoFade = false;updateLevel() }
init(_ letter: String,waveformCache: URL? = nil) { self.letter = letter;player = Player(waveformCacheDirectory:waveformCache,persistsMix:false);player.audibleScrubbing = true;player.autoFade = false;updateLevel() }
func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) }
func load(_ song: Song) throws {
var audioOnly = song;audioOnly.cdg = nil
@@ -151,7 +151,9 @@ struct DJAudioDevice: Identifiable, Equatable {
}
@MainActor final class DJSystem: ObservableObject {
let a = DJChannel("A"),b = DJChannel("B")
@Published private(set) var favourites = Set<Int64>()
let a: DJChannel
let b: DJChannel
let preview = Player(persistsMix:false)
let midi = DJMIDI()
lazy var automix = DJAutomix(system:self)
@@ -191,6 +193,7 @@ struct DJAudioDevice: Identifiable, Equatable {
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
Task { @MainActor [weak self] in self?.maintainSync() }
@@ -204,6 +207,7 @@ struct DJAudioDevice: Identifiable, Equatable {
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized }
if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) }
applyMix()
@@ -212,6 +216,14 @@ struct DJAudioDevice: Identifiable, Equatable {
}
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
func toggleFavourite(_ song: Int64) {
var updated = favourites
if !updated.insert(song).inserted { updated.remove(song) }
perform {
try library?.setSetting("DJ.Favourites.v1",updated.sorted().map(String.init).joined(separator:","))
favourites = updated
}
}
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } }
func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } }
func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);try refresh() } }
@@ -220,7 +232,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id}
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
@@ -228,7 +240,7 @@ struct DJAudioDevice: Identifiable, Equatable {
var importedIDs = [Int64]()
do {
let db = try Library(url:database)
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
}
@@ -264,9 +276,9 @@ struct DJAudioDevice: Identifiable, Equatable {
if !automated { automix.manualTakeover() }
if syncActive { disableSync() }
if previewDeck == letter { stopPreview() }
try deck.load(song)
let route = routing.route(letter)
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
// Configure HAL before load starts the engine, rather than restarting it immediately afterwards.
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel);try deck.load(song) } catch { deck.unload();throw error }
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad && !automated { deck.player.toggle() }
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
@@ -701,8 +713,67 @@ private enum DJStyle {
}
}
/// Observe user scrolling, rather than row appearance (List preloads offscreen rows).
private struct DJLibraryPageScroll: NSViewRepresentable {
let next: () -> Void
func makeNSView(context: Context) -> Observer { Observer() }
func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach() }
final class Observer: NSView {
var next: (() -> Void)?
private weak var scroll: NSScrollView?
private var observation: NSObjectProtocol?
private var endObservation: NSObjectProtocol?
private var advanced = false
private var lastY = 0.0
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } }
func attach() {
guard window != nil,scroll == nil else { return }
func find(_ view: NSView) -> NSScrollView? {
if let scroll = view as? NSScrollView { return scroll }
for child in view.subviews { if let found = find(child) { return found } }
return nil
}
var parent = superview
while let view = parent {
if let found = find(view) { scroll = found;break }
parent = view.superview
}
guard let scroll else { return }
lastY = scroll.contentView.bounds.origin.y
observation = NotificationCenter.default.addObserver(forName:NSScrollView.didLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
guard let self,let scroll = self.scroll,let document = scroll.documentView else { return }
let y = scroll.contentView.bounds.origin.y
let forward = y > self.lastY+0.1
self.lastY = y
let bottom = document.bounds.height-scroll.contentView.bounds.height
guard !self.advanced,forward,bottom > 1,y >= bottom-2 else { return }
self.advanced = true
DispatchQueue.main.async { [weak self] in self?.next?() }
}
endObservation = NotificationCenter.default.addObserver(forName:NSScrollView.didEndLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
self?.advanced = false;self?.lastY = self?.scroll?.contentView.bounds.origin.y ?? 0
}
}
deinit {
if let observation { NotificationCenter.default.removeObserver(observation) }
if let endObservation { NotificationCenter.default.removeObserver(endObservation) }
}
}
}
private struct DJQueueButton: View {
@ObservedObject var automix: DJAutomix
let open: () -> Void
var body: some View {
Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") }
.tint(automix.enabled ? .green : .accentColor)
}
}
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var favouritesOnly = false
@State private var queueVisible = false
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [Int64]()
@@ -712,7 +783,7 @@ struct DJConsole: View {
@State private var renaming = false
@State private var page = 0
private var songs: [Song] {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && (!favouritesOnly || system.favourites.contains($0.id)) && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} }
return all
}
@@ -723,10 +794,7 @@ struct DJConsole: View {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
HStack(alignment:.top,spacing:12) {
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(width:310).frame(maxHeight:.infinity)
}.frame(maxWidth:.infinity,maxHeight:.infinity)
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
@@ -750,6 +818,12 @@ struct DJConsole: View {
}
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
.sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) }
.sheet(isPresented:$queueVisible) {
VStack(spacing:16) {
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity)
HStack { Spacer();Button("Done") { queueVisible = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:620,height:560).background(DJStyle.background).preferredColorScheme(.dark)
}
.sheet(isPresented:$playlistEditor) { playlistSheet }
.alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
.onAppear { refreshPlaylist();connectMIDI() }
@@ -758,6 +832,7 @@ struct DJConsole: View {
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in page = 0 }
.onChange(of:favouritesOnly) { _ in page = 0 }
}
private var header: some View {
HStack {
@@ -765,6 +840,7 @@ struct DJConsole: View {
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Spacer()
DJQueueButton(automix:system.automix,open:{queueVisible = true})
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
}.controlSize(.small)
}
@@ -779,7 +855,9 @@ struct DJConsole: View {
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.foregroundStyle(favouritesOnly ? Color.yellow : Color.secondary).help("Show favourite tracks only")
Spacer(minLength:12)
TextField("Search DJ music",text:$query).textFieldStyle(.roundedBorder).controlSize(.regular).frame(maxWidth:260)
Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
@@ -787,27 +865,35 @@ struct DJConsole: View {
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
ScrollViewReader { reader in
List(selection:$selected) {
ForEach(visible) { song in
HStack(spacing:8) {
Image(systemName:"music.note").foregroundStyle(.secondary)
Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)")
DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selected = song.id}).frame(maxWidth:.infinity,alignment:.leading)
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue")
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).id(song.id).buttonStyle(.bordered).controlSize(.mini)
.contextMenu { playlistMenu(song) }
}
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
}.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6))
.background(DJLibraryPageScroll { if page < pages-1 { page += 1 } })
.onChange(of:page) { _ in
selected = visible.first?.id
if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } }
}
}
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Load into Deck A") { system.load(song,into:"A") }
Button("Load into Deck B") { system.load(song,into:"B") }
Divider()
Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) }
Button("Add to DJ Queue") { system.automix.enqueue([song]) }
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
@@ -889,11 +975,15 @@ private struct DJDeckSurfaces {
}
VStack(spacing:12) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:8,height:5) }
}.accessibilityLabel("Deck output meter")
HStack(spacing:3) {
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) }
}.accessibilityLabel("Deck output meter")
DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:94)
}
Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary)
}
}.frame(width:88)
}.frame(width:110)
}
}
@@ -1119,14 +1209,14 @@ struct DJContinuousConsole: View {
}.frame(height:92).background(Color.black.opacity(0.4))
Divider()
HStack(spacing:0) {
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:322)
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:344)
Divider()
VStack(spacing:1) {
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
}.frame(maxWidth:.infinity).background(.black)
Divider()
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:322)
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:344)
}.frame(height:198).background(Color(white:0.12))
Divider()
HStack(spacing:12) {
@@ -1199,7 +1289,7 @@ private struct DJConsoleWing: View {
}
if deck.letter == "A" { face.tonePanel }
if deck.letter == "B" { face.tempoStrip }
}.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}.padding(.horizontal,9).frame(width:344).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}
}
+11
View File
@@ -25,6 +25,7 @@ struct Importer {
let media: URL
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
var waveformCache: URL? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
try ManagedMediaStorage.prepare(media)
var state = ImportProgress()
@@ -252,6 +253,16 @@ struct Importer {
try remember(audio,signature,id)
state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache {
report("Generating waveform: "+audio.lastPathComponent,force:true)
do {
if (try? WaveformCache.load(audio:dest,directory:waveformCache)) == nil {
let waveform = try AudioWaveform.analyse(url:dest,cancelled:cancelled)
try WaveformCache.save(waveform,audio:dest,directory:waveformCache)
}
} catch is CancellationError { throw CancellationError() }
catch { state.logLines.append("[WARNING] Waveform unavailable for "+sourcePath(audio)+": "+error.localizedDescription) }
}
report(audio.lastPathComponent,force:state.completed == 1)
}
for archive in archives {
+38 -10
View File
@@ -476,7 +476,7 @@ struct SongDragReference: Codable, Sendable {
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
@@ -484,7 +484,7 @@ struct SongDragReference: Codable, Sendable {
var latest = ImportProgress()
do {
let database = try Library(url:database)
try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
try Importer(media:media,waveformCache:cache).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in
guard let self,self.importToken == token else { return };self.importProgress = update
@@ -616,6 +616,39 @@ private struct ImportProgressDialog: View {
}
}
}
private struct WaveformProgressCard: View {
let progress: WaveformJobProgress
let running: Bool
let cancel: () -> Void
let details: () -> Void
let dismiss: () -> Void
private var fraction: Double { min(1,max(0,Double(progress.processed)/Double(max(1,progress.total)))) }
private var colour: Color { progress.cancelled || progress.issues > 0 ? .orange : running ? .cyan : .green }
var body: some View {
HStack(spacing:16) {
ZStack {
Circle().stroke(colour.opacity(0.15),lineWidth:5)
Circle().trim(from:0,to:fraction).stroke(colour,style:StrokeStyle(lineWidth:5,lineCap:.round)).rotationEffect(.degrees(-90))
Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit()
}.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent")
VStack(alignment:.leading,spacing:7) {
HStack { Text(running ? "Preparing Karaoke Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) }
GeometryReader { proxy in
ZStack(alignment:.leading) {
Capsule().fill(colour.opacity(0.12))
Capsule().fill(LinearGradient(colors:[colour.opacity(0.6),colour],startPoint:.leading,endPoint:.trailing)).frame(width:proxy.size.width*fraction)
}
}.frame(height:7)
Text(progress.current).font(.caption).lineLimit(1).foregroundStyle(.secondary)
Text("\(progress.generated) generated · \(progress.cached) reused · \(progress.skipped) skipped · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
}
if running { Button("Cancel",action:cancel).controlSize(.small) }
if progress.issues > 0 { Button("Details",action:details).controlSize(.small) }
if !running { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(16).background(colour.opacity(0.05),in:RoundedRectangle(cornerRadius:12))
.overlay(RoundedRectangle(cornerRadius:12).strokeBorder(colour.opacity(0.18)))
}
}
struct ImportProgressPanel: View {
let progress: ImportProgress
let studio: String
@@ -864,13 +897,8 @@ struct LibraryView: View {
}
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
Image(systemName:"waveform").foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption)
Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) }
if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) }
if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(.horizontal,18).padding(.vertical,8)
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,cancel:{model.cancelWaveformGeneration()},details:{waveformDetails = true},dismiss:{model.dismissWaveformProgress()})
.padding(.horizontal,18).padding(.vertical,10)
}
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
@@ -1442,7 +1470,7 @@ private struct TonightSongDropTarget: ViewModifier {
}
}
/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white.
private struct GoldStar: View {
struct GoldStar: View {
var filled: Bool
var size: CGFloat = 14
var body: some View {
+31
View File
@@ -309,6 +309,7 @@ import CoreAudio
if let timer { RunLoop.main.add(timer,forMode:.common) }
}
func toggle() {
endScrubbing()
if slideImage != nil {
if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return
}
@@ -318,6 +319,7 @@ import CoreAudio
else { startNodePlayback(); playing = true; startTimer() }
}
func stop() {
endScrubbing()
cancelPendingPlayback()
graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1
cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop()
@@ -331,6 +333,35 @@ import CoreAudio
engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone)
file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song"
}
var audibleScrubbing = false
private var scrubPreviewTask: Task<Void,Never>?
private var scrubPreviewActive = false
/// Preview short grains through the deck's normal EQ, fader and output route.
/// Keep transport paused so scrubbing cannot start Automix or change sync state.
func scrub(to value: Double) {
guard audibleScrubbing,!playing,let file,value.isFinite else { seek(value);return }
scrubPreviewTask?.cancel()
seek(value)
guard time < duration,engine.isRunning else { return }
generation = UUID();let token = generation
node.stop()
let rate = file.processingFormat.sampleRate
let start = AVAudioFramePosition(time*rate)
let count = AVAudioFrameCount(min(file.length-start,Int64(rate*0.1)))
guard count > 0 else { return }
node.scheduleSegment(file,startingFrame:start,frameCount:count,at:nil)
scrubPreviewActive = true;node.play()
scrubPreviewTask = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds:100_000_000) } catch { return }
guard let self,self.generation == token else { return }
self.endScrubbing()
}
}
func endScrubbing() {
scrubPreviewTask?.cancel();scrubPreviewTask = nil
guard scrubPreviewActive else { return };scrubPreviewActive = false
if !playing { schedule(at:time,play:false) }
}
func seek(_ value: Double) {
guard value.isFinite else { return }
invalidateGraphics()