diff --git a/PROGRESS.md b/PROGRESS.md index 9d2c02a..23b192d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,17 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-06-20):** **macOS client UI overhaul** — mirrors the Windows client's UI + overhaul (commit 97fa659 + 540ec13), adapted to Mac-native conventions. Also fixed and + verified the previously-uncompiled Swift changes from the external PCM feed/tap commit + (615d2a8). The main window is now just toolbar + channels + users + chat; audio device + settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless + Settings window (⌘,). Details in M5 section below. `swift test` 10/10; `xcodebuild` Debug + + Release BUILD SUCCEEDED with 0 Swift warnings. + Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel + counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit + consumers of `vc_stream_feed_pcm`. + - **Awaiting on-device verification:** **iOS stereo mic kills headphone/A2DP output — REAL root cause found & fixed** (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the 2026-06-19 entries below) targeted the Swift `IOSAudioRouter` on the false premise that @@ -430,6 +441,58 @@ iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sh `test_pcm_sink`), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated (architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry). `ctest --preset dev` 23/23. +- [x] **macOS client UI overhaul** — done 2026-06-20. Mirrors the Windows client's UI + overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard + shortcuts), adapted to Mac-native conventions: + - **NSToolbar**: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons), + and Output Volume slider (NSSlider 0–100, default 80). Voice actions + mute/deafen + + output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps + input-mode segmented control / VAD slider / PTT key / device picker / level meter. + - **Unified log**: chat `NSTextView` + activity `NSTableView` collapsed into a single + `NSTextView` — activity events in `secondaryLabelColor` (gray), chat in default color. + Removed `activityTableView` and `activityLog` array. + - **Private messaging**: scope dropdown removed; compose bar always sends to the current + channel. Each PM conversation opens in its own modeless `PrivateMessageWindowController` + (NSWindow). Incoming `.textMessage` with `.private` scope routed to the right window; + outgoing PMs echoed by server arrive through the same path. "Send Private Message…" + added to user context menu. "New Private Message…" (⌘⇧N) opens `UserPickerSheet` + listing all server users. + - **Channel counts**: outline view renders `"Name (n)"` with live user counts; + `refreshChannelTree()` called on `.userJoined`/`.userLeft` (was missing). + - **Voice menu** (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and **Messages + menu** (⌘⇧N new PM) added to `NSApp.mainMenu` via `NSMenuItem` key equivalents with + `[.command, .shift]` mask. Removed on `windowWillClose`. Mac-native: ⌘ not Ctrl, dispatched + by the responder chain (no custom key monitor needed). + - **Output volume**: `setOutputVolume(_:)` wrapper added to `VoiceCatClient.swift` (was + missing — the C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was + never added). Wired end-to-end: toolbar slider → `client.setOutputVolume(gain)`. + - **Part A (uncompiled Swift fix)**: the external PCM feed/tap Swift wrapper (commit + 615d2a8) was never compiled — the local xcframework predating the `voicecat.h` PCM + additions. Fixed: rebuilt xcframework (regenerated module map), fixed `UInt`→`Int` type + mismatch in `feedPcm` (Swift imports `size_t` as `Int` not `UInt`), added + `VoiceCatPcmSinkCallback` typealias (Swift-idiomatic alias for the C `vc_pcm_sink_cb` + so consumers don't need to directly import `VoiceCatC`). `swift test` 10/10 green. + - **Audio settings moved to Settings window**: the bottom voice panel (input mode, VAD + slider, PTT key, device picker, level meter) was removed from the main window and moved + into a new `SettingsWindowController` — a modeless window opened via the app menu's + "Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat. + Source-of-truth for audio settings (`selectedInputMode`, `vadThresholdValue`, + `selectedInputDeviceId`, `pttKeyCode`) lives in `MainWindowController` so voice start can + apply them even before the settings window has been opened; `SettingsWindowController` + reads from and writes back to those properties and applies changes to the client + immediately when voice is active. The level meter is forwarded from + `MainWindowController.handleLevel` → `settingsWindowController.updateLevel(rms:)`. + `keyCodeName` helper deduplicated (was duplicated in `PttKeyCaptureSheet.swift` + + `MainWindowController.swift` — now shared from `MainWindowController.swift`). + - Files: `MainWindowController.swift` (overhauled), `PrivateMessageWindowController.swift` + (new), `UserPickerSheet.swift` (new), `SettingsWindowController.swift` (new), + `VoiceCatClient.swift` (setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm + type fix), `ExternalPcmTests.swift` (use typealias), `PttKeyCaptureSheet.swift` (removed + duplicate `keyCodeName`), `VoiceCatMac.xcodeproj/project.pbxproj` (register 3 new files). + - **Platform-specific adaptations** (vs. Windows): `NSToolbar` instead of `ToolStrip`; + global menu bar + `NSMenuItem` key equivalents (⌘ not Ctrl, responder-chain dispatched); + PM windows as modeless `NSWindow`s; picker as Mac sheet; gray = `secondaryLabelColor`; + SF Symbols for toolbar icons. --- diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index 92a62c8..d91d6e7 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -35,6 +35,12 @@ import VoiceCatC import Foundation +/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from +/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink +/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's +/// `VcPcmSinkCallback` delegate. +public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb + /// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime; /// `deinit` destroys it. Events and level meters are delivered on the main queue via the /// `onEvent` / `onLevel` closures. @@ -324,7 +330,7 @@ public final class VoiceCatClient { public func feedPcm(streamId: UInt32, pcm: UnsafePointer, samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult { VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm, - UInt(samplesPerChannel), channels)) + samplesPerChannel, channels)) } /// Convenience overload for feeding from a Swift `[Int16]` array. @@ -345,7 +351,7 @@ public final class VoiceCatClient { /// /// Pass `nil` to disable (default). The callback MUST NOT block or allocate. @discardableResult - public func setPcmSink(_ cb: vc_pcm_sink_cb?, user: UnsafeMutableRawPointer?) -> VoiceCatResult { + public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult { VoiceCatResult(vc_set_pcm_sink(handle, cb, user)) } @@ -370,6 +376,14 @@ public final class VoiceCatClient { VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0)) } + /// Global playback volume applied after mixing all remote streams. gain 0.0 = silent, + /// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the + /// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5. + @discardableResult + public func setOutputVolume(_ gain: Float) -> VoiceCatResult { + VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain)) + } + // MARK: - AVAudioSession interruption hooks (iOS) /// Pause miniaudio device I/O. Call when AVAudioSession interruption begins. diff --git a/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift b/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift index c0d648a..760c208 100644 --- a/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift +++ b/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift @@ -61,7 +61,7 @@ final class ExternalPcmTests: XCTestCase { logLevel: .off )) - let mySink: vc_pcm_sink_cb = { _, _, _, _, _, _, _ in } + let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in } XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok) XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok) } diff --git a/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj b/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj index c7b39d1..d948201 100644 --- a/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj +++ b/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ AAAA00000000000000000041 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = AAAA00000000000000000027 /* VoiceCatCore */; }; AAAA00000000000000000042 /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000013 /* Info.plist */; }; AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */; }; + AAAA00000000000000000046 /* PrivateMessageWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000045 /* PrivateMessageWindowController.swift */; }; + AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; }; + AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -50,6 +53,9 @@ AAAA00000000000000000023 /* BanUserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserSheet.swift; sourceTree = ""; }; AAAA00000000000000000024 /* PermissionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsSheet.swift; sourceTree = ""; }; AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttKeyCaptureSheet.swift; sourceTree = ""; }; + AAAA00000000000000000045 /* PrivateMessageWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageWindowController.swift; sourceTree = ""; }; + AAAA00000000000000000047 /* UserPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPickerSheet.swift; sourceTree = ""; }; + AAAA00000000000000000049 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; AAAA00000000000000000025 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -103,6 +109,8 @@ children = ( AAAA00000000000000000019 /* ConnectWindowController.swift */, AAAA0000000000000000001A /* MainWindowController.swift */, + AAAA00000000000000000045 /* PrivateMessageWindowController.swift */, + AAAA00000000000000000049 /* SettingsWindowController.swift */, ); path = Windows; sourceTree = ""; @@ -121,6 +129,7 @@ AAAA00000000000000000023 /* BanUserSheet.swift */, AAAA00000000000000000024 /* PermissionsSheet.swift */, AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */, + AAAA00000000000000000047 /* UserPickerSheet.swift */, ); path = Sheets; sourceTree = ""; @@ -219,6 +228,9 @@ AAAA0000000000000000003E /* BanUserSheet.swift in Sources */, AAAA0000000000000000003F /* PermissionsSheet.swift in Sources */, AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */, + AAAA00000000000000000046 /* PrivateMessageWindowController.swift in Sources */, + AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */, + AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift index 9375075..0c2c138 100644 --- a/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift +++ b/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift @@ -101,13 +101,3 @@ private final class KeyCaptureView: NSView { } override var focusRingMaskBounds: NSRect { bounds } } - -private func keyCodeName(_ keyCode: UInt16) -> String { - let map: [UInt16: String] = [ - 0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9", - 0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12", - 0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del", - 0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1", - ] - return map[keyCode] ?? "Key\(keyCode)" -} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/UserPickerSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/UserPickerSheet.swift new file mode 100644 index 0000000..f73255b --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/UserPickerSheet.swift @@ -0,0 +1,137 @@ +import AppKit +import VoiceCatCore + +// UserPickerSheet — a modal sheet for picking one user from the list of all connected server +// users. Used by the "Messages → New Private Message…" menu item so the user can start a PM +// with anyone on the server, not just the current channel. Mirrors the Windows client's +// `UserPickerDialog` (clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs), adapted to the +// Mac sheet pattern used by the rest of the macOS client (InputSheet, MoveUserSheet, etc.). + +final class UserPickerSheet: NSViewController, NSTableViewDataSource, NSTableViewDelegate { + + /// Called with the selected user's ID, or `nil` if the user cancelled. + var onComplete: ((UInt32?) -> Void)? + + private let users: [User] + private let tableView = NSTableView() + private var okButton: NSButton? + private var selectedRow: Int = -1 + + init(users: [User]) { + self.users = users + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 320)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + // MARK: - UI + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Select a user:") + titleLabel.font = .boldSystemFont(ofSize: 13) + titleLabel.setAccessibilityLabel("Select a user") + + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user")) + tableView.addTableColumn(col) + tableView.headerView = nil + tableView.dataSource = self + tableView.delegate = self + tableView.doubleAction = #selector(okClicked) + tableView.target = self + tableView.setAccessibilityLabel("User list") + + let scroll = NSScrollView() + scroll.documentView = tableView + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + scroll.translatesAutoresizingMaskIntoConstraints = false + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked)) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.isEnabled = false + self.okButton = okButton + + let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + + let stack = NSStackView(views: [titleLabel, scroll, buttonRow]) + stack.orientation = .vertical + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func viewDidAppear() { + super.viewDidAppear() + if users.count == 1 { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + // MARK: - Actions + + @objc private func okClicked() { + guard selectedRow >= 0, selectedRow < users.count else { return } + let userId = users[selectedRow].id + dismiss(nil) + onComplete?(userId) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } + + // MARK: - NSTableViewDataSource / Delegate + + func numberOfRows(in tableView: NSTableView) -> Int { users.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let id = NSUserInterfaceItemIdentifier("userCell") + let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView + ?? makeCellView(identifier: id) + cell.textField?.stringValue = users[row].nickname + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + selectedRow = tableView.selectedRow + okButton?.isEnabled = selectedRow >= 0 + } + + private func makeCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(tf) + cell.textField = tf + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift index de5446a..a70ce8c 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift @@ -28,59 +28,51 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { private var talkingUsers: Set = [] private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false, canMoveUsers: false, canAdminAccounts: false, isAdmin: false) - private var micStreamId: UInt32 = 0 + internal var micStreamId: UInt32 = 0 private var screenStreamId: UInt32 = 0 - private var pttKeyCode: UInt16 = 0x60 // F8 + internal var pttKeyCode: UInt16 = 0x60 // F8 private var pttMonitor: Any? private var serverMuted = false private var serverDeafened = false private var channelTree: [ChannelNode] = [] - private var activityLog: [String] = [] private var displayedUsers: [User] = [] private var displayedAccounts: [Account] = [] private var adminMenuItem: NSMenuItem? + private var voiceMenuItem: NSMenuItem? + private var messagesMenuItem: NSMenuItem? + private var settingsMenuItem: NSMenuItem? + private var pmWindows: [UInt32: PrivateMessageWindowController] = [:] + private var settingsWindowController: SettingsWindowController? + + // MARK: - Audio settings state (source of truth — read/written by SettingsWindowController) + + internal var selectedInputMode: VoiceCatInputMode = .voiceActivation + internal var vadThresholdValue: Float = 0.05 + internal var selectedInputDeviceId: String? // MARK: - UI components private let channelOutlineView = NSOutlineView() private let userTableView = NSTableView() - private let chatTextView: NSTextView = { + private let logTextView: NSTextView = { let tv = NSTextView() tv.isEditable = false tv.isSelectable = true + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.textContainerInset = NSSize(width: 4, height: 4) return tv }() - private let activityTableView = NSTableView() private let statusLabel = NSTextField(labelWithString: "") - private let micToggleButton = NSButton() - private let screenAudioButton = NSButton() - private let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: nil, action: nil) - private let deafenCheckbox = NSButton(checkboxWithTitle: "Deafen", target: nil, action: nil) - private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"], - trackingMode: .selectOne, - target: nil, action: nil) - private let vadSlider: NSSlider = { - let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil) - s.numberOfTickMarks = 0 - return s - }() - private let vadLabel = NSTextField(labelWithString: "Sensitivity:") - private let pttKeyLabel = NSTextField(labelWithString: "(F8)") - private let changePttButton = NSButton() - private let devicePicker = NSPopUpButton() - private let refreshDevicesButton = NSButton() - private let levelMeter: NSProgressIndicator = { - let p = NSProgressIndicator() - p.style = .bar - p.isIndeterminate = false - p.minValue = 0 - p.maxValue = 100 - p.doubleValue = 0 - return p - }() - private let scopePicker = NSPopUpButton() private let composeField = NSTextField() private let sendButton = NSButton() + // MARK: - Toolbar controls (kept as fields so we can update their state) + + private var joinVoiceButton: NSButton? + private var shareScreenButton: NSButton? + private var muteButton: NSButton? + private var deafenButton: NSButton? + private var outputVolumeSlider: NSSlider? + // MARK: - Init init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) { @@ -100,6 +92,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { window.delegate = self buildUI() + buildToolbar() wireEvents() bootstrap() } @@ -115,7 +108,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { private func buildUI() { guard let contentView = window?.contentView else { return } - // Outer split: left panel (channels+users) | right panel (chat+activity) + // Outer split: left panel (channels+users) | right panel (unified log) let outerSplit = NSSplitView() outerSplit.isVertical = true outerSplit.dividerStyle = .thin @@ -134,30 +127,19 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) - // Right panel: chat + activity stacked vertically - let rightSplit = NSSplitView() - rightSplit.isVertical = false - rightSplit.dividerStyle = .thin - rightSplit.translatesAutoresizingMaskIntoConstraints = false - let chatPanel = buildChatPanel() - let activityPanel = buildActivityPanel() - rightSplit.addArrangedSubview(chatPanel) - rightSplit.addArrangedSubview(activityPanel) - rightSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 0) - rightSplit.setHoldingPriority(.defaultLow, forSubviewAt: 1) + // Right panel: unified log (chat + activity in one text view) + let logPanel = buildLogPanel() outerSplit.addArrangedSubview(innerSplit) - outerSplit.addArrangedSubview(rightSplit) + outerSplit.addArrangedSubview(logPanel) outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) let statusBar = buildStatusBar() - let voicePanel = buildVoicePanel() let composeBar = buildComposeBar() contentView.addSubview(outerSplit) contentView.addSubview(statusBar) - contentView.addSubview(voicePanel) contentView.addSubview(composeBar) NSLayoutConstraint.activate([ @@ -168,13 +150,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - statusBar.bottomAnchor.constraint(equalTo: voicePanel.topAnchor), + statusBar.bottomAnchor.constraint(equalTo: composeBar.topAnchor), statusBar.heightAnchor.constraint(equalToConstant: 24), - voicePanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - voicePanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - voicePanel.bottomAnchor.constraint(equalTo: composeBar.topAnchor), - composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), @@ -183,13 +161,12 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { // Set initial divider positions so panels get reasonable space instead of // collapsing to zero. These are applied after the window is on screen. - DispatchQueue.main.async { [weak self, weak outerSplit, weak innerSplit, weak rightSplit] in + DispatchQueue.main.async { [weak self, weak outerSplit, weak innerSplit] in // outerSplit is vertical (left|right): divide at 40% from left outerSplit?.setPosition(360, ofDividerAt: 0) // innerSplit is horizontal (channels on top, users below): divide at 60% from top innerSplit?.setPosition(260, ofDividerAt: 0) - // rightSplit is horizontal (chat on top, activity below): divide at 70% from top - rightSplit?.setPosition(280, ofDividerAt: 0) + _ = self } } @@ -239,29 +216,12 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { return sv } - private func buildChatPanel() -> NSView { - chatTextView.setAccessibilityLabel("Chat messages") - chatTextView.textContainerInset = NSSize(width: 4, height: 4) - chatTextView.isAutomaticQuoteSubstitutionEnabled = false + private func buildLogPanel() -> NSView { + logTextView.setAccessibilityLabel("Chat and activity log") + logTextView.setAccessibilityHelp("Combined history of chat messages and activity events") let sv = NSScrollView() - sv.documentView = chatTextView - sv.hasVerticalScroller = true - sv.borderType = .noBorder - sv.translatesAutoresizingMaskIntoConstraints = false - return sv - } - - private func buildActivityPanel() -> NSView { - let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("activity")) - activityTableView.addTableColumn(col) - activityTableView.headerView = nil - activityTableView.dataSource = self - activityTableView.delegate = self - activityTableView.setAccessibilityLabel("Activity log") - - let sv = NSScrollView() - sv.documentView = activityTableView + sv.documentView = logTextView sv.hasVerticalScroller = true sv.borderType = .noBorder sv.translatesAutoresizingMaskIntoConstraints = false @@ -293,108 +253,12 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { return bar } - private func buildVoicePanel() -> NSView { - let panel = NSView() - panel.translatesAutoresizingMaskIntoConstraints = false - let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false - panel.addSubview(sep) - - // Mic + Screen buttons - micToggleButton.title = "Join Voice" - micToggleButton.bezelStyle = .rounded - micToggleButton.target = self; micToggleButton.action = #selector(micToggleClicked) - micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio") - - screenAudioButton.title = "Share Screen Audio" - screenAudioButton.bezelStyle = .rounded - screenAudioButton.target = self; screenAudioButton.action = #selector(screenAudioClicked) - screenAudioButton.setAccessibilityLabel("Share Screen Audio") - - // Mute/Deafen - muteCheckbox.target = self; muteCheckbox.action = #selector(muteChanged) - muteCheckbox.setAccessibilityLabel("Mute microphone") - muteCheckbox.isEnabled = false - deafenCheckbox.target = self; deafenCheckbox.action = #selector(deafenChanged) - deafenCheckbox.setAccessibilityLabel("Deafen — mute all incoming audio") - deafenCheckbox.isEnabled = false - - // Input mode - inputModeControl.target = self; inputModeControl.action = #selector(inputModeChanged) - inputModeControl.selectedSegment = 0 - inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On") - - // VAD slider - vadLabel.setAccessibilityLabel("VAD sensitivity") - vadSlider.target = self; vadSlider.action = #selector(vadSliderChanged) - vadSlider.setAccessibilityLabel("Voice activation sensitivity") - vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less") - - // PTT - pttKeyLabel.setAccessibilityLabel("Current PTT key") - changePttButton.title = "Change…" - changePttButton.bezelStyle = .rounded - changePttButton.target = self; changePttButton.action = #selector(changePttClicked) - changePttButton.setAccessibilityLabel("Change push-to-talk key") - pttKeyLabel.isHidden = true - changePttButton.isHidden = true - - // Device picker - let deviceLabel = NSTextField(labelWithString: "Input:") - devicePicker.setAccessibilityLabel("Input audio device") - refreshDevicesButton.title = "↺" - refreshDevicesButton.bezelStyle = .rounded - refreshDevicesButton.target = self; refreshDevicesButton.action = #selector(refreshDevicesClicked) - refreshDevicesButton.setAccessibilityLabel("Refresh device list") - refreshDevicesButton.toolTip = "Refresh" - - // Level meter - levelMeter.setAccessibilityLabel("Microphone input level") - levelMeter.setAccessibilityHelp("Shows current microphone volume level") - - // Layout all voice controls - let row1 = hstack([micToggleButton, screenAudioButton, muteCheckbox, deafenCheckbox]) - let row2 = hstack([inputModeControl, vadLabel, vadSlider, pttKeyLabel, changePttButton]) - let row3 = hstack([deviceLabel, devicePicker, refreshDevicesButton, levelMeter]) - - [sep, row1, row2, row3].forEach { v in - v.translatesAutoresizingMaskIntoConstraints = false - panel.addSubview(v) - } - - NSLayoutConstraint.activate([ - sep.topAnchor.constraint(equalTo: panel.topAnchor), - sep.leadingAnchor.constraint(equalTo: panel.leadingAnchor), - sep.trailingAnchor.constraint(equalTo: panel.trailingAnchor), - sep.heightAnchor.constraint(equalToConstant: 1), - - row1.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 6), - row1.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), - row1.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), - - row2.topAnchor.constraint(equalTo: row1.bottomAnchor, constant: 4), - row2.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), - row2.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), - - row3.topAnchor.constraint(equalTo: row2.bottomAnchor, constant: 4), - row3.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), - row3.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), - row3.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -6), - - levelMeter.widthAnchor.constraint(equalToConstant: 100), - devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 160), - - panel.heightAnchor.constraint(equalToConstant: 96), - ]) - return panel - } - private func buildComposeBar() -> NSView { let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false - scopePicker.setAccessibilityLabel("Message scope — channel or private") - composeField.placeholderString = "Type a message…" - composeField.setAccessibilityLabel("Compose message") + composeField.placeholderString = "Type a message to the current channel…" + composeField.setAccessibilityLabel("Compose channel message") composeField.target = self; composeField.action = #selector(sendClicked) sendButton.title = "Send" @@ -404,7 +268,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { sendButton.keyEquivalent = "\r" let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false - [sep, scopePicker, composeField, sendButton].forEach { v in + [sep, composeField, sendButton].forEach { v in v.translatesAutoresizingMaskIntoConstraints = false bar.addSubview(v) } @@ -414,10 +278,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor), sep.heightAnchor.constraint(equalToConstant: 1), - scopePicker.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), - scopePicker.centerYAnchor.constraint(equalTo: bar.centerYAnchor), - - composeField.leadingAnchor.constraint(equalTo: scopePicker.trailingAnchor, constant: 6), + composeField.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6), composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor), @@ -436,6 +297,38 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { return s } + // MARK: - Toolbar + + private enum ToolbarID { + static let joinVoice = NSToolbarItem.Identifier("vc.joinVoice") + static let shareScreen = NSToolbarItem.Identifier("vc.shareScreen") + static let mute = NSToolbarItem.Identifier("vc.mute") + static let deafen = NSToolbarItem.Identifier("vc.deafen") + static let outputVolume = NSToolbarItem.Identifier("vc.outputVolume") + } + + private func buildToolbar() { + let toolbar = NSToolbar(identifier: "VoiceCatMainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconAndLabel + toolbar.showsBaselineSeparator = true + window?.toolbar = toolbar + } + + private func makeToolbarButton(symbol: String, label: String, action: Selector, + accessibilityLabel: String) -> NSButton { + let btn = NSButton() + btn.bezelStyle = .inline + btn.image = NSImage(systemSymbolName: symbol, accessibilityDescription: accessibilityLabel) + btn.imagePosition = .imageLeading + btn.title = label + btn.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + btn.target = self + btn.action = action + btn.setAccessibilityLabel(accessibilityLabel) + return btn + } + // MARK: - Event wiring private func wireEvents() { @@ -443,11 +336,21 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { client.onLevel = { [weak self] streamId, rms in self?.handleLevel(streamId, rms) } pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in - guard let self, self.inputModeControl.selectedSegment == 1, + guard let self, self.selectedInputMode == .pushToTalk, self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event } self.client.setPushToTalk(event.type == .keyDown) return nil } + + // Observe PM window close so we can drop the controller from pmWindows. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: nil, queue: .main + ) { [weak self] note in + guard let self, + let window = note.object as? NSWindow, + let wc = window.windowController as? PrivateMessageWindowController else { return } + self.pmWindows.removeValue(forKey: wc.otherUserId) + } } // MARK: - Bootstrap @@ -466,11 +369,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { channels.count, allUsers.count, ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId) + // Apply initial output volume (default 80% — matches Windows client) + client.setOutputVolume(0.8) + refreshChannelTree() refreshUserList() - rebuildScopePicker() updateStatusLabel() - loadInputDevices() + buildSettingsMenuItem() + buildVoiceMenu() + buildMessagesMenu() buildAdminMenu() addActivity("Connected to server as \(nickname)") } @@ -490,7 +397,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { users[u.id] = u if u.id == selfUserId { currentChannelId = u.channelId } } - refreshChannelTree(); refreshUserList(); rebuildScopePicker() + refreshChannelTree(); refreshUserList() case .userJoined: let u = User(id: event.userId, @@ -500,7 +407,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { selfMicMuted: false, selfDeafened: false, serverMuted: false, serverDeafened: false) users[event.userId] = u - refreshUserList(); rebuildScopePicker() + refreshChannelTree(); refreshUserList() if event.channelId == currentChannelId && event.userId != selfUserId { addActivity("\(u.nickname) joined the channel") } @@ -510,8 +417,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId users.removeValue(forKey: event.userId) talkingUsers.remove(event.userId) - refreshUserList(); rebuildScopePicker() + refreshChannelTree(); refreshUserList() if wasHere { addActivity("\(nick) left the channel") } + if let pmWin = pmWindows[event.userId] { + pmWin.appendActivity("\(nick) disconnected from server") + } case .userUpdated: let allUsers = client.listUsers() @@ -523,7 +433,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened) } } - refreshChannelTree(); refreshUserList(); rebuildScopePicker() + refreshChannelTree(); refreshUserList() case .joinResult: if event.result == .ok { @@ -537,7 +447,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { refreshChannelTree(); refreshUserList(); updateStatusLabel() let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)" addActivity("Joined \(name)") - NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium]) } else { addActivity("Could not join channel: \(event.text ?? "\(event.result)")") @@ -561,7 +471,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { if talking && event.userId != selfUserId, let u = users[event.userId], u.channelId == currentChannelId { addActivity("\(u.nickname) started talking") - NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "\(u.nickname) started talking", .priority: NSAccessibilityPriorityLevel.medium]) } @@ -588,11 +498,10 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { private func handleLevel(_ streamId: UInt32, _ rms: Float) { guard streamId == micStreamId else { return } - levelMeter.doubleValue = min(100, Double(rms * 400)) - levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent") + settingsWindowController?.updateLevel(rms: rms) } - // MARK: - Chat + // MARK: - Chat / unified log private func appendChatMessage(_ event: VoiceCatEvent) { let time: String @@ -603,28 +512,34 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) } let sender = nickname(for: event.userId) - let prefix: String + if event.textScope == .private { - prefix = event.userId == selfUserId - ? "(private to \(nickname(for: event.channelId))) " - : "(private) " - } else { prefix = "" } - let line = "[\(time)] \(prefix)\(sender): \(event.text ?? "")\n" - chatTextView.textStorage?.append(NSAttributedString(string: line)) - chatTextView.scrollToEndOfDocument(nil) - if event.textScope == .private && event.userId != selfUserId { - addActivity("Private message from \(sender)") + // Route PMs to per-conversation windows. For our own outgoing PM, ev.channelId + // carries the recipient user ID; for incoming, ev.userId is the sender. + let otherId = event.userId == selfUserId ? event.channelId : event.userId + let win = getOrOpenPmWindow(otherId) + win.appendMessage(time: time, isSelf: event.userId == selfUserId, sender: sender, + text: event.text ?? "") + if event.userId != selfUserId { + addActivity("Private message from \(sender)") + } + } else { + let line = "[\(time)] \(sender): \(event.text ?? "")\n" + logTextView.textStorage?.append(NSAttributedString(string: line)) + logTextView.scrollToEndOfDocument(nil) } } - // MARK: - Activity log + // MARK: - Activity log (appended to the unified log in gray) private func addActivity(_ text: String) { - let entry = "[\(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))] \(text)" - activityLog.append(entry) - if activityLog.count > 200 { activityLog.removeFirst() } - activityTableView.reloadData() - activityTableView.scrollRowToVisible(activityLog.count - 1) + let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) + let line = "[\(time)] \(text)\n" + let attributed = NSAttributedString(string: line, attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + ]) + logTextView.textStorage?.append(attributed) + logTextView.scrollToEndOfDocument(nil) } // MARK: - UI refresh @@ -657,26 +572,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { updateStatusLabel() } - private func rebuildScopePicker() { - let prev = scopePicker.selectedItem?.representedObject as? UInt32 - scopePicker.removeAllItems() - let chItem = NSMenuItem(title: "Channel", action: nil, keyEquivalent: "") - chItem.representedObject = nil as UInt32? - scopePicker.menu?.addItem(chItem) - for u in users.values.sorted(by: { $0.nickname < $1.nickname }) where u.id != selfUserId { - let item = NSMenuItem(title: "Private: \(u.nickname)", action: nil, keyEquivalent: "") - item.representedObject = u.id - scopePicker.menu?.addItem(item) - } - if let prev { - for item in scopePicker.itemArray where (item.representedObject as? UInt32) == prev { - scopePicker.select(item); break - } - } else { - scopePicker.selectItem(at: 0) - } - } - private func updateStatusLabel() { var suffix = "" if serverMuted { suffix += " [server muted]" } @@ -708,7 +603,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { users.removeAll(); talkingUsers.removeAll() currentChannelId = 0; micStreamId = 0; screenStreamId = 0 composeField.isEnabled = false; sendButton.isEnabled = false - micToggleButton.isEnabled = false; screenAudioButton.isEnabled = false + joinVoiceButton?.isEnabled = false + shareScreenButton?.isEnabled = false + muteButton?.isEnabled = false + deafenButton?.isEnabled = false + // Close all PM windows + for (_, pmWin) in pmWindows { pmWin.close() } + pmWindows.removeAll() } // MARK: - Admin menu @@ -769,18 +670,16 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone")) if result == .ok { micStreamId = streamId - if let devId = selectedDeviceId(), !isDefaultDevice(devId) { + if let devId = selectedInputDeviceId { client.setInputDevice(streamId: streamId, deviceId: devId) } - client.setInputMode(currentInputMode()) - if inputModeControl.selectedSegment == 0 { - client.setVadThreshold(vadThresholdFromSlider()) + client.setInputMode(selectedInputMode) + if selectedInputMode == .voiceActivation { + client.setVadThreshold(vadThresholdValue) } - micToggleButton.title = "Leave Voice" - micToggleButton.setAccessibilityLabel("Leave Voice — stop sending microphone audio") - muteCheckbox.isEnabled = true; deafenCheckbox.isEnabled = true + setVoiceJoinedState(true) addActivity("Joined voice — microphone active") - NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium]) } else { addActivity("Failed to start microphone: \(result)") @@ -789,20 +688,32 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { client.setPushToTalk(false) client.stopStream(micStreamId) micStreamId = 0 - levelMeter.doubleValue = 0 - micToggleButton.title = "Join Voice" - micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio") - muteCheckbox.isEnabled = false; deafenCheckbox.isEnabled = false + settingsWindowController?.resetLevel() + setVoiceJoinedState(false) addActivity("Left voice") } } + /// Update toolbar button labels/states to reflect whether voice is active. Mirrors the + /// Windows client's `SetVoiceJoinedState`. + private func setVoiceJoinedState(_ joined: Bool) { + joinVoiceButton?.title = joined ? "Leave Voice" : "Join Voice" + joinVoiceButton?.image = NSImage(systemSymbolName: joined ? "mic.fill" : "mic", + accessibilityDescription: joined ? "Leave Voice" : "Join Voice") + joinVoiceButton?.setAccessibilityLabel(joined ? "Leave Voice — stop sending microphone audio" + : "Join Voice — start sending microphone audio") + muteButton?.isEnabled = joined + deafenButton?.isEnabled = joined + } + @objc private func screenAudioClicked() { if screenStreamId == 0 { let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio")) if result == .ok { screenStreamId = streamId - screenAudioButton.title = "Stop Screen Audio" + shareScreenButton?.title = "Stop Screen Audio" + shareScreenButton?.image = NSImage(systemSymbolName: "rectangle.on.rectangle.angled.fill", + accessibilityDescription: "Stop Screen Audio") addActivity("Started sharing screen audio") } else { addActivity("Failed to start screen audio: \(result)") @@ -810,78 +721,27 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { } else { client.stopStream(screenStreamId) screenStreamId = 0 - screenAudioButton.title = "Share Screen Audio" + shareScreenButton?.title = "Share Screen Audio" + shareScreenButton?.image = NSImage(systemSymbolName: "rectangle.on.rectangle.angled", + accessibilityDescription: "Share Screen Audio") addActivity("Stopped sharing screen audio") } } @objc private func muteChanged() { - client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on) + let muted = muteButton?.state == .on + let deafened = deafenButton?.state == .on + client.setSelfMute(micMuted: muted, deafened: deafened) + muteButton?.image = NSImage(systemSymbolName: muted ? "mic.slash.fill" : "mic.fill", + accessibilityDescription: muted ? "Unmute microphone" : "Mute microphone") } @objc private func deafenChanged() { - client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on) - } - - @objc private func inputModeChanged() { - let seg = inputModeControl.selectedSegment - vadLabel.isHidden = seg != 0; vadSlider.isHidden = seg != 0 - pttKeyLabel.isHidden = seg != 1; changePttButton.isHidden = seg != 1 - if micStreamId != 0 { - client.setInputMode(currentInputMode()) - if seg == 0 { client.setVadThreshold(vadThresholdFromSlider()) } - if seg == 1 { client.setPushToTalk(false) } - } - } - - @objc private func vadSliderChanged() { - if micStreamId != 0 && inputModeControl.selectedSegment == 0 { - client.setVadThreshold(vadThresholdFromSlider()) - } - } - - @objc private func changePttClicked() { - let sheet = PttKeyCaptureSheet(currentKeyCode: pttKeyCode) - sheet.onComplete = { [weak self] keyCode in - guard let self, let keyCode else { return } - self.pttKeyCode = keyCode - self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))" - } - presentSheet(sheet) - } - - @objc private func refreshDevicesClicked() { loadInputDevices() } - - private func loadInputDevices() { - let devices = client.listDevices(.input) - devicePicker.removeAllItems() - for d in devices { - let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "") - item.representedObject = d.id - devicePicker.menu?.addItem(item) - } - if let def = devices.first(where: { $0.isDefault }) { - devicePicker.select(devicePicker.item(withTitle: def.name)) - } else if devicePicker.numberOfItems > 0 { - devicePicker.selectItem(at: 0) - } - } - - private func selectedDeviceId() -> String? { devicePicker.selectedItem?.representedObject as? String } - private func isDefaultDevice(_ id: String) -> Bool { - client.listDevices(.input).first(where: { $0.id == id })?.isDefault == true - } - - private func currentInputMode() -> VoiceCatInputMode { - switch inputModeControl.selectedSegment { - case 1: return .pushToTalk - case 2: return .alwaysOn - default: return .voiceActivation - } - } - - private func vadThresholdFromSlider() -> Float { - 0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0) + let muted = muteButton?.state == .on + let deafened = deafenButton?.state == .on + client.setSelfMute(micMuted: muted, deafened: deafened) + deafenButton?.image = NSImage(systemSymbolName: deafened ? "speaker.slash.fill" : "speaker.wave.2.fill", + accessibilityDescription: deafened ? "Undeafen" : "Deafen") } // MARK: - Text send @@ -889,12 +749,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { @objc private func sendClicked() { let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces) guard !msg.isEmpty else { return } - if let targetId = scopePicker.selectedItem?.representedObject as? UInt32 { - client.sendText(scope: .private, targetId: targetId, text: msg) - } else { - guard currentChannelId != 0 else { return } - client.sendText(scope: .channel, targetId: currentChannelId, text: msg) - } + guard currentChannelId != 0 else { return } + client.sendText(scope: .channel, targetId: currentChannelId, text: msg) composeField.stringValue = "" } @@ -968,11 +824,180 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { return displayedUsers[row] } + // MARK: - Voice menu (⌘⇧V / ⌘⇧S / ⌘⇧M / ⌘⇧D) + + private func buildVoiceMenu() { + let menu = NSMenu(title: "Voice") + + let joinItem = NSMenuItem(title: "Join Voice", action: #selector(micToggleClicked), + keyEquivalent: "v") + joinItem.keyEquivalentModifierMask = [.command, .shift] + joinItem.target = self + menu.addItem(joinItem) + + let screenItem = NSMenuItem(title: "Share Screen Audio", action: #selector(screenAudioClicked), + keyEquivalent: "s") + screenItem.keyEquivalentModifierMask = [.command, .shift] + screenItem.target = self + menu.addItem(screenItem) + + menu.addItem(.separator()) + + let muteItem = NSMenuItem(title: "Mute", action: #selector(muteClicked), + keyEquivalent: "m") + muteItem.keyEquivalentModifierMask = [.command, .shift] + muteItem.target = self + menu.addItem(muteItem) + + let deafenItem = NSMenuItem(title: "Deafen", action: #selector(deafenClicked), + keyEquivalent: "d") + deafenItem.keyEquivalentModifierMask = [.command, .shift] + deafenItem.target = self + menu.addItem(deafenItem) + + let topItem = NSMenuItem(title: "Voice", action: nil, keyEquivalent: "") + topItem.submenu = menu + voiceMenuItem = topItem + NSApp.mainMenu?.addItem(topItem) + } + + /// Toolbar/menu toggle for self-mute. Flips the toolbar button state and applies. + @objc private func muteClicked() { + muteButton?.state = (muteButton?.state == .on) ? .off : .on + muteChanged() + } + + /// Toolbar/menu toggle for self-deafen. Flips the toolbar button state and applies. + @objc private func deafenClicked() { + deafenButton?.state = (deafenButton?.state == .on) ? .off : .on + deafenChanged() + } + + // MARK: - Messages menu (⌘⇧N for New Private Message) + + private func buildMessagesMenu() { + let menu = NSMenu(title: "Messages") + + let newPmItem = NSMenuItem(title: "New Private Message…", action: #selector(openNewPmDialog), + keyEquivalent: "n") + newPmItem.keyEquivalentModifierMask = [.command, .shift] + newPmItem.target = self + menu.addItem(newPmItem) + + let topItem = NSMenuItem(title: "Messages", action: nil, keyEquivalent: "") + topItem.submenu = menu + messagesMenuItem = topItem + NSApp.mainMenu?.addItem(topItem) + } + + // MARK: - Private messaging + + /// Get or open a PM window for the given user. If one already exists, bring it to front. + @discardableResult + private func getOrOpenPmWindow(_ userId: UInt32) -> PrivateMessageWindowController { + if let existing = pmWindows[userId], existing.window != nil { + existing.window?.makeKeyAndOrderFront(nil) + return existing + } + let nick = nickname(for: userId) + let wc = PrivateMessageWindowController(client: client, otherUserId: userId, + nickname: nick, selfUserId: selfUserId) + pmWindows[userId] = wc + wc.showWindow(nil) + return wc + } + + /// Open the PM picker dialog (Messages → New Private Message…). Lists all server users. + @objc private func openNewPmDialog() { + let others = users.values + .filter { $0.id != selfUserId } + .sorted { $0.nickname < $1.nickname } + if others.isEmpty { + let alert = NSAlert() + alert.messageText = "No other users are connected to the server." + alert.addButton(withTitle: "OK") + guard let window else { return } + alert.beginSheetModal(for: window) { _ in } + return + } + let sheet = UserPickerSheet(users: others) + sheet.onComplete = { [weak self] userId in + guard let userId else { return } + self?.getOrOpenPmWindow(userId) + } + presentSheet(sheet) + } + + /// Open a PM window from the user context menu. + private func openPmWindow(_ user: User) { + getOrOpenPmWindow(user.id) + } + + // MARK: - Output volume + + @objc private func outputVolumeSliderChanged() { + let gain = Float(outputVolumeSlider?.doubleValue ?? 80) / 100.0 + client.setOutputVolume(gain) + } + + // MARK: - Settings window (⌘,) + + /// Add "Settings…" to the app menu (the first menu, named after the app). Mac convention: + /// Settings lives in the app menu with ⌘, key equivalent. + private func buildSettingsMenuItem() { + guard let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu else { return } + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), + keyEquivalent: ",") + settingsItem.target = self + // Insert before the Quit item (or at the end if Quit isn't found) + if let quitIndex = appMenu.items.firstIndex(where: { $0.keyEquivalent == "q" }) { + appMenu.insertItem(settingsItem, at: quitIndex) + appMenu.insertItem(NSMenuItem.separator(), at: quitIndex) + } else { + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(settingsItem) + } + settingsMenuItem = settingsItem + } + + @objc private func openSettings() { + if let existing = settingsWindowController, existing.window != nil { + existing.window?.makeKeyAndOrderFront(nil) + return + } + let wc = SettingsWindowController(client: client, mainController: self) + settingsWindowController = wc + wc.showWindow(nil) + } + // MARK: - NSWindowDelegate func windowWillClose(_ notification: Notification) { if let mon = pttMonitor { NSEvent.removeMonitor(mon) } + NotificationCenter.default.removeObserver(self) + // Close all PM windows + for (_, pmWin) in pmWindows { pmWin.close() } + pmWindows.removeAll() + // Close settings window + settingsWindowController?.close() + settingsWindowController = nil + // Remove app menus we added + if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) } + if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) } if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) } + // Remove Settings menu item + separator from app menu + if let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu { + if let item = settingsMenuItem { appMenu.removeItem(item) } + // Remove the separator we inserted before Quit + for mi in appMenu.items where mi.isSeparatorItem { + if let quitIndex = appMenu.items.firstIndex(where: { $0.keyEquivalent == "q" }), + let sepIndex = appMenu.items.firstIndex(of: mi), + sepIndex == quitIndex - 1 { + appMenu.removeItem(at: sepIndex) + break + } + } + } client.setPushToTalk(false) if screenStreamId != 0 { client.stopStream(screenStreamId) } if micStreamId != 0 { client.stopStream(micStreamId) } @@ -1002,7 +1027,8 @@ extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { let node = item as! ChannelNode let ch = node.channel - var label = ch.name + let userCount = users.values.filter { $0.channelId == ch.id }.count + var label = "\(ch.name) (\(userCount))" if ch.passwordProtected { label += " [pw]" } if ch.id == currentChannelId { label += " ►" } let id = NSUserInterfaceItemIdentifier("channelCell") @@ -1020,25 +1046,21 @@ extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate { extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate { func numberOfRows(in tableView: NSTableView) -> Int { - tableView === userTableView ? displayedUsers.count : activityLog.count + displayedUsers.count } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let id = NSUserInterfaceItemIdentifier("cell") let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView ?? makeTextCellView(identifier: id) - if tableView === userTableView { - let user = displayedUsers[row] - var label = user.nickname - if user.id == selfUserId { label += " (you)" } - if talkingUsers.contains(user.id) { label += " (talking)" } - if user.selfMicMuted || user.serverMuted { label += " (muted)" } - if user.selfDeafened || user.serverDeafened { label += " (deafened)" } - cell.textField?.stringValue = label - cell.setAccessibilityLabel(label) - } else { - cell.textField?.stringValue = activityLog[row] - } + let user = displayedUsers[row] + var label = user.nickname + if user.id == selfUserId { label += " (you)" } + if talkingUsers.contains(user.id) { label += " (talking)" } + if user.selfMicMuted || user.serverMuted { label += " (muted)" } + if user.selfDeafened || user.serverDeafened { label += " (deafened)" } + cell.textField?.stringValue = label + cell.setAccessibilityLabel(label) return cell } @@ -1098,6 +1120,9 @@ extension MainWindowController: NSMenuDelegate { if !isSelf { menu.addItem(.separator()) + menu.addItem(withTitle: "Send Private Message…", + action: #selector(userContextPm), keyEquivalent: "").target = self + menu.addItem(.separator()) if ownPermissions.canMoveUsers || ownPermissions.isAdmin { menu.addItem(withTitle: "Move to channel…", action: #selector(userContextMove), keyEquivalent: "").target = self } @@ -1163,6 +1188,10 @@ extension MainWindowController: NSMenuDelegate { } @objc private func userContextTune() { openUserTuning(row: userTableView.clickedRow) } + @objc private func userContextPm() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + openPmWindow(u) + } @objc private func userContextMove() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } moveUser(u) @@ -1196,7 +1225,7 @@ extension MainWindowController: NSMenuDelegate { // MARK: - PTT key name helper -private func keyCodeName(_ keyCode: UInt16) -> String { +func keyCodeName(_ keyCode: UInt16) -> String { let map: [UInt16: String] = [ 0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9", 0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12", @@ -1205,3 +1234,92 @@ private func keyCodeName(_ keyCode: UInt16) -> String { ] return map[keyCode] ?? "Key\(keyCode)" } + +// MARK: - NSToolbarDelegate + +extension MainWindowController: NSToolbarDelegate { + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarID.joinVoice, ToolbarID.shareScreen, .space, + ToolbarID.mute, ToolbarID.deafen, .flexibleSpace, + ToolbarID.outputVolume] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + + switch itemIdentifier { + case ToolbarID.joinVoice: + let btn = makeToolbarButton(symbol: "mic", label: "Join Voice", + action: #selector(micToggleClicked), + accessibilityLabel: "Join Voice — start sending microphone audio") + item.view = btn + item.label = "Join Voice" + item.paletteLabel = "Join Voice" + item.toolTip = "Join or leave voice (⌘⇧V)" + joinVoiceButton = btn + + case ToolbarID.shareScreen: + let btn = makeToolbarButton(symbol: "rectangle.on.rectangle.angled", label: "Share Screen Audio", + action: #selector(screenAudioClicked), + accessibilityLabel: "Share Screen Audio") + item.view = btn + item.label = "Share Screen Audio" + item.paletteLabel = "Share Screen Audio" + item.toolTip = "Share screen audio (⌘⇧S)" + shareScreenButton = btn + + case ToolbarID.mute: + let btn = makeToolbarButton(symbol: "mic.fill", label: "Mute", + action: #selector(muteClicked), + accessibilityLabel: "Mute microphone") + item.view = btn + item.label = "Mute" + item.paletteLabel = "Mute" + item.toolTip = "Mute/unmute mic (⌘⇧M)" + btn.isEnabled = false + muteButton = btn + + case ToolbarID.deafen: + let btn = makeToolbarButton(symbol: "speaker.wave.2.fill", label: "Deafen", + action: #selector(deafenClicked), + accessibilityLabel: "Deafen — mute all incoming audio") + item.view = btn + item.label = "Deafen" + item.paletteLabel = "Deafen" + item.toolTip = "Deafen/undeafen (⌘⇧D)" + btn.isEnabled = false + deafenButton = btn + + case ToolbarID.outputVolume: + let slider = NSSlider(value: 80, minValue: 0, maxValue: 100, target: self, + action: #selector(outputVolumeSliderChanged)) + slider.numberOfTickMarks = 0 + slider.setAccessibilityLabel("Output volume") + slider.setAccessibilityHelp("Global playback volume for all incoming audio") + let container = NSView(frame: NSRect(x: 0, y: 0, width: 140, height: 24)) + slider.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(slider) + NSLayoutConstraint.activate([ + slider.leadingAnchor.constraint(equalTo: container.leadingAnchor), + slider.trailingAnchor.constraint(equalTo: container.trailingAnchor), + slider.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + item.view = container + item.label = "Output Volume" + item.paletteLabel = "Output Volume" + item.toolTip = "Global output volume" + outputVolumeSlider = slider + + default: + return nil + } + + return item + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Windows/PrivateMessageWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/PrivateMessageWindowController.swift new file mode 100644 index 0000000..5d67986 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Windows/PrivateMessageWindowController.swift @@ -0,0 +1,165 @@ +import AppKit +import VoiceCatCore + +// PrivateMessageWindowController — a modeless window for a single private message +// conversation, owned and routed-to by MainWindowController. Mirrors the Windows client's +// `PrivateMessageForm` (clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs): each PM +// conversation opens in its own window instead of sharing the main chat log via a scope +// dropdown. MainWindowController routes incoming `.textMessage` events with `.private` scope +// to the right window; outgoing PMs are echoed back by the server and arrive through the +// same path (no optimistic local echo). +// +// The window is modeless (`NSWindow`) rather than a sheet because the user should be able to +// keep chatting in the main channel while a PM window is open — the same Discord/Slack +// pattern the Windows client follows. + +final class PrivateMessageWindowController: NSWindowController, NSWindowDelegate, NSTextViewDelegate { + + // MARK: - Owned state + + private let client: VoiceCatClient + let otherUserId: UInt32 + private let selfUserId: UInt32 + private let nickname: String + + // MARK: - UI + + private let historyTextView: NSTextView = { + let tv = NSTextView() + tv.isEditable = false + tv.isSelectable = true + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.textContainerInset = NSSize(width: 4, height: 4) + return tv + }() + + private let composeField = NSTextField() + private let sendButton = NSButton() + + // MARK: - Init + + init(client: VoiceCatClient, otherUserId: UInt32, nickname: String, selfUserId: UInt32) { + self.client = client + self.otherUserId = otherUserId + self.selfUserId = selfUserId + self.nickname = nickname + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Private Message — \(nickname)" + window.minSize = NSSize(width: 300, height: 220) + window.center() + super.init(window: window) + window.delegate = self + + buildUI() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - UI construction + + private func buildUI() { + guard let contentView = window?.contentView else { return } + + historyTextView.setAccessibilityLabel("Private message history with \(nickname)") + + let scroll = NSScrollView() + scroll.documentView = historyTextView + scroll.hasVerticalScroller = true + scroll.borderType = .noBorder + scroll.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(scroll) + + composeField.placeholderString = "Type a private message…" + composeField.setAccessibilityLabel("Private message to \(nickname)") + composeField.target = self + composeField.action = #selector(sendClicked) + + sendButton.title = "Send" + sendButton.bezelStyle = .rounded + sendButton.target = self + sendButton.action = #selector(sendClicked) + sendButton.setAccessibilityLabel("Send private message") + sendButton.keyEquivalent = "\r" + + let sep = NSBox() + sep.boxType = .separator + sep.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep) + + let composeBar = NSStackView(views: [composeField, sendButton]) + composeBar.orientation = .horizontal + composeBar.spacing = 6 + composeBar.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(composeBar) + + NSLayoutConstraint.activate([ + scroll.topAnchor.constraint(equalTo: contentView.topAnchor), + scroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scroll.bottomAnchor.constraint(equalTo: sep.topAnchor), + + sep.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + sep.heightAnchor.constraint(equalToConstant: 1), + sep.bottomAnchor.constraint(equalTo: composeBar.topAnchor), + + composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + composeBar.heightAnchor.constraint(equalToConstant: 28), + + sendButton.widthAnchor.constraint(equalToConstant: 70), + ]) + + window?.makeFirstResponder(composeField) + } + + // MARK: - Public — called by MainWindowController + + /// Append a message line. `isSelf=true` renders in gray (our own echoed outgoing message); + /// `false` renders in default color (incoming from the other user). Mirrors the Windows + /// `PrivateMessageForm.AppendMessage`. + func appendMessage(time: String, isSelf: Bool, sender: String, text: String) { + let line = "[\(time)] \(sender): \(text)\n" + let attrs: [NSAttributedString.Key: Any] = isSelf + ? [.foregroundColor: NSColor.secondaryLabelColor] + : [:] + let attributed = NSAttributedString(string: line, attributes: attrs) + historyTextView.textStorage?.append(attributed) + historyTextView.scrollToEndOfDocument(nil) + } + + /// Append a gray status/activity line (e.g. the other user disconnected). Mirrors the + /// Windows `PrivateMessageForm.AppendActivity`. + func appendActivity(_ text: String) { + let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) + let line = "[\(time)] \(text)\n" + let attributed = NSAttributedString(string: line, attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + ]) + historyTextView.textStorage?.append(attributed) + historyTextView.scrollToEndOfDocument(nil) + } + + // MARK: - Send + + @objc private func sendClicked() { + let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !msg.isEmpty else { return } + client.sendText(scope: .private, targetId: otherUserId, text: msg) + composeField.stringValue = "" + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + // Notify the owner so it can drop this controller from its pmWindows map. + // MainWindowController observes NSWindow.willCloseNotification on this window. + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift new file mode 100644 index 0000000..0f0606c --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift @@ -0,0 +1,297 @@ +import AppKit +import VoiceCatCore + +// SettingsWindowController — a modeless window containing the audio input settings that used +// to live in the main window's bottom voice panel: input mode (VAD/PTT/Always On), VAD +// sensitivity, PTT key selection, input device picker, and the live microphone level meter. +// +// The main window is now just toolbar + channels + users + chat; audio settings live here +// and are accessed via the app menu's "Settings…" item (⌘,). The window is modeless so the +// user can keep it open while interacting with the main window — essential for watching the +// level meter while adjusting VAD threshold or testing a device. +// +// Source-of-truth for the current settings lives in MainWindowController (so voice start can +// apply them even before this window has been opened). This window reads from and writes back +// to MainWindowController's stored properties, and applies changes to the client immediately +// when voice is active. + +final class SettingsWindowController: NSWindowController, NSWindowDelegate { + + // MARK: - References + + private let client: VoiceCatClient + weak var mainController: MainWindowController? + + // MARK: - UI + + private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"], + trackingMode: .selectOne, + target: nil, action: nil) + private let vadSlider: NSSlider = { + let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil) + s.numberOfTickMarks = 0 + return s + }() + private let vadLabel = NSTextField(labelWithString: "Sensitivity:") + private let pttKeyLabel = NSTextField(labelWithString: "(F8)") + private let changePttButton = NSButton() + private let devicePicker = NSPopUpButton() + private let refreshDevicesButton = NSButton() + private let levelMeter: NSProgressIndicator = { + let p = NSProgressIndicator() + p.style = .bar + p.isIndeterminate = false + p.minValue = 0 + p.maxValue = 100 + p.doubleValue = 0 + return p + }() + + // Cached VAD slider position so we can restore it when the window reopens. + private var vadSliderValue: Double = 50 + + // MARK: - Init + + init(client: VoiceCatClient, mainController: MainWindowController) { + self.client = client + self.mainController = mainController + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 260), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Audio Settings" + window.minSize = NSSize(width: 340, height: 220) + window.center() + super.init(window: window) + window.delegate = self + + buildUI() + syncFromMainController() + loadInputDevices() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - UI construction + + private func buildUI() { + guard let contentView = window?.contentView else { return } + + let inputModeLabel = NSTextField(labelWithString: "Input mode:") + inputModeLabel.setAccessibilityLabel("Input mode") + + inputModeControl.target = self + inputModeControl.action = #selector(inputModeChanged) + inputModeControl.selectedSegment = 0 + inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On") + + vadLabel.setAccessibilityLabel("VAD sensitivity") + vadSlider.target = self + vadSlider.action = #selector(vadSliderChanged) + vadSlider.setAccessibilityLabel("Voice activation sensitivity") + vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less") + + pttKeyLabel.setAccessibilityLabel("Current PTT key") + changePttButton.title = "Change…" + changePttButton.bezelStyle = .rounded + changePttButton.target = self + changePttButton.action = #selector(changePttClicked) + changePttButton.setAccessibilityLabel("Change push-to-talk key") + pttKeyLabel.isHidden = true + changePttButton.isHidden = true + + let deviceLabel = NSTextField(labelWithString: "Input device:") + deviceLabel.setAccessibilityLabel("Input device") + devicePicker.setAccessibilityLabel("Input audio device") + devicePicker.target = self + devicePicker.action = #selector(deviceChanged) + refreshDevicesButton.title = "↺" + refreshDevicesButton.bezelStyle = .rounded + refreshDevicesButton.target = self + refreshDevicesButton.action = #selector(refreshDevicesClicked) + refreshDevicesButton.setAccessibilityLabel("Refresh device list") + refreshDevicesButton.toolTip = "Refresh" + + let levelLabel = NSTextField(labelWithString: "Level:") + levelLabel.setAccessibilityLabel("Microphone input level") + levelMeter.setAccessibilityLabel("Microphone input level") + levelMeter.setAccessibilityHelp("Shows current microphone volume level") + + let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl]) + inputModeRow.orientation = .horizontal + inputModeRow.spacing = 8 + + let vadRow = NSStackView(views: [vadLabel, vadSlider]) + vadRow.orientation = .horizontal + vadRow.spacing = 8 + + let pttRow = NSStackView(views: [pttKeyLabel, changePttButton]) + pttRow.orientation = .horizontal + pttRow.spacing = 8 + + let deviceRow = NSStackView(views: [deviceLabel, devicePicker, refreshDevicesButton]) + deviceRow.orientation = .horizontal + deviceRow.spacing = 8 + + let levelRow = NSStackView(views: [levelLabel, levelMeter]) + levelRow.orientation = .horizontal + levelRow.spacing = 8 + + let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow]) + stack.orientation = .vertical + stack.spacing = 12 + stack.alignment = .leading + stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: contentView.topAnchor), + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200), + levelMeter.widthAnchor.constraint(equalToConstant: 200), + devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), + ]) + } + + // MARK: - Sync from MainWindowController + + /// Read the current settings from MainWindowController and update our UI to match. + /// Called on init and whenever the window is re-shown. + private func syncFromMainController() { + guard let mc = mainController else { return } + + switch mc.selectedInputMode { + case .voiceActivation: inputModeControl.selectedSegment = 0 + case .pushToTalk: inputModeControl.selectedSegment = 1 + case .alwaysOn: inputModeControl.selectedSegment = 2 + } + + vadSlider.doubleValue = vadSliderValue + pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))" + + updateConditionalControls() + } + + /// Show/hide VAD and PTT controls based on the selected input mode. + private func updateConditionalControls() { + let seg = inputModeControl.selectedSegment + vadLabel.isHidden = seg != 0 + vadSlider.isHidden = seg != 0 + pttKeyLabel.isHidden = seg != 1 + changePttButton.isHidden = seg != 1 + } + + // MARK: - Actions + + @objc private func inputModeChanged() { + updateConditionalControls() + let mode = currentInputMode() + mainController?.selectedInputMode = mode + if let mc = mainController, mc.micStreamId != 0 { + client.setInputMode(mode) + if mode == .voiceActivation { + client.setVadThreshold(vadThresholdFromSlider()) + } else if mode == .pushToTalk { + client.setPushToTalk(false) + } + } + } + + @objc private func vadSliderChanged() { + vadSliderValue = vadSlider.doubleValue + let threshold = vadThresholdFromSlider() + mainController?.vadThresholdValue = threshold + if let mc = mainController, mc.micStreamId != 0, mc.selectedInputMode == .voiceActivation { + client.setVadThreshold(threshold) + } + } + + @objc private func changePttClicked() { + guard let mc = mainController else { return } + let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode) + sheet.onComplete = { [weak self] keyCode in + guard let self, let keyCode else { return } + self.mainController?.pttKeyCode = keyCode + self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))" + } + presentSheet(sheet) + } + + @objc private func refreshDevicesClicked() { loadInputDevices() } + + @objc private func deviceChanged() { + let devId = devicePicker.selectedItem?.representedObject as? String + mainController?.selectedInputDeviceId = devId + if let mc = mainController, mc.micStreamId != 0, let devId { + client.setInputDevice(streamId: mc.micStreamId, deviceId: devId) + } + } + + // MARK: - Level meter (called by MainWindowController) + + func updateLevel(rms: Float) { + levelMeter.doubleValue = min(100, Double(rms * 400)) + levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent") + } + + func resetLevel() { + levelMeter.doubleValue = 0 + } + + // MARK: - Device enumeration + + private func loadInputDevices() { + let devices = client.listDevices(.input) + let prevSelected = devicePicker.selectedItem?.representedObject as? String + devicePicker.removeAllItems() + for d in devices { + let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "") + item.representedObject = d.id + devicePicker.menu?.addItem(item) + } + // Restore previous selection, or pick default, or first + if let prev = prevSelected, + let item = devicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) { + devicePicker.select(item) + } else if let def = devices.first(where: { $0.isDefault }) { + devicePicker.select(devicePicker.item(withTitle: def.name)) + } else if devicePicker.numberOfItems > 0 { + devicePicker.selectItem(at: 0) + } + // Sync the selected device back to main controller + let devId = devicePicker.selectedItem?.representedObject as? String + mainController?.selectedInputDeviceId = devId + } + + // MARK: - Helpers + + private func currentInputMode() -> VoiceCatInputMode { + switch inputModeControl.selectedSegment { + case 1: return .pushToTalk + case 2: return .alwaysOn + default: return .voiceActivation + } + } + + private func vadThresholdFromSlider() -> Float { + 0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0) + } + + private func presentSheet(_ vc: NSViewController) { + if let cvc = window?.contentViewController { + cvc.presentAsSheet(vc) + } else { + let cvc = NSViewController() + cvc.view = window!.contentView! + window?.contentViewController = cvc + cvc.presentAsSheet(vc) + } + } +}