feat(ui): microphones in source sidebar, add menu per channel, gain reset, cursor always hidden on outputs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent 2d6d0236d5
commit db71c81d53

@ -12,12 +12,12 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
SourceListView(sourceList: sourceList) SourceListView(sourceList: sourceList, audio: audio)
.navigationSplitViewColumnWidth(min: 220, ideal: 260) .navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: { } detail: {
switch tab { switch tab {
case .screens: ScreensView(outputs: outputs) case .screens: ScreensView(outputs: outputs)
case .sound: SoundView(audio: audio) case .sound: SoundView(audio: audio, sourceList: sourceList)
} }
} }
.navigationTitle("Musubi") .navigationTitle("Musubi")
@ -44,24 +44,39 @@ struct ContentView: View {
private struct SourceListView: View { private struct SourceListView: View {
let sourceList: SourceListModel let sourceList: SourceListModel
let audio: AudioController
var body: some View { var body: some View {
Group { List {
Section("NDI") {
switch sourceList.state { switch sourceList.state {
case .unavailable: case .unavailable:
EmptyStateView(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.") HintRow(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.")
case .idle, .searching: case .idle, .searching:
if sourceList.sources.isEmpty { if sourceList.sources.isEmpty {
EmptyStateView(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.") HintRow(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.")
} else { } else {
List(sourceList.sources, id: \.ndiName) { source in ForEach(sourceList.sources, id: \.ndiName) { source in
SourceRow(source: source) SourceRow(source: source)
.draggable(source.ndiName) .draggable(source.ndiName)
} }
.scrollContentBackground(.hidden)
} }
} }
} }
Section("Mikrofone") {
ForEach(audio.matrix.inputDevices) { device in
Label {
Text(verbatim: device.name)
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.washi)
} icon: {
Image(systemName: "mic").foregroundStyle(Theme.Color.nezumi)
}
.draggable("input:\(device.uid)")
}
}
}
.scrollContentBackground(.hidden)
.background(Theme.Color.kasumi) .background(Theme.Color.kasumi)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
Text("Quellen") Text("Quellen")
@ -73,6 +88,17 @@ private struct SourceListView: View {
} }
} }
private struct HintRow: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct SourceRow: View { private struct SourceRow: View {
let source: Source let source: Source
@ -88,17 +114,3 @@ private struct SourceRow: View {
.padding(.vertical, Theme.Space.unit / 2) .padding(.vertical, Theme.Space.unit / 2)
} }
} }
private struct EmptyStateView: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
.multilineTextAlignment(.center)
.padding(Theme.Space.card * 2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.Color.sumi)
}
}

