removed unwanted bar
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2622,5 +2622,66 @@ extension KJamsCloneTests {
|
||||
XCTAssertTrue(report.finished);XCTAssertNil(report.failure)
|
||||
XCTAssertEqual(report.completed,48);XCTAssertEqual(report.skipped,47)
|
||||
XCTAssertEqual(report.reportLines.count,48);XCTAssertEqual(system.songs.count,1)
|
||||
let song = try XCTUnwrap(system.songs.first)
|
||||
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:root.appendingPathComponent("DJ/Waveforms")))
|
||||
}
|
||||
@MainActor func testDJFavouritesPersistIndependentlyAndToggleOff() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let system = DJSystem(storageURL:root)
|
||||
system.toggleFavourite(42)
|
||||
XCTAssertEqual(system.favourites,[42])
|
||||
let reopened = DJSystem(storageURL:root)
|
||||
XCTAssertEqual(reopened.favourites,[42])
|
||||
reopened.toggleFavourite(42)
|
||||
XCTAssertTrue(reopened.favourites.isEmpty)
|
||||
XCTAssertTrue(DJSystem(storageURL:root).favourites.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor func testDJAudibleScrubbingPreviewsPausedAudioAndPreservesTransport() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let player = Player(waveformCacheDirectory:root)
|
||||
defer { player.stop() }
|
||||
player.audibleScrubbing = true
|
||||
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
|
||||
player.scrub(to:0.5)
|
||||
XCTAssertFalse(player.playing)
|
||||
XCTAssertTrue(player.audioPlaybackStarted,"Paused scrubbing must render an audio grain")
|
||||
player.scrub(to:0.2)
|
||||
XCTAssertEqual(player.time,0.2,accuracy:0.001)
|
||||
player.endScrubbing()
|
||||
XCTAssertFalse(player.audioPlaybackStarted);XCTAssertFalse(player.playing)
|
||||
XCTAssertEqual(player.time,0.2,accuracy:0.001)
|
||||
player.scrub(to:0.4)
|
||||
try await Task.sleep(nanoseconds:180_000_000)
|
||||
XCTAssertFalse(player.audioPlaybackStarted,"Preview must stop if dragging stops")
|
||||
player.toggle()
|
||||
player.scrub(to:0.6)
|
||||
XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
|
||||
player.endScrubbing()
|
||||
XCTAssertTrue(player.playing,"Releasing a playing deck must keep it playing")
|
||||
}
|
||||
|
||||
@MainActor func testLoadingSecondDJDeckKeepsFirstDeckRendering() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let system = DJSystem(storageURL:root)
|
||||
defer { system.a.unload();system.b.unload() }
|
||||
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
|
||||
system.load(song,into:"A");system.a.player.toggle()
|
||||
try await Task.sleep(nanoseconds:100_000_000)
|
||||
let before = system.a.player.displayTime
|
||||
for _ in 0..<3 {
|
||||
system.load(song,into:"B")
|
||||
XCTAssertTrue(system.a.player.playing);XCTAssertTrue(system.a.player.audioPlaybackStarted)
|
||||
XCTAssertFalse(system.b.player.playing)
|
||||
try await Task.sleep(nanoseconds:50_000_000)
|
||||
}
|
||||
XCTAssertGreaterThan(system.a.player.displayTime,before)
|
||||
XCTAssertNil(system.error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DJ workstation — feature/dj_workstation
|
||||
|
||||
The main window has two tabs: **Karaoke Studio** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ library and queue fill the remaining window height. The two bottom status messages and the DJ Settings, Audio Routing and MIDI header buttons have been removed. Settings… (Command-comma) stays available in either workspace and opens the matching Karaoke or DJ scope; choose Devices or MIDI there. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope.
|
||||
The main window has two tabs: **Karaoke Studio** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ library fills the remaining window height. The DJ Queue button opens the queue and Automix controls in a modal, with a live waiting-track count. The two bottom status messages and the DJ Settings, Audio Routing and MIDI header buttons have been removed. Settings… (Command-comma) stays available in either workspace and opens the matching Karaoke or DJ scope; choose Devices or MIDI there. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope.
|
||||
|
||||
## Using the decks
|
||||
|
||||
@@ -79,3 +79,11 @@ Spectrum scrolling caches source-aligned image sections and translates them by f
|
||||
DJ imports show a live progress panel with a coloured progress bar, imported/skipped/remaining totals, the current file and a selectable, copyable log. DJ imports, including Finder drops, offer Run in Background; View Progress reopens the panel, and View Log shows the completed report. Scanning, hashing, copying and metadata reads run on a utility background worker with a separate database connection; interface progress updates are throttled to about ten per second. Cancelling retains completed tracks. Duplicate detection reuses existing track IDs, including identical files renamed or selected from another folder; a repeated drop can still load that track into either deck. Different songs are never considered duplicates solely because their titles match.
|
||||
|
||||
Karaoke Studio imports run in the background without the DJ import modal; import failures use the existing error alert.
|
||||
|
||||
New tracks imported into either studio also receive saved waveforms on the import worker. DJ decks reuse the DJ waveform cache. Waveform failures leave the imported track available and add a warning to its import log. Karaoke waveform batch jobs show a coloured progress bar and percentage ring, with generated/reused/skipped/issue counts and cancellation.
|
||||
|
||||
Each deck has a vertical channel-level fader beside its EQ/filter controls. The DJ library search uses the same compact rounded field as Karaoke Studio.
|
||||
|
||||
DJ library rows include the same gold favourite star as Karaoke Studio. Favourites are saved independently in the DJ library, with a Favourites filter and a right-click toggle.
|
||||
|
||||
Dragging a DJ waveform previews short audio grains through that deck’s EQ, level and output routing while paused; releasing stops the preview and retains the selected position. Scrubbing a playing deck continues playback. This is audio preview seeking, rather than reverse vinyl scratching.
|
||||
|
||||
Reference in New Issue
Block a user