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()
|
||||
|
||||
Reference in New Issue
Block a user