191 lines
12 KiB
Swift
191 lines
12 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
@MainActor final class DJAutomix: ObservableObject {
|
|
struct Entry: Codable, Identifiable, Equatable {
|
|
var id = UUID()
|
|
var song: Int64
|
|
var title: String
|
|
var artist: String
|
|
}
|
|
private struct Saved: Codable { var entries: [Entry];var seconds: Double;var matchTempo: Bool }
|
|
private struct Prepared { var entry: Entry;var deck: String }
|
|
private struct Fade { var from: String;var to: String;var start: Double;var duration: Double }
|
|
@Published private(set) var entries = [Entry]()
|
|
@Published private(set) var enabled = false
|
|
@Published private(set) var activeDeck: String?
|
|
@Published private(set) var progress: Double?
|
|
@Published private(set) var message = "Add tracks from the DJ library"
|
|
@Published var seconds: Double = 8 { didSet { save() } }
|
|
@Published var matchTempo = false { didSet { save() } }
|
|
private weak var system: DJSystem?
|
|
private let file: URL
|
|
private var prepared: Prepared?
|
|
private var fade: Fade?
|
|
private var task: Task<Void,Never>?
|
|
private var expectedFader: Double = 0
|
|
init(system: DJSystem) {
|
|
self.system = system;file = system.root.appendingPathComponent("DJQueue.json")
|
|
if let data = try? Data(contentsOf:file),let saved = try? JSONDecoder().decode(Saved.self,from:data) {
|
|
entries = Array(saved.entries.prefix(10000));seconds = saved.seconds.isFinite ? min(30,max(0,saved.seconds)) : 8;matchTempo = saved.matchTempo
|
|
}
|
|
}
|
|
deinit { task?.cancel() }
|
|
private func save() {
|
|
do { try JSONEncoder().encode(Saved(entries:entries,seconds:seconds,matchTempo:matchTempo)).write(to:file,options:.atomic) }
|
|
catch { message = "Queue could not be saved: \(error.localizedDescription)" }
|
|
}
|
|
func enqueue(_ songs: [Song]) {
|
|
entries += songs.prefix(max(0,10000-entries.count)).map { Entry(song:$0.id,title:$0.title,artist:$0.artist) };save()
|
|
}
|
|
func remove(_ id: UUID) { entries.removeAll{$0.id == id};save() }
|
|
func clear() { entries = [];save() }
|
|
func removeSongs(_ ids: Set<Int64>) { entries.removeAll{ids.contains($0.song)};save() }
|
|
func shuffle() { entries.shuffle();save() }
|
|
func move(_ id: UUID,by offset: Int) {
|
|
guard let index = entries.firstIndex(where:{$0.id == id}) else { return }
|
|
let target = min(entries.count-1,max(0,index+offset));guard target != index else { return }
|
|
let entry = entries.remove(at:index);entries.insert(entry,at:target);save()
|
|
}
|
|
func stop(_ reason: String = "Automix off · playback remains under manual control") {
|
|
enabled = false;task?.cancel();task = nil;fade = nil;prepared = nil;progress = nil;activeDeck = nil;message = reason
|
|
}
|
|
func manualTakeover() { if enabled { stop("Automix off · manual control") } }
|
|
func start(runTimer: Bool = true) {
|
|
guard let system,!enabled else { return }
|
|
guard !system.importing else { message = "Wait for the current DJ import to finish";return }
|
|
guard system.routing.mode == "Internal" else { message = "Automix requires Internal mixer mode";return }
|
|
let playing = [system.a,system.b].filter{$0.player.playing}
|
|
guard playing.count <= 1 else { message = "Pause one deck before starting Automix";return }
|
|
system.disableSync()
|
|
if let deck = playing.first { activeDeck = deck.letter }
|
|
else {
|
|
guard !entries.isEmpty else { message = "Add tracks to the DJ queue first";return }
|
|
activeDeck = "A";setFader(0)
|
|
guard let next = prepare(on:"A") else { activeDeck = nil;return }
|
|
entries.removeAll{$0.id == next.entry.id};save();system.a.player.toggle()
|
|
}
|
|
guard let activeDeck else { return }
|
|
setFader(activeDeck == "A" ? 0 : 1);enabled = true;message = "Automix on · deck \(activeDeck)"
|
|
_ = prepare(on:activeDeck == "A" ? "B" : "A")
|
|
if runTimer {
|
|
task = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
do { try await Task.sleep(nanoseconds:50_000_000) } catch { return }
|
|
self?.tick()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private func setFader(_ value: Double) { expectedFader = value;system?.crossfader = value }
|
|
private func prepare(on letter: String) -> Prepared? {
|
|
guard let system,!system.channel(letter).player.playing else { return nil }
|
|
while let entry = entries.first {
|
|
guard let song = system.songs.first(where:{$0.id == entry.song}) else { entries.removeFirst();save();message = "Skipped unavailable track: \(entry.title)";continue }
|
|
system.load(song,into:letter,automated:true)
|
|
guard system.channel(letter).player.currentSongID == song.id else {
|
|
entries.removeFirst();save();message = "Skipped unreadable track: \(entry.title)";continue
|
|
}
|
|
let next = Prepared(entry:entry,deck:letter);prepared = next;return next
|
|
}
|
|
prepared = nil;return nil
|
|
}
|
|
func mixNow(now: Double = ProcessInfo.processInfo.systemUptime) {
|
|
guard enabled,fade == nil,let system,let activeDeck else { return }
|
|
if prepared?.entry.id != entries.first?.id { prepared = nil }
|
|
if prepared == nil { _ = prepare(on:activeDeck == "A" ? "B" : "A") }
|
|
guard let next = prepared else { message = "No next track queued";return }
|
|
let outgoing = system.channel(activeDeck),incoming = system.channel(next.deck)
|
|
guard incoming.player.currentSongID == next.entry.song,!incoming.player.playing else { stop("Automix off · deck changed manually");return }
|
|
if matchTempo,outgoing.bpmConfirmed,incoming.bpmConfirmed {
|
|
let rate = outgoing.bpm*outgoing.player.tempo/incoming.bpm,range = system.settings.tempoRange/100
|
|
if rate.isFinite,(1-range...1+range).contains(rate) { incoming.player.tempo = rate }
|
|
}
|
|
let remaining = max(0,(outgoing.player.duration-outgoing.player.time)/max(0.5,outgoing.player.tempo))
|
|
let duration = min(max(0,seconds),remaining,incoming.player.duration/max(0.5,incoming.player.tempo)/2)
|
|
entries.removeAll{$0.id == next.entry.id};save();prepared = nil
|
|
incoming.player.toggle()
|
|
fade = Fade(from:activeDeck,to:next.deck,start:now,duration:duration);progress = 0
|
|
message = "Mixing \(activeDeck) → \(next.deck)"
|
|
if duration == 0 { finishFade() }
|
|
}
|
|
func tick(now: Double = ProcessInfo.processInfo.systemUptime) {
|
|
guard enabled,let system,let activeDeck else { return }
|
|
guard system.routing.mode == "Internal" else { stop("Automix off · external routing selected");return }
|
|
guard abs(system.crossfader-expectedFader) < 0.03 else { stop("Automix off · crossfader moved manually");return }
|
|
if let fade {
|
|
guard system.channel(fade.to).player.playing else { stop("Automix off · incoming deck paused");return }
|
|
let amount = min(1,max(0,(now-fade.start)/max(0.001,fade.duration)))
|
|
progress = amount;setFader(fade.from == "A" ? amount : 1-amount)
|
|
if amount >= 1 { finishFade() };return
|
|
}
|
|
let player = system.channel(activeDeck).player
|
|
if player.loopEnabled { message = "Loop active · exit the loop to continue Automix";return }
|
|
if !player.playing {
|
|
if player.time >= player.duration-0.05 {
|
|
if entries.isEmpty { stop("Queue finished");return }
|
|
mixNow(now:now)
|
|
} else { stop("Automix off · active deck paused") }
|
|
return
|
|
}
|
|
if prepared?.entry.id != entries.first?.id { prepared = nil }
|
|
let idle = activeDeck == "A" ? "B" : "A"
|
|
if prepared == nil { _ = prepare(on:idle) }
|
|
if let prepared,system.channel(prepared.deck).player.playing { stop("Automix off · second deck started manually");return }
|
|
let remaining = max(0,(player.duration-player.time)/max(0.5,player.tempo))
|
|
message = entries.isEmpty ? "Last track · queue finishes at the end" : "Next transition in \(Int(max(0,remaining-max(0,seconds)))) s"
|
|
if !entries.isEmpty,remaining <= max(0.05,seconds) { mixNow(now:now) }
|
|
}
|
|
private func finishFade() {
|
|
guard let fade,let system else { return }
|
|
setFader(fade.to == "A" ? 0 : 1);system.channel(fade.from).player.stopDeck()
|
|
activeDeck = fade.to;self.fade = nil;progress = nil
|
|
message = "Automix on · deck \(fade.to)"
|
|
_ = prepare(on:fade.from)
|
|
}
|
|
}
|
|
|
|
struct DJQueuePane: View {
|
|
@ObservedObject var system: DJSystem
|
|
@ObservedObject var automix: DJAutomix
|
|
var body: some View {
|
|
VStack(alignment:.leading,spacing:8) {
|
|
HStack { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) }
|
|
HStack {
|
|
Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor).help("Start or stop automatic transitions through the queued tracks")
|
|
Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil).help("Transition immediately to the next queued track using the selected fade duration")
|
|
}.controlSize(.small)
|
|
HStack {
|
|
Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1).help("Duration in seconds for each automatic crossfade");Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34)
|
|
}.font(.caption)
|
|
Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption).help("Match confirmed track tempos before the automatic transition")
|
|
if let progress = automix.progress { ProgressView(value:progress) }
|
|
List {
|
|
ForEach(automix.entries) { entry in
|
|
HStack(spacing:5) {
|
|
VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
|
|
Spacer(minLength:0)
|
|
Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier").help("Move this queued track one place earlier")
|
|
Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later").help("Move this queued track one place later")
|
|
Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue").help("Remove this track from the queue without deleting it from the library")
|
|
}.font(.caption).buttonStyle(.borderless)
|
|
}
|
|
if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) }
|
|
}.listStyle(.inset).scrollContentBackground(.hidden)
|
|
.onDrop(of:["public.text"],isTargeted:nil) { providers in
|
|
guard let provider = providers.first else { return false }
|
|
Task { @MainActor in
|
|
do {
|
|
let reference = try await SongDragReference.load(provider)
|
|
guard reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return }
|
|
automix.enqueue([song])
|
|
} catch { system.error = error.localizedDescription }
|
|
}
|
|
return true
|
|
}
|
|
HStack { Button("Shuffle") { automix.shuffle() }.help("Randomise the order of the remaining queued tracks");Button("Clear") { automix.clear() }.help("Remove all queued entries; library tracks are kept");Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty)
|
|
Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
|
|
}.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6))
|
|
}
|
|
}
|