601 lines
47 KiB
Swift
601 lines
47 KiB
Swift
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:12) {
|
||
Image(systemName:"music.note").font(.title3).foregroundStyle(deck.playing ? .green : .secondary)
|
||
VStack(alignment:.leading,spacing:3) {
|
||
Text(deck.loaded ? deck.title : "Background Music").font(.system(size:12,weight:.semibold)).lineLimit(1)
|
||
Text(deck.status).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1)
|
||
if deck.loaded {
|
||
HStack(spacing:6) {
|
||
Text(stamp(deck.time)).monospacedDigit()
|
||
Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.small).accessibilityLabel("Background track position").help("Drag to find the background track’s starting point; paused music stays paused")
|
||
Text(stamp(deck.duration)).monospacedDigit()
|
||
}.font(.system(size:10)).foregroundStyle(.secondary)
|
||
}
|
||
}.frame(maxWidth:.infinity,alignment:.leading)
|
||
Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).help("Start background music between singers; pause it before singer audio starts")
|
||
Button("Choose…") { model.chooseBackground() }.controlSize(.small)
|
||
Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill").frame(width:23) }.disabled(!deck.loaded).help(deck.playing ? "Pause background music" : "DJ: start or resume background music")
|
||
Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).help("Stop background and return to start")
|
||
Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:85).controlSize(.small).help("Background level relative to global master").accessibilityLabel("Background level")
|
||
Text("\(Int(model.studio.backgroundTrim*100))%").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:30)
|
||
}.padding(.horizontal,12).padding(.vertical,8).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
|
||
}
|
||
}
|
||
|
||
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 {
|
||
@AppStorage("studioAppearance") private var appearance = "system"
|
||
@ObservedObject var model: AppModel
|
||
@State private var port = ""
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment:.leading,spacing:22) {
|
||
HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() }
|
||
GroupBox("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)
|
||
}
|
||
GroupBox("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)
|
||
}
|
||
GroupBox("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)
|
||
GroupBox("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. Global EQ and master apply to both decks.").font(.caption).foregroundStyle(.secondary)
|
||
}.padding(10)
|
||
}
|
||
GlobalMixerSettings(player:model.player)
|
||
GroupBox("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))
|
||
.onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
|
||
.onChange(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 } }
|
||
}
|
||
}
|