From db71c81d5306c6910793faa226bf0f8248f8721f Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 15:30:54 +0200 Subject: [PATCH] feat(ui): microphones in source sidebar, add menu per channel, gain reset, cursor always hidden on outputs Co-Authored-By: Claude Opus 5.5 --- App/Sources/ContentView.swift | 68 +++++++++++-------- App/Sources/SoundView.swift | 66 ++++++++++-------- .../Sources/VideoOutput/OutputWindow.swift | 36 +++++++++- 3 files changed, 109 insertions(+), 61 deletions(-) diff --git a/App/Sources/ContentView.swift b/App/Sources/ContentView.swift index e3d88a6..21acecf 100644 --- a/App/Sources/ContentView.swift +++ b/App/Sources/ContentView.swift @@ -12,12 +12,12 @@ struct ContentView: View { var body: some View { NavigationSplitView { - SourceListView(sourceList: sourceList) + SourceListView(sourceList: sourceList, audio: audio) .navigationSplitViewColumnWidth(min: 220, ideal: 260) } detail: { switch tab { case .screens: ScreensView(outputs: outputs) - case .sound: SoundView(audio: audio) + case .sound: SoundView(audio: audio, sourceList: sourceList) } } .navigationTitle("Musubi") @@ -44,24 +44,39 @@ struct ContentView: View { private struct SourceListView: View { let sourceList: SourceListModel + let audio: AudioController var body: some View { - Group { - switch sourceList.state { - case .unavailable: - EmptyStateView(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.") - case .idle, .searching: - if sourceList.sources.isEmpty { - EmptyStateView(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.") - } else { - List(sourceList.sources, id: \.ndiName) { source in - SourceRow(source: source) - .draggable(source.ndiName) + List { + Section("NDI") { + switch sourceList.state { + case .unavailable: + HintRow(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.") + case .idle, .searching: + if sourceList.sources.isEmpty { + HintRow(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.") + } else { + ForEach(sourceList.sources, id: \.ndiName) { source in + SourceRow(source: source) + .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) .safeAreaInset(edge: .top, spacing: 0) { 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 { let source: Source @@ -88,17 +114,3 @@ private struct SourceRow: View { .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) - } -} diff --git a/App/Sources/SoundView.swift b/App/Sources/SoundView.swift index b1d3b36..a197e3c 100644 --- a/App/Sources/SoundView.swift +++ b/App/Sources/SoundView.swift @@ -5,12 +5,12 @@ import SwiftUI /// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip. struct SoundView: View { let audio: AudioController + let sourceList: SourceListModel var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { header - MicrophoneChips(audio: audio) if let device = audio.selectedDevice { if let error = audio.matrix.errors[device.uid] { 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) { ForEach(0.. 0 { @@ -187,6 +187,12 @@ private struct RouteRow: View { } } .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) } + } } } diff --git a/Packages/MusubiKit/Sources/VideoOutput/OutputWindow.swift b/Packages/MusubiKit/Sources/VideoOutput/OutputWindow.swift index 8bed519..9d7a54f 100644 --- a/Packages/MusubiKit/Sources/VideoOutput/OutputWindow.swift +++ b/Packages/MusubiKit/Sources/VideoOutput/OutputWindow.swift @@ -26,6 +26,16 @@ public final class OutputWindow: NSWindow { 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?) { onCancel?() } @@ -35,6 +45,7 @@ public final class OutputWindow: NSWindow { @MainActor final class VideoView: NSView { let displayLayer = AVSampleBufferDisplayLayer() + private var cursorHidden = false init() { super.init(frame: .zero) @@ -50,9 +61,28 @@ final class VideoView: NSView { override func updateTrackingAreas() { super.updateTrackingAreas() 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) } - override func mouseMoved(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) } + // NSCursor.hide/unhide are counted, so keep them balanced. + private func hideCursor() { + guard !cursorHidden else { return } + NSCursor.hide() + cursorHidden = true + } + + func showCursor() { + guard cursorHidden else { return } + NSCursor.unhide() + cursorHidden = false + } }