broken changes

This commit is contained in:
Mark Gregory
2026-11-05 13:42:20 +00:00
parent d5b24bff29
commit d565e40215
7 changed files with 808 additions and 258 deletions
+80 -17
View File
@@ -269,6 +269,7 @@ struct DJDeckControls: View {
struct StudioSettingsPane: View {
var showHeading = true
var section: String? = nil
@AppStorage("studioAppearance") private var appearance = "system"
@ObservedObject var model: AppModel
@State private var port = ""
@@ -276,7 +277,8 @@ struct StudioSettingsPane: 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() } }
GroupBox("Appearance") {
if section == nil || section == "Appearance" {
PreferencesSection("Appearance") {
HStack {
Picker("Appearance",selection:$appearance) {
Text("System").tag("system")
@@ -287,7 +289,9 @@ struct StudioSettingsPane: View {
}.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") {
}
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)
@@ -297,14 +301,18 @@ struct StudioSettingsPane: View {
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)") {
}
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)
GroupBox("Background Music") {
}
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}))
@@ -313,11 +321,15 @@ struct StudioSettingsPane: View {
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)
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)
GroupBox("Imported Media Storage") {
}
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 {
@@ -329,7 +341,9 @@ struct StudioSettingsPane: View {
Text("The small library database, singer lists, settings and waveform cache stay on this Mac.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
GroupBox("Singer Web & Library") {
}
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)
@@ -337,6 +351,7 @@ struct StudioSettingsPane: View {
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) }
}
@@ -610,24 +625,72 @@ struct RotationSongBrowser: View {
}
}
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(spacing:14) {
Image(systemName:"slider.horizontal.3").font(.title2).foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:4) {
Text("Studio Settings").font(.title2.bold())
Text("Karaoke playback, background music and hosting").font(.callout).foregroundStyle(.secondary)
HStack {
Text(category).font(.headline).frame(maxWidth:.infinity,alignment:.leading)
Picker("Settings for",selection:$scope) { Text("Karaoke").tag("Karaoke");Text("DJ").tag("DJ") }.pickerStyle(.segmented).frame(width:220)
}.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")
}
Spacer()
}.padding(24)
}.padding(.horizontal,24).padding(.bottom,16)
Divider()
StudioSettingsPane(showHeading:false,model:model)
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("Changes save automatically on this Mac").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:760,height:680).background(Color(nsColor:.windowBackgroundColor))
HStack { Text(scope+" 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))
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onChange(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
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -65,6 +65,7 @@ struct SongDragReference: Codable, Sendable {
@Published var activeSection = "Library"
@Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } }
let backgroundDeck = BackgroundDeck()
@Published var karaokeOutputUID = ""
let dj: DJSystem
var backgroundAutomationReady = false
var backgroundPlaybackObserver: AnyCancellable?
@@ -933,7 +934,6 @@ struct LibraryView: View {
navigationRow("Venues",symbol:"building.2",count:model.venues.count)
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
navigationRow("Singer Web",symbol:"network")
navigationRow("Music Stores",symbol:"bag")
Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain)
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
+11 -2
View File
@@ -27,7 +27,11 @@ import CoreAudio
guard canJog else { return }
pauseDeck();loopEnabled = false;seek(0)
}
func setCue() { guard canJog else { return };cuePosition = time }
func setCue(at seconds: Double? = nil) {
guard canJog else { return }
if let seconds { guard seconds.isFinite else { return };cuePosition = min(duration,max(0,seconds)) }
else { tick();cuePosition = time }
}
func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) }
private func pauseDeck() {
cancelPendingPlayback()
@@ -45,6 +49,10 @@ import CoreAudio
func markLoopOut() {
guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true
}
func defineLoop(start: Double,end: Double) {
guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1 else { return }
loopIn = start;loopOut = end;loopEnabled = true
}
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
private var graphicsGeneration = UUID()
@@ -241,7 +249,8 @@ import CoreAudio
Task { @MainActor in
guard let self, self.generation == token else { return }
if self.fading { return }
if self.repeating { self.schedule(at:0,play:true) }
if self.loopEnabled,let start = self.loopIn { self.schedule(at:start,play:true) }
else if self.repeating { self.schedule(at:0,play:true) }
else {
self.playing = false; self.time = self.duration; self.render()
self.timer?.invalidate(); self.timer = nil; self.onFinished?()