broken changes
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+625
-180
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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?()
|
||||
|
||||
Reference in New Issue
Block a user