@ -5,12 +5,12 @@ import SwiftUI
/// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip. /// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip.
struct SoundView: View { struct SoundView: View {
let audio: AudioController let audio: AudioController
let sourceList: SourceListModel
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) { VStack(alignment: .leading, spacing: Theme.Space.card) {
header header
MicrophoneChips(audio: audio)
if let device = audio.selectedDevice { if let device = audio.selectedDevice {
if let error = audio.matrix.errors[device.uid] { if let error = audio.matrix.errors[device.uid] {
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku) Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
@ -22,7 +22,7 @@ struct SoundView: View {
} }
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: Theme.Space.card)], spacing: Theme.Space.card) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: Theme.Space.card)], spacing: Theme.Space.card) {
ForEach(0..<device.outputChannels, id: \.self) { channel in ForEach(0..<device.outputChannels, id: \.self) { channel in
ChannelStrip(audio: audio, device: device, channel: channel) ChannelStrip(audio: audio, sourceList: sourceList, device: device, channel: channel)
} }
} }
} else { } else {
@ -57,34 +57,9 @@ struct SoundView: View {
} }
} }
private struct MicrophoneChips: View {
let audio: AudioController
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text("Mikrofone – über die Sprechtaste")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Space.unit) {
ForEach(audio.matrix.inputDevices) { device in
Text(verbatim: device.name)
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.washi)
.padding(.horizontal, Theme.Space.unit)
.padding(.vertical, Theme.Space.unit / 2)
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.chip))
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.chip).strokeBorder(Theme.Color.usuzumi))
.draggable("input:\(device.uid)")
}
}
}
}
}
}
private struct ChannelStrip: View { private struct ChannelStrip: View {
let audio: AudioController let audio: AudioController
let sourceList: SourceListModel
let device: AudioDevice let device: AudioDevice
let channel: Int let channel: Int
@State private var targeted = false @State private var targeted = false
@ -93,19 +68,41 @@ private struct ChannelStrip: View {
audio.matrix.routes.filter { $0.deviceUID == device.uid && $0.channel == channel } audio.matrix.routes.filter { $0.deviceUID == device.uid && $0.channel == channel }
} }
private var addMenu: some View {
Menu {
Section("NDI") {
ForEach(sourceList.sources, id: \.ndiName) { source in
Button(source.label) { audio.add(.ndi(name: source.ndiName), channel: channel) }
}
}
Section("Mikrofone") {
ForEach(audio.matrix.inputDevices) { input in
Button(input.name) { audio.add(.input(deviceUID: input.uid), channel: channel) }
}
}
} label: {
Image(systemName: "plus")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.help("Quelle hinzufügen")
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) { VStack(alignment: .leading, spacing: Theme.Space.unit) {
HStack { HStack {
Text(verbatim: "Out \(channel + 1)").font(Theme.Font.title).foregroundStyle(Theme.Color.washi) Text(verbatim: "Out \(channel + 1)").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
Spacer() Spacer()
LevelText(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB) LevelText(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
addMenu
} }
LevelMeter(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB) LevelMeter(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
Divider().overlay(Theme.Color.usuzumi) Divider().overlay(Theme.Color.usuzumi)
if routes.isEmpty { if routes.isEmpty {
Text("Quelle hierher ziehen") Text("Quelle oder Mikrofon hierher ziehen")
.font(Theme.Font.body) .font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi) .foregroundStyle(Theme.Color.nezumi)
} }
@ -178,8 +175,11 @@ private struct RouteRow: View {
.controlSize(.small) .controlSize(.small)
Text(verbatim: route.gainDB <= Level.floorDB ? "−∞ dB" : String(format: "%+.1f dB", route.gainDB)) Text(verbatim: route.gainDB <= Level.floorDB ? "−∞ dB" : String(format: "%+.1f dB", route.gainDB))
.font(Theme.Font.number) .font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi) .foregroundStyle(route.gainDB == 0 ? Theme.Color.nezumi : Theme.Color.washi)
.frame(width: 70, alignment: .trailing) .frame(width: 70, alignment: .trailing)
.contentShape(Rectangle())
.onTapGesture(count: 2) { audio.matrix.setGain(route.id, db: 0) }
.help("Doppelklick: Pegel auf 0 dB")
} }
LevelMeter(db: audio.meters[route.id] ?? Level.floorDB) LevelMeter(db: audio.meters[route.id] ?? Level.floorDB)
if let count = audio.dropouts[route.id], count > 0 { if let count = audio.dropouts[route.id], count > 0 {
@ -187,6 +187,12 @@ private struct RouteRow: View {
} }
} }
.padding(.vertical, Theme.Space.unit / 2) .padding(.vertical, Theme.Space.unit / 2)
.contextMenu {
Button("Pegel auf 0 dB") { audio.matrix.setGain(route.id, db: 0) }
Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.matrix.setMuted(route.id, !route.muted) }
Divider()
Button("Route entfernen", role: .destructive) { audio.remove(route.id) }
}
} }
} }

@ -26,6 +26,16 @@ public final class OutputWindow: NSWindow {
public override var canBecomeKey: Bool { true } public override var canBecomeKey: Bool { true }
public override func orderFrontRegardless() {
super.orderFrontRegardless()
(contentView as? VideoView)?.hideCursorIfInside()
}
public override func orderOut(_ sender: Any?) {
(contentView as? VideoView)?.showCursor()
super.orderOut(sender)
}
public override func cancelOperation(_ sender: Any?) { public override func cancelOperation(_ sender: Any?) {
onCancel?() onCancel?()
} }
@ -35,6 +45,7 @@ public final class OutputWindow: NSWindow {
@MainActor @MainActor
final class VideoView: NSView { final class VideoView: NSView {
let displayLayer = AVSampleBufferDisplayLayer() let displayLayer = AVSampleBufferDisplayLayer()
private var cursorHidden = false
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
@ -50,9 +61,28 @@ final class VideoView: NSView {
override func updateTrackingAreas() { override func updateTrackingAreas() {
super.updateTrackingAreas() super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea) trackingAreas.forEach(removeTrackingArea)
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self)) addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self))
}
override func mouseEntered(with event: NSEvent) { hideCursor() }
override func mouseExited(with event: NSEvent) { showCursor() }
/// The cursor may already rest on the screen when the output opens; no enter event comes then.
func hideCursorIfInside() {
guard let window, window.frame.contains(NSEvent.mouseLocation) else { return }
hideCursor()
} }
override func mouseEntered(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) } // NSCursor.hide/unhide are counted, so keep them balanced.
override func mouseMoved(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) } private func hideCursor() {
guard !cursorHidden else { return }
NSCursor.hide()
cursorHidden = true
}
func showCursor() {
guard cursorHidden else { return }
NSCursor.unhide()
cursorHidden = false
}
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.