Files

698 lines
54 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AVFoundation
import AppKit
struct StudioSettings: Codable, Equatable {
var autoAdvance = true
var repeatTrack = false
var graphicsOffset: Double = 0
var jogSecondsPerTurn: Double = 2
var nudgeSeconds: Double = 0.05
var backgroundPath: String?
var backgroundBookmark: Data?
var backgroundTrim: Double = 0.3
var backgroundLoop = true
var backgroundFadeIn: Double? = 0
var backgroundFadeOut: Double? = 0
var automaticBackground: Bool? = true
var backgroundAutoEnabled: Bool { automaticBackground ?? true }
var serverPort = 8080
var sanitized: Self {
var value = self
value.graphicsOffset = graphicsOffset.isFinite ? min(5,max(-5,graphicsOffset)) : 0
value.jogSecondsPerTurn = jogSecondsPerTurn.isFinite ? min(20,max(0.1,jogSecondsPerTurn)) : 2
value.nudgeSeconds = nudgeSeconds.isFinite ? min(2,max(0.005,nudgeSeconds)) : 0.05
value.backgroundTrim = backgroundTrim.isFinite ? min(1,max(0,backgroundTrim)) : 0.3
value.backgroundFadeIn = (backgroundFadeIn ?? 0).isFinite ? min(15,max(0,backgroundFadeIn ?? 0)) : 0
value.backgroundFadeOut = (backgroundFadeOut ?? 0).isFinite ? min(15,max(0,backgroundFadeOut ?? 0)) : 0
value.serverPort = min(65535,max(1024,serverPort));return value
}
}
/// Independent background audio deck. Singer playback always pauses it before the main node starts.
@MainActor final class BackgroundDeck: ObservableObject {
@Published private(set) var title = "No background track"
@Published private(set) var playing = false
@Published private(set) var time: Double = 0
@Published private(set) var duration: Double = 0
@Published private(set) var interruptedBySinger = false
@Published private(set) var status = "Choose an audio file"
var trim: Double = 0.3 { didSet { applyVolume() } }
var fadeInSeconds: Double = 0
var fadeOutSeconds: Double = 0
private var fadeTask: Task<Void,Never>?
private var fadeLevel: Double = 1
private var singerFadeDeadline: Double?
var repeating = true
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
private let eq = AVAudioUnitEQ(numberOfBands:4)
private(set) var mix = Player.GlobalMix(volume:0.8,low:0,mid:0,high:0,filter:0,enabled:true)
private var file: AVAudioFile?
private var accessURL: URL?
private var scoped = false
private var generation = UUID()
private var anchor: Double = 0
private var timer: Timer?
var loaded: Bool { file != nil }
var outputGain: Float { engine.mainMixerNode.outputVolume }
init() { engine.attach(node);engine.attach(eq) }
func load(_ url: URL,title: String? = nil) throws {
unload()
scoped = url.startAccessingSecurityScopedResource();accessURL = url
do {
let file = try AVAudioFile(forReading:url)
guard file.length > 0 else { throw LibraryError.message("Background file is empty") }
self.file = file;duration = Double(file.length)/file.processingFormat.sampleRate
self.title = title ?? url.deletingPathExtension().lastPathComponent
engine.connect(node,to:eq,format:file.processingFormat);engine.connect(eq,to:engine.mainMixerNode,format:file.processingFormat)
apply(mix:mix);schedule(at:0);status = "Ready"
} catch { unload();status = "Unavailable: "+error.localizedDescription;throw error }
}
func apply(mix: Player.GlobalMix) {
self.mix = mix;applyVolume()
AudioTone.configure(eq,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:mix.low,mid:mix.mid,high:mix.high,filter:mix.filter,enabled:mix.enabled)
}
private func applyVolume() { engine.mainMixerNode.outputVolume = Float(min(1,max(0,mix.volume*trim*fadeLevel))) }
private func schedule(at seconds: Double) {
guard let file else { return };generation = UUID();let token = generation
node.stop();let rate = file.processingFormat.sampleRate,start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate;time = anchor
guard start < file.length else { return }
node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in
Task { @MainActor in
guard let self,self.generation == token else { return }
if self.repeating && self.playing { self.schedule(at:0);self.node.play() }
else { self.playing = false;self.time = self.duration;self.status = "Finished · Press Play to restart";self.timer?.invalidate();self.timer = nil }
}
}
}
private func cancelFade() { fadeTask?.cancel();fadeTask = nil;singerFadeDeadline = nil }
private func fade(to target: Double,seconds: Double,completion: (() -> Void)? = nil) {
cancelFade()
guard seconds.isFinite,seconds > 0 else { fadeLevel = target;applyVolume();completion?();return }
let initial = fadeLevel,began = ProcessInfo.processInfo.systemUptime
fadeTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }
let fraction = min(1,(ProcessInfo.processInfo.systemUptime-began)/seconds)
self.fadeLevel = initial+(target-initial)*fraction;self.applyVolume()
if fraction >= 1 { self.fadeTask = nil;completion?();return }
do { try await Task.sleep(nanoseconds:16_000_000) } catch { return }
}
}
}
func fadeOut(stop: Bool) {
guard playing || node.isPlaying else { if stop { self.stop() };return }
tick();playing = false;timer?.invalidate();timer = nil
if stop { time = 0 }
status = "Fading out"
fade(to:0,seconds:fadeOutSeconds) { [weak self] in
guard let self else { return }
if stop { self.stop() } else { self.pause() }
}
}
func start() throws {
guard file != nil else { throw LibraryError.message("Choose a background track first") }
if time >= duration { schedule(at:0) }
if !engine.isRunning { engine.prepare();try engine.start() }
cancelFade();if !node.isPlaying { fadeLevel = fadeInSeconds > 0 ? 0 : 1;applyVolume() }
node.play();playing = true;interruptedBySinger = false;status = "Background music playing"
fade(to:1,seconds:fadeInSeconds)
timer?.invalidate();timer = Timer(timeInterval:0.1,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } }
if let timer { RunLoop.main.add(timer,forMode:.common) }
}
func seek(_ seconds: Double) {
guard loaded,seconds.isFinite else { return }
let resume = playing,target = min(duration,max(0,seconds))
cancelFade();fadeLevel = resume ? 1 : 0;applyVolume()
status = resume ? "Background music playing" : (interruptedBySinger ? "Paused for singer" : "Ready at selected position")
schedule(at:target)
if resume,target < duration { node.play() }
else if target >= duration {
playing = false;timer?.invalidate();timer = nil;status = "At end · Press Play to restart"
}
}
private func tick() {
if node.isPlaying,let file,let render = node.lastRenderTime,let played = node.playerTime(forNodeTime:render) { time = min(duration,anchor+Double(played.sampleTime)/file.processingFormat.sampleRate) }
}
/// Return the remaining audible fade time; the singer node waits before starting.
func prepareForSinger() -> Double {
if let deadline = singerFadeDeadline { return max(0,deadline-ProcessInfo.processInfo.systemUptime) }
guard playing || node.isPlaying else { return 0 }
let seconds = fadeOutSeconds.isFinite ? min(15,max(0,fadeOutSeconds)) : 0
guard seconds > 0 else { pauseForSinger();return 0 }
tick();playing = false;interruptedBySinger = true;timer?.invalidate();timer = nil
status = "Fading out for singer"
fade(to:0,seconds:seconds) { [weak self] in self?.pauseForSinger() }
singerFadeDeadline = ProcessInfo.processInfo.systemUptime+seconds
return seconds
}
func pauseForSinger() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = true;status = "Paused for singer";timer?.invalidate();timer = nil }
func pause() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = false;status = "Paused · Press Play to resume";timer?.invalidate();timer = nil }
func stop() { cancelFade();fadeLevel = 0;applyVolume();node.stop();playing = false;interruptedBySinger = false;timer?.invalidate();timer = nil;schedule(at:0);status = file == nil ? "Choose an audio file" : "Stopped · Ready" }
func unload() {
cancelFade();fadeLevel = 0;generation = UUID();node.stop();engine.stop();timer?.invalidate();timer = nil
engine.disconnectNodeOutput(node);engine.disconnectNodeOutput(eq);file = nil
if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() };scoped = false;accessURL = nil
title = "No background track";playing = false;interruptedBySinger = false;time = 0;duration = 0;status = "Choose an audio file"
}
deinit { fadeTask?.cancel();timer?.invalidate();if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() } }
}
@MainActor extension AppModel {
func applyStudioSettings(save: Bool,previous: StudioSettings? = nil) {
let value = studio.sanitized
if previous == nil || previous?.autoAdvance != value.autoAdvance { autoAdvance = value.autoAdvance }
if previous == nil || previous?.repeatTrack != value.repeatTrack { player.repeating = value.repeatTrack }
if previous == nil || previous?.graphicsOffset != value.graphicsOffset { player.graphicsOffset = value.graphicsOffset }
player.jogSecondsPerTurn = value.jogSecondsPerTurn;player.nudgeStep = value.nudgeSeconds
backgroundDeck.fadeInSeconds = value.backgroundFadeIn ?? 0;backgroundDeck.fadeOutSeconds = value.backgroundFadeOut ?? 0
backgroundDeck.trim = value.backgroundTrim;backgroundDeck.repeating = value.backgroundLoop;backgroundDeck.apply(mix:player.globalMix)
if save,let data = try? JSONEncoder().encode(value) { do { try data.write(to:root.appendingPathComponent("StudioSettings.json"),options:.atomic) } catch { self.error = error.localizedDescription } }
if previous == nil || previous?.backgroundPath != value.backgroundPath || previous?.backgroundBookmark != value.backgroundBookmark { restoreBackground() }
if previous != nil { scheduleBackgroundHandoff() }
}
func restoreBackground() {
guard let path = studio.backgroundPath else { backgroundDeck.unload();return }
var url = URL(fileURLWithPath:path)
if let bookmark = studio.backgroundBookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:.withSecurityScope,relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved } }
do { try backgroundDeck.load(url) } catch { /* The deck shows its unavailable state without interrupting navigation. */ }
}
func chooseBackground() {
let panel = NSOpenPanel();panel.title = "Choose Background Music";panel.allowedContentTypes = [.audio];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false
guard panel.runModal() == .OK,let url = panel.url else { return }
var next = studio;next.backgroundPath = url.path
next.backgroundBookmark = try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil);studio = next
}
func scheduleBackgroundHandoff() {
guard backgroundAutomationReady else { return }
backgroundHandoff?.cancel()
backgroundHandoff = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds:250_000_000) } catch { return }
guard let self,self.studio.backgroundAutoEnabled,self.backgroundDeck.loaded,
!self.backgroundDeck.playing,(!self.player.playing || self.player.isShowScreen) else { return }
self.perform { try self.backgroundDeck.start() }
}
}
func pauseBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:false) }
func stopBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:true) }
func playBackground() {
guard !player.playing || player.isShowScreen else { error = "Stop or pause the singer deck before starting background music";return }
perform { try backgroundDeck.start() }
}
func placeSinger(_ singer: Int64,before: Int64?) { perform { guard let currentVenue,let library else { return };try library.placeRotation(singer:singer,venue:currentVenue,before:before);try refresh() } }
}
struct BackgroundDeckStrip: View {
@ObservedObject var model: AppModel
@ObservedObject var deck: BackgroundDeck
private func stamp(_ seconds: Double) -> String { let value = Int(max(0,seconds));return String(format:"%d:%02d",value/60,value%60) }
var body: some View {
HStack(spacing:10) {
Image(systemName:"music.note.list").foregroundStyle(deck.playing ? .green : .secondary)
VStack(alignment:.leading,spacing:1) {
Text("BACKGROUND MUSIC").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary)
Text(deck.loaded ? deck.title : "Choose background music").font(.system(size:10,weight:.medium)).lineLimit(1).help(deck.title+" · "+deck.status)
}.frame(width:175,alignment:.leading)
if deck.loaded {
Text(stamp(deck.time)).font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary)
Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.mini).accessibilityLabel("Background track position").frame(minWidth:70)
Text(stamp(deck.duration)).font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary)
} else { Spacer(minLength:0) }
Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).font(.system(size:10)).help("Start between singers and pause before singer audio")
Button("Choose…") { model.chooseBackground() }
Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill") }.disabled(!deck.loaded).accessibilityLabel("Background play pause")
Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).accessibilityLabel("Stop background music")
Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:65).accessibilityLabel("Background music level")
Text("\(Int(model.studio.backgroundTrim*100))%").font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary).frame(width:26)
}.controlSize(.mini).padding(.horizontal,10).padding(.vertical,6).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:6))
}
}
struct DJDeckControls: View {
@ObservedObject var player: Player
@State private var angle: Double?
@State private var resume = false
@State private var song: Int64?
private func stamp(_ value: Double) -> String { String(format:"%d:%05.2f",Int(max(0,value))/60,max(0,value).truncatingRemainder(dividingBy:60)) }
var body: some View {
HStack(spacing:7) {
ZStack {
Circle().fill(Color.white.opacity(0.035));Circle().strokeBorder(Color.white.opacity(0.16),lineWidth:1)
Circle().trim(from:0.05,to:0.95).stroke(Color.accentColor.opacity(0.65),style:StrokeStyle(lineWidth:2,lineCap:.round)).padding(5)
Text("JOG").font(.system(size:8,weight:.bold)).foregroundStyle(.secondary)
}.frame(width:43,height:43).contentShape(Circle()).gesture(DragGesture(minimumDistance:0).onChanged { event in
guard player.canJog else { return };let next = atan2(event.location.y-21.5,event.location.x-21.5)
if let angle { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta/(2 * .pi)*player.jogSecondsPerTurn) }
else { resume = player.playing;song = player.currentSongID;if player.playing { player.toggle() } }
angle = next
}.onEnded { _ in angle = nil;if resume,player.currentSongID == song,!player.playing { player.toggle() };resume = false })
.help("Drag around the jog wheel for precise positioning. Playback resumes on release.").accessibilityLabel("Jog wheel").accessibilityAdjustableAction { player.jog($0 == .increment ? player.nudgeStep : -player.nudgeStep) }
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill") }.help("Play / pause main deck")
Button { player.jog(-player.nudgeStep) } label: { Image(systemName:"backward.frame") }.help("Nudge backward")
Button { player.jog(player.nudgeStep) } label: { Image(systemName:"forward.frame") }.help("Nudge forward")
Divider().frame(height:24)
VStack(spacing:2) { HStack(spacing:4) { Button("Cue") { player.returnToCue() }.help("Pause and return to cue");Button { player.setCue() } label: { Image(systemName:"flag.fill") }.help("Set cue at current position") };Text(stamp(player.cuePosition)).font(.system(size:8,design:.monospaced)).foregroundStyle(.secondary) }
HStack(spacing:3) { ForEach(0..<4) { index in Button("\(index+1)") { player.hotCue(index) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : Color.accentColor).help(player.hotCues[index] == nil ? "Set hot cue \(index+1)" : "Jump to hot cue \(index+1)")
.contextMenu { Button("Set Here") { player.hotCue(index,clear:true);player.hotCue(index) };Button("Clear") { player.hotCue(index,clear:true) } }
} }
Spacer(minLength:0)
Button("In") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? Color.primary : Color.accentColor).help("Set loop start")
Button("Out") { player.markLoopOut() }.disabled(player.loopIn == nil).help("Set loop end and enable loop")
Button { player.loopEnabled.toggle() } label: { Image(systemName:"repeat") }.disabled(player.loopOut == nil).foregroundStyle(player.loopEnabled ? Color.accentColor : Color.secondary).help("Toggle A/B loop").contextMenu { Button("Clear Loop") { player.clearLoop() } }
Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.help("Stop immediately, keep track and waveform loaded")
Button { player.fadeOutAndStop() } label: { Image(systemName:"speaker.slash") }.help("Fade out and unload")
}.buttonStyle(.borderless).controlSize(.small).font(.system(size:10,weight:.medium)).disabled(!player.canJog)
}
}
struct StudioSettingsPane: View {
var showHeading = true
var section: String? = nil
@AppStorage("studioAppearance") private var appearance = "system"
@ObservedObject var model: AppModel
@State private var port = ""
var body: some View {
ScrollView {
VStack(alignment:.leading,spacing:22) {
if showHeading { HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } }
if section == nil || section == "Appearance" {
PreferencesSection("Appearance") {
HStack {
Picker("Appearance",selection:$appearance) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}.pickerStyle(.segmented).frame(maxWidth:360)
Spacer()
}.padding(10)
Text("System follows your Mac. The waveform and video output remain dark for clear monitoring.").font(.caption).foregroundStyle(.secondary).padding([.horizontal,.bottom],10)
}
}
if section == nil || section == "General" {
PreferencesSection("Playback & Jog") {
VStack(alignment:.leading,spacing:12) {
Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance)
Toggle("Repeat on startup",isOn:$model.studio.repeatTrack)
HStack { Text("Jog sensitivity");Slider(value:$model.studio.jogSecondsPerTurn,in:0.25...10,step:0.25);Text(String(format:"%.2fs / turn",model.studio.jogSecondsPerTurn)).monospacedDigit().frame(width:100) }
Picker("Nudge step",selection:$model.studio.nudgeSeconds) { Text("10 ms").tag(0.01);Text("50 ms").tag(0.05);Text("100 ms").tag(0.1);Text("500 ms").tag(0.5) }.frame(maxWidth:320)
HStack { Text("Video / CDG offset");Slider(value:$model.studio.graphicsOffset,in:-2...2,step:0.05);Text(String(format:"%+.2fs",model.studio.graphicsOffset)).monospacedDigit().frame(width:65) }
Text("Option-scroll over the waveform to jog. Cue, hot cues and loops belong to the loaded deck and reset on track change.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
if section == nil || section == "General" {
PreferencesSection("Hosting · \(model.currentVenueName)") {
VStack(alignment:.leading,spacing:12) {
Toggle("Fade song endings",isOn:Binding(get:{model.hostSettings.autoFade},set:{model.hostSettings.autoFade = $0;model.saveHostSettings()}))
Stepper("Fade duration: \(Int(model.hostSettings.fadeSeconds)) seconds",value:Binding(get:{model.hostSettings.fadeSeconds},set:{model.hostSettings.fadeSeconds = $0;model.saveHostSettings()}),in:1...15,step:1)
Picker("Between singers",selection:Binding(get:{model.hostSettings.intermission},set:{model.hostSettings.intermission = $0;model.saveHostSettings()})) { Text("No automatic slide").tag(nil as Int64?);ForEach(model.showScreens) { Text($0.title).tag(Optional($0.id)) } }
}.padding(10)
}.disabled(model.currentVenue == nil)
}
if section == nil || section == "Sound" {
PreferencesSection("Background Music") {
VStack(alignment:.leading,spacing:12) {
HStack { Text(model.studio.backgroundPath.map{URL(fileURLWithPath:$0).lastPathComponent} ?? "No track selected").lineLimit(1);Spacer();Button("Choose Track…") { model.chooseBackground() };Button("Clear") { model.studio.backgroundPath = nil;model.studio.backgroundBookmark = nil }.disabled(model.studio.backgroundPath == nil) }
Toggle("Automatically play background music between singers",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0}))
Toggle("Loop background track",isOn:$model.studio.backgroundLoop)
HStack { Text("Fade in");Slider(value:Binding(get:{model.studio.backgroundFadeIn ?? 0},set:{model.studio.backgroundFadeIn = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeIn ?? 0)).monospacedDigit().frame(width:65) }
HStack { Text("Fade out");Slider(value:Binding(get:{model.studio.backgroundFadeOut ?? 0},set:{model.studio.backgroundFadeOut = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeOut ?? 0)).monospacedDigit().frame(width:65) }
Text("Play uses Fade in; background Pause and Stop use Fade out. Zero disables a fade. Singer audio waits for the background fade-out to finish.").font(.caption).foregroundStyle(.secondary)
HStack { Text("Background level");Slider(value:$model.studio.backgroundTrim,in:0...1);Text("\(Int(model.studio.backgroundTrim*100))%").monospacedDigit() }
Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Karaoke master and EQ apply to singer and background audio; DJ has separate controls.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
if section == nil || section == "Sound" {
GlobalMixerSettings(player:model.player)
}
if section == nil || section == "Library" {
PreferencesSection("Imported Media Storage") {
VStack(alignment:.leading,spacing:12) {
Label(model.managedMediaDirectory.path,systemImage:"externaldrive").font(.callout).textSelection(.enabled).lineLimit(3)
HStack {
Button("Choose Import Folder…") { model.chooseMediaStorage() }
Button("Move Existing Media…") { model.chooseMediaStorage(moveExisting:true) }
Button("Show in Finder") { NSWorkspace.shared.open(model.managedMediaDirectory) }
}.disabled(model.isImporting || model.movingMedia || model.scanningSource != nil || model.generatingWaveforms)
Text("Choose changes where new imports are stored. Move copies and verifies existing imported audio and CDG files, updates library links, then removes the originals to free space. Keep the external drive connected while importing or playing.").font(.caption).foregroundStyle(.secondary)
Text("The small library database, singer lists, settings and waveform cache stay on this Mac.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
if section == nil || section == "Advanced" {
PreferencesSection("Singer Web & Library") {
VStack(alignment:.leading,spacing:12) {
HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } }
Text("Used next time you start Singer Web. The server remains off until started explicitly.").font(.caption).foregroundStyle(.secondary)
HStack { Button("Add Drive / Folder…") { model.chooseMediaLocation() };Button("Show Library Data") { NSWorkspace.shared.open(model.root) } }
Text("Audio output follows the Mac’s current sound output. Configure hardware output in macOS Sound settings.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
}.padding(22)
}.background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(model.studio.serverPort) }
}
}
private struct GlobalMixerSettings: View {
@ObservedObject var player: Player
var body: some View {
GroupBox("Global Master & EQ") {
VStack(alignment:.leading,spacing:10) {
Toggle("Enable EQ",isOn:$player.toneEnabled)
HStack { Text("Master").frame(width:45,alignment:.leading);Slider(value:$player.volume,in:0...1);Text("\(Int(player.volume*100))%").monospacedDigit().frame(width:45) }
HStack { Text("Low").frame(width:45,alignment:.leading);Slider(value:$player.lowGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.lowGain)).monospacedDigit().frame(width:60) }
HStack { Text("Mid").frame(width:45,alignment:.leading);Slider(value:$player.midGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.midGain)).monospacedDigit().frame(width:60) }
HStack { Text("High").frame(width:45,alignment:.leading);Slider(value:$player.highGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.highGain)).monospacedDigit().frame(width:60) }
}.padding(10)
}
}
}
struct WaveformJogWheel: NSViewRepresentable {
let player: Player
func makeNSView(context: Context) -> WheelView { let view = WheelView();view.player = player;view.install();return view }
func updateNSView(_ view: WheelView,context: Context) { view.player = player }
static func dismantleNSView(_ view: WheelView,coordinator: ()) { view.remove() }
final class WheelView: NSView {
weak var player: Player?
private var monitor: Any?
override func hitTest(_ point: NSPoint) -> NSView? { nil }
func install() {
monitor = NSEvent.addLocalMonitorForEvents(matching:.scrollWheel) { [weak self] event in
guard let self,event.window === self.window,self.window != nil,event.modifierFlags.contains(.option),self.bounds.contains(self.convert(event.locationInWindow,from:nil)),let player = self.player,player.canJog else { return event }
player.jog(-Double(event.scrollingDeltaY)/120*player.jogSecondsPerTurn);return nil
}
}
func remove() { if let monitor { NSEvent.removeMonitor(monitor) };monitor = nil }
deinit { if let monitor { NSEvent.removeMonitor(monitor) } }
}
}
struct RotationDrag: Codable {
var session: String;var venue: Int64;var singer: Int64
var text: String { "KJMix.rotation.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() }
static func parse(_ text: String) -> Self? { guard text.hasPrefix("KJMix.rotation.v1:"),let data = Data(base64Encoded:String(text.dropFirst("KJMix.rotation.v1:".count))) else { return nil };return try? JSONDecoder().decode(Self.self,from:data) }
}
@MainActor extension AppModel {
func rotationReference(_ singer: Int64) -> RotationDrag? { currentVenue.map{.init(session:rotationSession,venue:$0,singer:singer)} }
func applyRotationDrop(_ reference: RotationDrag,before: Int64?) throws {
guard reference.session == rotationSession,reference.venue == currentVenue,rotationSingers.contains(where:{$0.id == reference.singer}),let library else { throw LibraryError.message("Drag a singer from the current venue’s rotation") }
if let before,!rotationSingers.contains(where:{$0.id == before}) { try library.markHere(singer:before,venue:reference.venue,here:false) }
try library.placeRotation(singer:reference.singer,venue:reference.venue,before:before);try refresh();synchronizeManualSingerOrder();resetRound()
}
func synchronizeManualSingerOrder() {
guard !hostSettings.smart,let venue = currentVenue,let library else { return }
perform { try library.orderQueueByRoster(venue:venue);try refresh() }
}
func startRotation() {
guard currentVenue != nil else { error = "Choose a venue, then add singers and songs to Tonight.";return }
if rotationDeckSongID != nil,rotationDeckSongID == player.currentSongID,rotationDeckVenue == currentVenue,player.canJog,player.time < player.duration {
studio.autoAdvance = true
if !player.playing { player.toggle() };rotationPlaybackActive = true
return
}
guard !rotationPlan.isEmpty else {
error = queue.isEmpty ? "Add a song to a singer’s Tonight list using the song browser below." : "Mark a singer with pending songs Here, or select Manual Queue."
return
}
studio.autoAdvance = true;playQueueNext()
}
func pauseRotation() { if rotationPlaybackActive,player.playing { player.toggle() } }
func stopRotation() { studio.autoAdvance = false;rotationPlaybackActive = false;player.stopDeck() }
func reorderRotationSinger(_ singer: Int64,by offset: Int) {
perform {
guard let library,let venue = currentVenue else { return }
for person in venueSingers where !rotationSingers.contains(where:{$0.id == person.id}) { try library.markHere(singer:person.id,venue:venue,here:false) }
try library.moveRotation(singer:singer,venue:venue,by:offset);try refresh();synchronizeManualSingerOrder();resetRound()
}
}
func applyRosterDropText(_ text: String,target: Int64?) async throws {
if let reference = RotationDrag.parse(text) { try applyRotationDrop(reference,before:target) }
else if let target,let venue = currentVenue {
let reference = try await SongDragReference.load(NSItemProvider(object:text as NSString))
try placeDroppedSong(reference,singer:target,venue:venue,before:nil)
} else { throw LibraryError.message("Drop a singer here to reorder, or a library song onto a singer.") }
}
func acceptRosterDrop(_ providers: [NSItemProvider],target: Int64?) -> Bool {
let items = providers.filter{$0.canLoadObject(ofClass:NSString.self)}
guard !items.isEmpty else { return false }
for item in items {
_ = item.loadObject(ofClass:NSString.self) { [weak self] value,_ in
guard let text = value as? String else { return }
Task { @MainActor in
do { try await self?.applyRosterDropText(text,target:target) }
catch { self?.error = error.localizedDescription }
}
}
}
return true
}
func playPerformance(_ entry: Int64) {
perform { guard let row = queue.first(where:{$0.id == entry}),let song = songs.first(where:{$0.id == row.songID}),let library else { return };selectedSinger = row.singerID;playingIntermission = false;try play(song,singer:row.singerID);try library.removeQueue(row.id);try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue }
}
}
struct RotationDashboard: View {
@ObservedObject var model: AppModel
@State private var addingSongs = false
private var roster: [PersonPlace] {
let ids = model.rotationSingers.map(\.id)
return ids.compactMap{id in model.venueSingers.first{$0.id == id}}+model.venueSingers.filter{!ids.contains($0.id)}
}
private var selected: PersonPlace? { model.venueSingers.first{$0.id == model.selectedSinger} }
var body: some View {
VStack(spacing:14) {
HStack(spacing:12) {
Picker("Rotation mode",selection:$model.hostSettings.smart) { Text("Manual Queue").tag(false);Text("Round Robin").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:255)
Toggle("Auto advance",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox).font(.caption)
Spacer();Menu("Add Singer") { Button("New Singer…") { model.beginSinger() };ForEach(model.singers.filter{person in !model.venueSingers.contains{$0.id == person.id}}) { person in Button(person.name) { model.membership(singer:person.id,included:true);model.markHere(person.id,true);model.selectedSinger = person.id;addingSongs = true } } };Button("Add Songs…") { if model.selectedSinger == nil { model.selectedSinger = roster.first?.id };addingSongs = true }.disabled(roster.isEmpty)
}
RotationTransport(model:model,player:model.player)
HStack {
Text("Up next:").foregroundStyle(.secondary)
Text(model.rotationPlan.first.map { $0.singer+" · "+$0.title } ?? "Add songs below to begin").lineLimit(1)
Spacer();Text("\(model.rotationSingers.filter(\.here).count) singers Here").foregroundStyle(.secondary)
}.font(.caption)
HSplitView {
VStack(alignment:.leading,spacing:8) {
HStack { Text("Singers").font(.headline);Spacer();Button { model.beginSinger() } label: { Image(systemName:"person.badge.plus") }.buttonStyle(.borderless).help("Add a singer") }
ScrollView {
LazyVStack(spacing:5) {
ForEach(Array(roster.enumerated()),id:\.element.id) { index,singer in rosterRow(singer,index:index) }
if roster.isEmpty { Text("Choose a venue and add singers to begin.").font(.callout).foregroundStyle(.secondary).padding(16) }
Text("Drag a singer here to place last").font(.caption2).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(10)
.onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:nil) }
}
}
Text(model.hostSettings.smart ? "One turn each · Empty singers keep their place" : "Manual queue order decides who sings next").font(.caption2).foregroundStyle(.secondary)
}.padding(12).frame(minWidth:210,idealWidth:250,maxWidth:300).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
VStack(alignment:.leading,spacing:8) {
HStack { Picker("Rotation panel",selection:$addingSongs) { Text("Upcoming").tag(false);Text("Add Songs").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:220);Spacer();Text("\(model.rotationPlan.count) pending").font(.caption).foregroundStyle(.secondary)
Menu { Button("Reset Round") { model.resetRound() };Button("Mark All Here") { model.markAllSingersHere() } } label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:18)
}
if addingSongs {
RotationSongBrowser(model:model)
} else {
ScrollView {
LazyVStack(spacing:0) {
ForEach(Array(model.rotationPlan.enumerated()),id:\.element.id) { index,row in
HStack(spacing:10) {
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:20)
VStack(alignment:.leading,spacing:4) { Text(row.singer).font(.system(size:12,weight:.semibold));Text(row.title).font(.caption).foregroundStyle(.secondary).lineLimit(1) };Spacer()
if !model.hostSettings.smart {
Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0)
Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == model.rotationPlan.count-1)
}
Button { model.playPerformance(row.id) } label: { Image(systemName:"play.fill") }.help("Play this performance now")
Menu { Button("Remove Performance",role:.destructive) { model.removeQueue(row.id) };Menu("Assign Singer") { Button("No Singer / Filler") { model.assignQueue(row.id,singer:nil) };ForEach(model.venueSingers) { singer in Button(singer.name) { model.assignQueue(row.id,singer:singer.id) } } } } label: { Image(systemName:"ellipsis") }.menuStyle(.borderlessButton).frame(width:15)
}.buttonStyle(.borderless).padding(.vertical,10).padding(.horizontal,8).background(index == 0 ? Color.accentColor.opacity(0.06) : .clear)
Divider().opacity(0.4)
}
if model.rotationPlan.isEmpty { VStack(spacing:10) { Image(systemName:"text.badge.plus").font(.title);Text("No upcoming performances").font(.headline);Text("Select a singer, add songs to Tonight, and mark them Here for round robin.").font(.caption).multilineTextAlignment(.center) }.foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(22) }
}
}
}
}.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
}
}.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor))
.onStudioChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
.onStudioChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } }
}
private func rosterRow(_ singer: PersonPlace,index: Int) -> some View {
HStack(spacing:7) {
Toggle("Here",isOn:Binding(get:{model.rotationSingers.contains{$0.id == singer.id && $0.here}},set:{model.markHere(singer.id,$0)})).labelsHidden().toggleStyle(.checkbox).help("Singer is here tonight")
Button { model.selectedSinger = singer.id } label: { VStack(alignment:.leading,spacing:3) { Text(singer.name).font(.system(size:12,weight:.medium)).lineLimit(1);Text("\(model.queue.filter{$0.singerID == singer.id}.count) pending").font(.caption2).foregroundStyle(.secondary) }.frame(maxWidth:.infinity,alignment:.leading).contentShape(Rectangle()) }.buttonStyle(.plain)
Button { model.selectedSinger = singer.id;addingSongs = true } label: { Label("Songs",systemImage:"plus.circle").font(.caption).fixedSize() }.buttonStyle(.borderless).help("Add songs for \(singer.name)")
VStack(spacing:3) {
Button { model.reorderRotationSinger(singer.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0).accessibilityIdentifier("rotation.up.\(singer.id)")
Button { model.reorderRotationSinger(singer.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == roster.count-1).accessibilityIdentifier("rotation.down.\(singer.id)")
}.font(.system(size:10)).buttonStyle(.borderless)
Image(systemName:"line.3.horizontal").font(.caption).foregroundStyle(.secondary).frame(width:28,height:36).contentShape(Rectangle())
.onDrag {
if !model.rotationSingers.contains(where:{$0.id == singer.id}) { model.markHere(singer.id,false) }
return NSItemProvider(object:(model.rotationReference(singer.id)?.text ?? "") as NSString)
}.help("Drag to reorder singers")
}.padding(9).background(model.selectedSinger == singer.id ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:7))
.onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:singer.id) }
.contextMenu { Button("Manage Tonight…") { model.selectedSinger = singer.id;addingSongs = true };Button("Earlier") { model.reorderRotationSinger(singer.id,by:-1) };Button("Later") { model.reorderRotationSinger(singer.id,by:1) } }
}
}
private struct RotationNowCard: View {
@ObservedObject var player: Player
let singer: String?
var body: some View {
VStack(alignment:.leading,spacing:5) {
HStack { Circle().fill(player.playing ? Color.green : Color.secondary).frame(width:5,height:5);Text(player.playing ? "ON STAGE" : "DECK READY").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary);Spacer();Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.buttonStyle(.borderless).disabled(!player.canJog) }
Text(player.currentSongID == nil ? "No singer playing" : singer ?? "Main Deck").font(.headline).lineLimit(1)
Text(player.title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
}
}
private struct RotationTonightSheet: View {
@ObservedObject var model: AppModel
let singer: PersonPlace
@State private var query = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text("Tonight · "+singer.name).font(.title2.bold())
TextField("Search the library",text:$query).textFieldStyle(.roundedBorder)
List(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in
HStack {
Toggle(isOn:Binding(get:{model.tonightSongIDs[singer.id]?.contains(song.id) == true},set:{if $0 { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) }})) { VStack(alignment:.leading,spacing:3) { Text(song.title);Text(song.artist).font(.caption).foregroundStyle(.secondary) } }.toggleStyle(.checkbox)
Spacer()
if model.queue.contains(where:{$0.singerID == singer.id && $0.songID == song.id}) { Text("Queued").font(.caption).foregroundStyle(.green) }
else if model.tonightSongIDs[singer.id]?.contains(song.id) == true { Button("Queue Again") { model.addToTonight(song.id,singer:singer.id) }.controlSize(.small) }
}
}.frame(height:330)
HStack { Text("Adding a song marks this singer Here and puts it in the rotation.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor))
}
}
@MainActor extension AppModel {
func markAllSingersHere() {
perform {
guard let library,let currentVenue else { return };try library.execute("BEGIN IMMEDIATE")
do { for singer in venueSingers { try library.markHere(singer:singer.id,venue:currentVenue,here:true) };try library.execute("COMMIT") } catch { try? library.execute("ROLLBACK");throw error };try refresh()
}
}
}
struct RotationTransport: View {
@ObservedObject var model: AppModel
@ObservedObject var player: Player
var body: some View {
HStack(spacing:12) {
Button { model.startRotation() } label: { Label(model.rotationPlaybackActive && !player.playing ? "Resume Rotation" : "Start Rotation",systemImage:"play.fill") }.buttonStyle(.borderedProminent).disabled(model.rotationPlaybackActive && player.playing).accessibilityIdentifier("rotation.start")
Button { model.pauseRotation() } label: { Label("Pause",systemImage:"pause.fill") }.disabled(!model.rotationPlaybackActive || !player.playing)
Button { model.stopRotation() } label: { Label("Stop",systemImage:"stop.fill") }.disabled(!model.rotationPlaybackActive)
Button { model.studio.autoAdvance = true;model.playQueueNext() } label: { Label("Next Singer",systemImage:"forward.end.fill") }.disabled(model.rotationPlan.isEmpty)
Spacer()
Text(player.waitingForBackground ? "Fading background…" : model.rotationPlaybackActive ? (player.playing ? "Rotation playing" : "Rotation paused") : "Ready to start").font(.caption).foregroundStyle(.secondary)
}.controlSize(.small)
}
}
struct RotationSongBrowser: View {
@ObservedObject var model: AppModel
@State private var query = ""
var body: some View {
VStack(alignment:.leading,spacing:7) {
HStack {
Text("Add songs to Tonight").font(.system(size:12,weight:.semibold));Spacer()
Picker("Singer",selection:$model.selectedSinger) { Text("Choose singer…").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:150)
}
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
ScrollView {
LazyVStack(spacing:3) {
ForEach(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in
HStack {
VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:12,weight:.medium)).lineLimit(1);Text(song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
let queued = model.queue.contains{$0.songID == song.id && $0.singerID == model.selectedSinger}
Button(queued ? "Queued" : "Add to Tonight") { model.addToTonight(song.id) }.disabled(model.selectedSinger == nil || queued).controlSize(.small).accessibilityIdentifier("rotation.add.\(song.id)")
}.padding(.horizontal,8).padding(.vertical,5)
.onDrag { model.dragReference(song.id).provider() }
}
if model.songs.isEmpty { Text("Import songs into Library first.").foregroundStyle(.secondary).padding(10) }
}
}
}.padding(10).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
.onAppear { if model.selectedSinger == nil { model.selectedSinger = model.venueSingers.first?.id } }
}
}
struct PreferencesSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
init(_ title: String,@ViewBuilder content: @escaping () -> Content) { self.title = title;self.content = content }
var body: some View {
VStack(alignment:.leading,spacing:12) {
HStack(spacing:8) { Text(title).font(.headline);Rectangle().fill(Color.primary.opacity(0.12)).frame(height:1) }
content().frame(maxWidth:.infinity,alignment:.leading)
}.frame(maxWidth:.infinity,alignment:.leading)
}
}
struct StudioSettingsDialog: View {
@ObservedObject var model: AppModel
@State var scope = "Karaoke"
@State var category = "General"
@Environment(\.dismiss) private var dismiss
private var categories: [String] { scope == "DJ" ? ["General","Devices","Sound","Library","Appearance","MIDI","Advanced"] : ["General","Devices","Sound","Library","Appearance","Singer Web","Advanced"] }
private func icon(_ name: String) -> String { ["General":"gearshape","Devices":"hifispeaker","Sound":"waveform","Library":"music.note.house","Appearance":"eye","MIDI":"pianokeys","Advanced":"gearshape.2","Singer Web":"network"][name] ?? "gearshape" }
var body: some View {
VStack(spacing:0) {
HStack {
VStack(alignment:.leading,spacing:4) { Text("Settings").font(.title2.bold());Text(category).font(.callout).foregroundStyle(.secondary) }
Spacer(minLength:24)
Picker("Studio",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.labelsHidden().pickerStyle(.segmented).frame(width:340).fixedSize(horizontal:true,vertical:true)
}.padding(.horizontal,24).padding(.top,18).padding(.bottom,10)
HStack(spacing:8) {
ForEach(categories,id:\.self) { name in
Button { category = name } label: {
VStack(spacing:5) { Image(systemName:icon(name)).font(.system(size:24));Text(name).font(.system(size:11,weight:.medium)) }.frame(maxWidth:.infinity).frame(height:58).foregroundStyle(category == name ? Color.accentColor : Color.secondary).background(category == name ? Color.primary.opacity(0.08) : .clear,in:RoundedRectangle(cornerRadius:9))
}.buttonStyle(.plain).accessibilityLabel(scope+" "+name+" settings")
}
}.padding(.horizontal,24).padding(.bottom,16)
Divider()
if scope == "DJ" { DJPreferencesPane(system:model.dj,category:category) }
else if category == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) }
else if category == "Devices" { KaraokeDevicesPreferences(model:model) }
else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) }
Divider()
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
.onStudioChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onStudioChange(of:category) { _ in model.dj.midi.learning = nil }
.onDisappear { model.dj.midi.learning = nil }
.alert("Settings",isPresented:Binding(get:{model.error != nil},set:{if !$0 { model.error = nil }})) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
}
}
private struct KaraokeDevicesPreferences: View {
@ObservedObject var model: AppModel
var body: some View {
ScrollView {
PreferencesSection("Karaoke Audio Output") {
VStack(alignment:.leading,spacing:16) {
Picker("Main output",selection:Binding(get:{model.karaokeOutputUID},set:{model.routeKaraokeOutput($0)})) { Text("System default").tag("");ForEach(model.dj.devices) { Text($0.name).tag($0.uid) } }.frame(maxWidth:520)
Button("Refresh devices") { model.dj.refreshDevices() }
Text("Applies to the singer deck. Background music follows the system output. DJ audio is configured separately under DJ → Devices.").font(.callout).foregroundStyle(.secondary)
}.padding(12)
}.padding(24)
}.onAppear { model.dj.refreshDevices() }
}
}
@MainActor extension AppModel {
func routeKaraokeOutput(_ uid: String) {
perform {
guard let id = uid.isEmpty ? DJAudioDevice.defaultOutput() : dj.devices.first(where:{$0.uid == uid})?.id else { throw LibraryError.message("Output device unavailable") }
try player.routeOutput(id);karaokeOutputUID = uid
}
}
}