import AppKit import VoiceCatCore // MARK: - Channel tree node private final class ChannelNode: NSObject { let channel: Channel var children: [ChannelNode] init(_ channel: Channel, children: [ChannelNode] = []) { self.channel = channel self.children = children } } // MARK: - Window controller final class MainWindowController: NSWindowController, NSWindowDelegate { // MARK: - Owned client private let client: VoiceCatClient private let selfUserId: UInt32 private let nickname: String // MARK: - State private var currentChannelId: UInt32 = 0 private var channels: [Channel] = [] private var users: [UInt32: User] = [:] private var talkingUsers: Set = [] private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false, canMoveUsers: false, canAdminAccounts: false, isAdmin: false) internal var micStreamId: UInt32 = 0 private var screenStreamId: UInt32 = 0 private var screenCapture: ScreenAudioCapture? private var auxStreamId: UInt32 = 0 // 0 = aux (second input device) stream not active private var auxCapture: InputDeviceCapture? // client-side capture feeding the aux stream // Last app/exclusion choice from the share picker; reused as the default next time. private var screenAudioSelection: ScreenAudioSelection = .default internal var pttKeyCode: UInt16 = 0x60 { // F8 didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) } } private var pttMonitor: Any? private var pttEngaged = false // guards the PTT cue against key-repeat private var serverMuted = false private var serverDeafened = false private var channelTree: [ChannelNode] = [] 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) // The input mode / VAD threshold / mic gain / PTT key persist via UserDefaults (didSet below) // so they survive relaunch; loadPersistedAudioSettings() restores them at startup and they are // pushed into the core when the mic stream starts (micToggleClicked). enum AudioDefaults { static let inputMode = "voice.inputMode" static let vadThreshold = "voice.vadThreshold" static let inputGain = "voice.inputGain" static let inputNoiseReduction = "voice.inputNoiseReduction" static let stereoMic = "voice.stereoMic" static let pttKeyCode = "voice.pttKeyCode" static let auxEnabled = "voice.auxEnabled" static let auxDeviceUID = "voice.auxDeviceUID" static let auxGain = "voice.auxGain" } internal var selectedInputMode: VoiceCatInputMode = .voiceActivation { didSet { UserDefaults.standard.set(Int(selectedInputMode.rawValue), forKey: AudioDefaults.inputMode) } } internal var vadThresholdValue: Float = 0.05 { didSet { UserDefaults.standard.set(vadThresholdValue, forKey: AudioDefaults.vadThreshold) } } internal var inputGain: Float = 1.0 { didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) } } internal var inputNoiseReduction: Bool = false { didSet { UserDefaults.standard.set(inputNoiseReduction, forKey: AudioDefaults.inputNoiseReduction) } } // Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the // wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Applied to // the core when the mic stream starts (micToggleClicked) and live via SettingsWindowController. internal var stereoMic: Bool = false { didSet { UserDefaults.standard.set(stereoMic, forKey: AudioDefaults.stereoMic) } } internal var selectedInputDeviceId: String? // Aux outgoing stream: a second hardware input device the client captures itself and feeds to // the core (kind = AUX_DEVICE, external_feed). Device + volume only — aux is always-on (the // core never gates AUX_DEVICE on VAD/PTT). auxDeviceUID is a Core Audio device UID (stable), // NOT a core/miniaudio id. auxGain is read live by the capture feed, so the slider is instant. internal var auxEnabled: Bool = false { didSet { UserDefaults.standard.set(auxEnabled, forKey: AudioDefaults.auxEnabled) } } internal var auxDeviceUID: String? { didSet { UserDefaults.standard.set(auxDeviceUID, forKey: AudioDefaults.auxDeviceUID) } } internal var auxGain: Float = 1.0 { didSet { UserDefaults.standard.set(auxGain, forKey: AudioDefaults.auxGain) } } // MARK: - UI components private let channelOutlineView = NSOutlineView() private let userTableView = NSTableView() 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 statusLabel = NSTextField(labelWithString: "") 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 joinVoiceItem: NSToolbarItem? private var shareScreenButton: NSButton? private var shareScreenItem: NSToolbarItem? private var muteButton: NSButton? private var deafenButton: NSButton? private var outputVolumeSlider: NSSlider? // MARK: - Init init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) { self.client = client self.selfUserId = selfUserId self.nickname = nickname let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) window.title = "VoiceCat — \(nickname)" window.minSize = NSSize(width: 640, height: 480) window.center() super.init(window: window) window.delegate = self loadPersistedAudioSettings() buildUI() buildToolbar() wireEvents() bootstrap() } /// Restore the saved input mode / VAD threshold / mic gain / PTT key from UserDefaults so a /// relaunch keeps the user's transmission settings instead of resetting to VAD defaults. private func loadPersistedAudioSettings() { let d = UserDefaults.standard if d.object(forKey: AudioDefaults.inputMode) != nil { let raw = UInt32(d.integer(forKey: AudioDefaults.inputMode)) selectedInputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation } if d.object(forKey: AudioDefaults.vadThreshold) != nil { vadThresholdValue = d.float(forKey: AudioDefaults.vadThreshold) } if d.object(forKey: AudioDefaults.inputGain) != nil { inputGain = d.float(forKey: AudioDefaults.inputGain) } if d.object(forKey: AudioDefaults.inputNoiseReduction) != nil { inputNoiseReduction = d.bool(forKey: AudioDefaults.inputNoiseReduction) } stereoMic = d.bool(forKey: AudioDefaults.stereoMic) if d.object(forKey: AudioDefaults.pttKeyCode) != nil { pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode)) } auxEnabled = d.bool(forKey: AudioDefaults.auxEnabled) if d.object(forKey: AudioDefaults.auxDeviceUID) != nil { auxDeviceUID = d.string(forKey: AudioDefaults.auxDeviceUID) } if d.object(forKey: AudioDefaults.auxGain) != nil { auxGain = d.float(forKey: AudioDefaults.auxGain) } } required init?(coder: NSCoder) { fatalError() } deinit {} // MARK: - UI construction private func buildUI() { guard let contentView = window?.contentView else { return } // Outer split: left panel (channels+users) | right panel (unified log) let outerSplit = NSSplitView() outerSplit.isVertical = true outerSplit.dividerStyle = .thin outerSplit.translatesAutoresizingMaskIntoConstraints = false // Left panel: channel outline + user table stacked vertically let innerSplit = NSSplitView() innerSplit.isVertical = false innerSplit.dividerStyle = .thin innerSplit.translatesAutoresizingMaskIntoConstraints = false let channelPanel = buildChannelPanel() let userPanel = buildUserPanel() innerSplit.addArrangedSubview(channelPanel) innerSplit.addArrangedSubview(userPanel) innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) // Right panel: unified log (chat + activity in one text view) let logPanel = buildLogPanel() outerSplit.addArrangedSubview(innerSplit) outerSplit.addArrangedSubview(logPanel) outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) let statusBar = buildStatusBar() let composeBar = buildComposeBar() contentView.addSubview(outerSplit) contentView.addSubview(statusBar) contentView.addSubview(composeBar) NSLayoutConstraint.activate([ outerSplit.topAnchor.constraint(equalTo: contentView.topAnchor), outerSplit.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), outerSplit.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), outerSplit.bottomAnchor.constraint(equalTo: statusBar.topAnchor), statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), statusBar.bottomAnchor.constraint(equalTo: composeBar.topAnchor), statusBar.heightAnchor.constraint(equalToConstant: 24), composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), composeBar.heightAnchor.constraint(equalToConstant: 36), ]) // 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] 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) _ = self } } private func buildChannelPanel() -> NSView { let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("channel")) channelOutlineView.addTableColumn(col) channelOutlineView.headerView = nil channelOutlineView.outlineTableColumn = col channelOutlineView.dataSource = self channelOutlineView.delegate = self channelOutlineView.doubleAction = #selector(channelDoubleClicked) channelOutlineView.target = self channelOutlineView.setAccessibilityLabel("Channel list") let sv = NSScrollView() sv.documentView = channelOutlineView sv.hasVerticalScroller = true sv.borderType = .noBorder sv.translatesAutoresizingMaskIntoConstraints = false let menu = NSMenu() menu.delegate = self channelOutlineView.menu = menu return sv } private func buildUserPanel() -> NSView { let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user")) userTableView.addTableColumn(col) userTableView.headerView = nil userTableView.dataSource = self userTableView.delegate = self userTableView.doubleAction = #selector(userDoubleClicked) userTableView.target = self userTableView.setAccessibilityLabel("Users in current channel") let menu = NSMenu() menu.delegate = self userTableView.menu = menu let sv = NSScrollView() sv.documentView = userTableView sv.hasVerticalScroller = true sv.borderType = .noBorder sv.translatesAutoresizingMaskIntoConstraints = false return sv } 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 = logTextView sv.hasVerticalScroller = true sv.borderType = .noBorder sv.translatesAutoresizingMaskIntoConstraints = false return sv } private func buildStatusBar() -> NSView { let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false statusLabel.translatesAutoresizingMaskIntoConstraints = false statusLabel.textColor = .secondaryLabelColor statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) bar.addSubview(statusLabel) NSLayoutConstraint.activate([ statusLabel.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), statusLabel.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8), statusLabel.centerYAnchor.constraint(equalTo: bar.centerYAnchor), ]) let sep = NSBox() sep.boxType = .separator sep.translatesAutoresizingMaskIntoConstraints = false bar.addSubview(sep) NSLayoutConstraint.activate([ sep.topAnchor.constraint(equalTo: bar.topAnchor), sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor), sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor), sep.heightAnchor.constraint(equalToConstant: 1), ]) return bar } private func buildComposeBar() -> NSView { let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false composeField.placeholderString = "Type a message to the current channel…" composeField.setAccessibilityLabel("Compose channel message") composeField.target = self; composeField.action = #selector(sendClicked) sendButton.title = "Send" sendButton.bezelStyle = .rounded sendButton.target = self; sendButton.action = #selector(sendClicked) sendButton.setAccessibilityLabel("Send message") sendButton.keyEquivalent = "\r" let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false [sep, composeField, sendButton].forEach { v in v.translatesAutoresizingMaskIntoConstraints = false bar.addSubview(v) } NSLayoutConstraint.activate([ sep.topAnchor.constraint(equalTo: bar.topAnchor), sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor), sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor), sep.heightAnchor.constraint(equalToConstant: 1), composeField.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6), composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor), sendButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8), sendButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor), sendButton.widthAnchor.constraint(equalToConstant: 60), ]) return bar } private func hstack(_ views: [NSView]) -> NSStackView { let s = NSStackView(views: views) s.orientation = .horizontal s.spacing = 8 s.alignment = .centerY 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() { client.onEvent = { [weak self] event in self?.handleEvent(event) } 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.selectedInputMode == .pushToTalk, self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event } let down = event.type == .keyDown self.client.setPushToTalk(down) // Cue only on the press transition — key-down auto-repeats while held. if down && !self.pttEngaged { EventFeedback.shared.play(.ptt) } self.pttEngaged = down 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 private func bootstrap() { channels = client.listChannels() let allUsers = client.listUsers() users.removeAll() for u in allUsers { users[u.id] = u if u.id == selfUserId { currentChannelId = u.channelId } } ownPermissions = client.getPermissions() // Apply initial output volume (default 80% — matches Windows client) client.setOutputVolume(0.8) refreshChannelTree() refreshUserList() updateStatusLabel() buildSettingsMenuItem() buildVoiceMenu() buildMessagesMenu() buildAdminMenu() addActivity("Connected to server as \(nickname)") EventFeedback.shared.play(.login) EventFeedback.shared.speak("Connected") } // MARK: - Event handling private func handleEvent(_ event: VoiceCatEvent) { switch event.type { case .channelList: channels = client.listChannels() let allUsers = client.listUsers() users.removeAll() for u in allUsers { users[u.id] = u if u.id == selfUserId { currentChannelId = u.channelId } } refreshChannelTree(); refreshUserList() case .userJoined: let u = User(id: event.userId, nickname: event.text ?? "User#\(event.userId)", isGuest: true, channelId: event.channelId, selfMicMuted: false, selfDeafened: false, serverMuted: false, serverDeafened: false, voiceSubscribed: false) users[event.userId] = u refreshChannelTree(); refreshUserList() if event.channelId == currentChannelId && event.userId != selfUserId { addActivity("\(u.nickname) joined the channel") EventFeedback.shared.play(.channelJoin) EventFeedback.shared.speak("\(u.nickname) joined") } case .userLeft: let nick = users[event.userId]?.nickname ?? "User#\(event.userId)" let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId users.removeValue(forKey: event.userId) talkingUsers.remove(event.userId) refreshChannelTree(); refreshUserList() if wasHere { addActivity("\(nick) left the channel") EventFeedback.shared.play(.channelLeave) EventFeedback.shared.speak("\(nick) left") } if let pmWin = pmWindows[event.userId] { pmWin.appendActivity("\(nick) disconnected from server") } case .userUpdated: let allUsers = client.listUsers() users.removeAll() for u in allUsers { users[u.id] = u if u.id == selfUserId { currentChannelId = u.channelId applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened) } } refreshChannelTree(); refreshUserList() case .joinResult: if event.result == .ok { currentChannelId = event.channelId if let self_ = users[selfUserId] { users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest, channelId: event.channelId, selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened, serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened, voiceSubscribed: self_.voiceSubscribed) } refreshChannelTree(); refreshUserList(); updateStatusLabel() let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)" addActivity("Joined \(name)") NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium]) } else { addActivity("Could not join channel: \(event.text ?? "\(event.result)")") } case .genericResult: let prefix = event.result == .ok ? "Success" : "Failed" let detail = event.text.map { ": \($0)" } ?? "" addActivity("\(prefix)\(detail) (\(event.result))") case .accountList: addActivity("Account list updated") case .textMessage: appendChatMessage(event) case .talkState: let talking = event.u32a == 1 if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) } refreshUserList() if event.userId == selfUserId { EventFeedback.shared.play(talking ? .vaStart : .vaStop) } if talking && event.userId != selfUserId, let u = users[event.userId], u.channelId == currentChannelId { addActivity("\(u.nickname) started talking") NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "\(u.nickname) started talking", .priority: NSAccessibilityPriorityLevel.medium]) } case .streamStarted: // Our own SCREEN_AUDIO stream is now live on the server — begin capture. if event.userId == selfUserId && event.streamId == screenStreamId { startScreenCapture() } guard let u = users[event.userId], u.channelId == currentChannelId else { break } let streams = client.listUserStreams(event.userId) let kind = streams.first(where: { $0.id == event.streamId })?.kind let kindStr = kind == .screenAudio ? "screen audio" : kind == .auxDevice ? "aux device" : "microphone" addActivity("\(u.nickname) started \(kindStr) stream") case .streamStopped: if event.userId == selfUserId { if event.streamId == micStreamId { micStreamId = 0 settingsWindowController?.resetLevel() } } else if let u = users[event.userId], u.channelId == currentChannelId { addActivity("\(u.nickname) stopped a stream") } case .voiceState: handleVoiceState(event) case .disconnected: handleDisconnected(event) default: break } } private func handleLevel(_ streamId: UInt32, _ rms: Float) { guard streamId == micStreamId else { return } settingsWindowController?.updateLevel(rms: rms) } // MARK: - Chat / unified log private func appendChatMessage(_ event: VoiceCatEvent) { let time: String if event.timestampUnixMs > 0 { let date = Date(timeIntervalSince1970: Double(event.timestampUnixMs) / 1000.0) time = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) } else { time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) } let sender = nickname(for: event.userId) let isSelf = event.userId == selfUserId let body = event.text ?? "" if event.textScope == .private { // 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 = isSelf ? event.channelId : event.userId let win = getOrOpenPmWindow(otherId) win.appendMessage(time: time, isSelf: isSelf, sender: sender, text: body) EventFeedback.shared.play(isSelf ? .pmSent : .pmRecv) if !isSelf { addActivity("Private message from \(sender)") EventFeedback.shared.speak("Private message from \(sender): \(body)") } } else { let line = "[\(time)] \(sender): \(body)\n" logTextView.textStorage?.append(NSAttributedString(string: line)) logTextView.scrollToEndOfDocument(nil) EventFeedback.shared.play(isSelf ? .channelSent : .channelRecv) if !isSelf { EventFeedback.shared.speak("\(sender): \(body)") } } } // MARK: - Activity log (appended to the unified log in gray) private func addActivity(_ 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, ]) logTextView.textStorage?.append(attributed) logTextView.scrollToEndOfDocument(nil) } // MARK: - UI refresh private func refreshChannelTree() { channelTree = buildChannelTree(channels) channelOutlineView.reloadData() channelOutlineView.expandItem(nil, expandChildren: true) } private func buildChannelTree(_ channels: [Channel]) -> [ChannelNode] { var nodeMap: [UInt32: ChannelNode] = [:] for ch in channels { nodeMap[ch.id] = ChannelNode(ch) } var roots: [ChannelNode] = [] for ch in channels.sorted(by: { $0.name < $1.name }) { if ch.parentId == 0 { roots.append(nodeMap[ch.id]!) } else if let parent = nodeMap[ch.parentId] { parent.children.append(nodeMap[ch.id]!) } } return roots.sorted { $0.channel.name < $1.channel.name } } private func refreshUserList() { displayedUsers = users.values .filter { $0.channelId == currentChannelId } .sorted { $0.nickname < $1.nickname } userTableView.reloadData() updateStatusLabel() } private func updateStatusLabel() { var suffix = "" if serverMuted { suffix += " [server muted]" } if serverDeafened { suffix += " [server deafened]" } if currentChannelId == 0 { statusLabel.stringValue = "Connected as \(nickname)\(suffix) — not in a channel." return } let chanName = channels.first(where: { $0.id == currentChannelId })?.name ?? "Channel #\(currentChannelId)" let count = users.values.filter { $0.channelId == currentChannelId }.count statusLabel.stringValue = "Connected as \(nickname)\(suffix) — \(chanName) (\(count) user\(count == 1 ? "" : "s"))" } private func applyServerMuteState(muted: Bool, deafened: Bool) { if muted && !serverMuted { addActivity("You have been server-muted") } if deafened && !serverDeafened { addActivity("You have been server-deafened") } if !muted && serverMuted { addActivity("Server mute cleared") } if !deafened && serverDeafened { addActivity("Server deafen cleared") } serverMuted = muted; serverDeafened = deafened updateStatusLabel() } private func handleDisconnected(_ event: VoiceCatEvent) { let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server." statusLabel.stringValue = msg addActivity(msg) EventFeedback.shared.play(event.result == .ok ? .logout : .connectionLost) EventFeedback.shared.speak(event.result == .ok ? "Disconnected" : "Connection lost") channelTree = []; channelOutlineView.reloadData() displayedUsers = []; userTableView.reloadData() users.removeAll(); talkingUsers.removeAll() stopScreenCapture() auxCapture?.stop(); auxCapture = nil // connection gone — drop capture, no stopStream currentChannelId = 0; micStreamId = 0; screenStreamId = 0; auxStreamId = 0 composeField.isEnabled = false; sendButton.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 private func buildAdminMenu() { guard ownPermissions.canAdminAccounts || ownPermissions.isAdmin else { return } let adminMenu = NSMenu(title: "Admin") let accountsItem = NSMenuItem(title: "Server accounts…", action: #selector(openAccounts), keyEquivalent: "") accountsItem.target = self adminMenu.addItem(accountsItem) let topItem = NSMenuItem(title: "Admin", action: nil, keyEquivalent: "") topItem.submenu = adminMenu adminMenuItem = topItem NSApp.mainMenu?.addItem(topItem) } @objc private func openAccounts() { presentSheet(AccountsSheet(client: client)) } // MARK: - Channel actions @objc private func channelDoubleClicked() { guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return } joinChannelRequest(node.channel.id) } private func joinChannelRequest(_ channelId: UInt32) { guard channelId != currentChannelId else { return } let channel = channels.first(where: { $0.id == channelId }) if channel?.passwordProtected == true { let sheet = PasswordPromptSheet(prompt: "Password for channel \"\(channel!.name)\":") sheet.onComplete = { [weak self] pw in self?.client.joinChannel(channelId, password: pw) } presentSheet(sheet) } else { client.joinChannel(channelId, password: nil) } } // MARK: - User actions @objc private func userDoubleClicked() { openUserTuning(row: userTableView.clickedRow) } private func openUserTuning(row: Int) { guard row >= 0, row < displayedUsers.count else { return } let user = displayedUsers[row] presentSheet(PerUserTuningSheet(client: client, userId: user.id, nickname: user.nickname)) } // MARK: - Voice controls @objc private func micToggleClicked() { if micStreamId == 0 { let result = client.joinVoice() if result != .ok { addActivity("Failed to join voice: \(result)") } } else { stopAuxStream() if screenStreamId != 0 { stopScreenAudio() } client.setPushToTalk(false) client.leaveVoice() } } private func handleVoiceState(_ event: VoiceCatEvent) { let subscribed = event.u32a != 0 if subscribed { let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone")) if result == .ok { micStreamId = streamId if let devId = selectedInputDeviceId { client.setInputDevice(streamId: streamId, deviceId: devId) } client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1) client.setInputMode(selectedInputMode) if selectedInputMode == .voiceActivation { client.setVadThreshold(vadThresholdValue) } client.setInputGain(inputGain) client.setInputNoiseReduction(inputNoiseReduction) setVoiceJoinedState(true) addActivity("Joined voice — microphone active") EventFeedback.shared.play(.voiceOn) NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium]) startAuxStream() } else { addActivity("Failed to start microphone: \(result)") } } else { micStreamId = 0 settingsWindowController?.resetLevel() setVoiceJoinedState(false) addActivity("Left voice") EventFeedback.shared.play(.voiceOff) } } // MARK: - Aux input stream (second hardware input device) /// Called by SettingsWindowController when the user toggles the aux checkbox. func applyAuxEnabled(_ on: Bool) { auxEnabled = on guard micStreamId != 0 else { return } // not in voice — applied on next Join Voice if on { startAuxStream() } else { stopAuxStream() } } /// Called by SettingsWindowController when the user picks a different aux device. func applyAuxDevice(_ uid: String?) { auxDeviceUID = uid if auxStreamId != 0 { restartAuxCapture() } } private func startAuxStream() { guard auxStreamId == 0, auxEnabled else { return } let (result, streamId) = client.startStream( StreamDescriptor(kind: .auxDevice, deviceId: nil, label: "Aux device", externalFeed: true)) guard result == .ok else { addActivity("Failed to start aux stream: \(result)") return } auxStreamId = streamId startAuxCapture() addActivity("Aux input stream active") } private func startAuxCapture() { let capture = InputDeviceCapture(deviceUID: auxDeviceUID) { [weak self] ptr, spc, ch in self?.feedAux(ptr, samplesPerChannel: spc, channels: ch) } auxCapture = capture do { try capture.start() } catch { addActivity("Failed to open aux input device") stopAuxStream() } } // Re-open the capture on a different device while the aux stream stays up (the core stream id // is unchanged — only the client-side capture source changes). private func restartAuxCapture() { guard auxStreamId != 0 else { return } auxCapture?.stop() auxCapture = nil startAuxCapture() } private func stopAuxStream() { auxCapture?.stop() auxCapture = nil if auxStreamId != 0 { client.stopStream(auxStreamId) auxStreamId = 0 } } // Fired on the capture's realtime thread. feedPcm is thread-safe. Gain is read live from // auxGain each frame so the volume slider takes effect immediately. private func feedAux(_ pcm: UnsafePointer, samplesPerChannel: Int, channels: UInt32) { guard auxStreamId != 0 else { return } let gain = auxGain if gain != 1.0 { let n = samplesPerChannel * Int(channels) var scaled = [Int16](repeating: 0, count: n) for i in 0.. String { var parts: [String] = [] switch selection.scope { case .entireDesktop: break case .onlyApps(let ids): if !ids.isEmpty { parts.append("only \(ids.count) app(s)") } case .allExcept(let ids): if !ids.isEmpty { parts.append("excluding \(ids.count) app(s)") } } // For .onlyApps the screen reader is already excluded, so don't claim it twice. if selection.excludeScreenReader { if case .onlyApps = selection.scope {} else { parts.append("no screen reader") } } return parts.isEmpty ? "" : " — " + parts.joined(separator: ", ") } @objc private func muteChanged() { 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() { 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 @objc private func sendClicked() { let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces) guard !msg.isEmpty else { return } guard currentChannelId != 0 else { return } client.sendText(scope: .channel, targetId: currentChannelId, text: msg) composeField.stringValue = "" } // MARK: - Moderation helpers private func moveUser(_ user: User) { let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId) sheet.onComplete = { [weak self] channelId in guard let channelId else { return } self?.client.moveUser(user.id, toChannel: channelId) } presentSheet(sheet) } private func kickUser(_ user: User) { let sheet = InputSheet(title: "Kick user", prompt: "Reason:", defaultValue: "Kicked by admin") sheet.onComplete = { [weak self] reason in self?.client.kickUser(user.id, reason: reason) } presentSheet(sheet) } private func banUser(_ user: User) { let sheet = BanUserSheet(nickname: user.nickname) sheet.onComplete = { [weak self] (reason, expiresMs) in self?.client.banUser(user.id, reason: reason, expiresUnixMs: expiresMs) } presentSheet(sheet) } private func setPermissions(_ user: User) { let sheet = PermissionsSheet(nickname: user.nickname, current: Permissions( canCreateTempChannel: false, canKick: false, canBan: false, canMoveUsers: false, canAdminAccounts: false, isAdmin: false)) sheet.onComplete = { [weak self] perms in guard let perms else { return } self?.client.setPermission(user.id, perms: perms) } presentSheet(sheet) } private func toggleServerMute(_ user: User) { client.setServerMute(user.id, muted: !user.serverMuted, deafened: user.serverDeafened) } private func toggleServerDeafen(_ user: User) { client.setServerMute(user.id, muted: user.serverMuted, deafened: !user.serverDeafened) } // MARK: - Helpers private func nickname(for userId: UInt32) -> String { if userId == selfUserId { return nickname } return users[userId]?.nickname ?? "User#\(userId)" } 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) } } private func selectedUserInTable() -> User? { let row = userTableView.selectedRow guard row >= 0, row < displayedUsers.count else { return nil } 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) } 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) for (_, pmWin) in pmWindows { pmWin.close() } pmWindows.removeAll() settingsWindowController?.close() settingsWindowController = nil if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) } if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) } if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) } 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) stopScreenCapture() if auxStreamId != 0 { stopAuxStream() } if screenStreamId != 0 { client.stopStream(screenStreamId) } if micStreamId != 0 { client.stopStream(micStreamId) } client.onLevel = nil client.onEvent = nil client.disconnect() } } // MARK: - NSOutlineView DataSource / Delegate extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { if item == nil { return channelTree.count } return (item as! ChannelNode).children.count } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { if item == nil { return channelTree[index] } return (item as! ChannelNode).children[index] } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { !(item as! ChannelNode).children.isEmpty } func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { let node = item as! ChannelNode let ch = node.channel 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") let cell = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView ?? makeTextCellView(identifier: id) cell.textField?.stringValue = label cell.setAccessibilityLabel(label) return cell } func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { true } } // MARK: - NSTableView DataSource / Delegate extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate { func numberOfRows(in tableView: NSTableView) -> Int { 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) 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 } private func makeTextCellView(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 } } // MARK: - NSMenuDelegate (context menus) extension MainWindowController: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() if menu === channelOutlineView.menu { buildChannelContextMenu(menu) } else if menu === userTableView.menu { buildUserContextMenu(menu) } } private func buildChannelContextMenu(_ menu: NSMenu) { let row = channelOutlineView.clickedRow if row >= 0, let node = channelOutlineView.item(atRow: row) as? ChannelNode { menu.addItem(withTitle: "Join", action: #selector(channelContextJoin), keyEquivalent: "").target = self menu.addItem(.separator()) if ownPermissions.isAdmin { let edit = menu.addItem(withTitle: "Edit channel…", action: #selector(channelContextEdit), keyEquivalent: "") edit.target = self; edit.representedObject = node let del = menu.addItem(withTitle: "Delete channel…", action: #selector(channelContextDelete), keyEquivalent: "") del.target = self; del.representedObject = node menu.addItem(.separator()) } } if ownPermissions.canCreateTempChannel || ownPermissions.isAdmin { menu.addItem(withTitle: "Create channel…", action: #selector(channelContextCreate), keyEquivalent: "").target = self } } private func buildUserContextMenu(_ menu: NSMenu) { let row = userTableView.clickedRow guard row >= 0, row < displayedUsers.count else { return } let user = displayedUsers[row] let isSelf = user.id == selfUserId menu.addItem(withTitle: "Adjust volume and noise settings…", action: #selector(userContextTune), keyEquivalent: "").target = self 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 } if ownPermissions.canKick || ownPermissions.isAdmin { menu.addItem(withTitle: "Kick…", action: #selector(userContextKick), keyEquivalent: "").target = self } if ownPermissions.canBan || ownPermissions.isAdmin { menu.addItem(withTitle: "Ban…", action: #selector(userContextBan), keyEquivalent: "").target = self } if ownPermissions.isAdmin { menu.addItem(.separator()) menu.addItem(withTitle: user.serverMuted ? "Server unmute" : "Server mute", action: #selector(userContextServerMute), keyEquivalent: "").target = self menu.addItem(withTitle: user.serverDeafened ? "Server undeafen" : "Server deafen", action: #selector(userContextServerDeafen), keyEquivalent: "").target = self menu.addItem(withTitle: "Set permissions…", action: #selector(userContextPermissions), keyEquivalent: "").target = self } } } @objc private func channelContextJoin() { guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return } joinChannelRequest(node.channel.id) } @objc private func channelContextCreate() { let sheet = ChannelEditSheet(channels: channels, editing: nil) sheet.onComplete = { [weak self] info in guard let info else { return } self?.client.createChannel(info) } presentSheet(sheet) } @objc private func channelContextEdit(_ sender: NSMenuItem) { guard let node = sender.representedObject as? ChannelNode else { return } let ch = node.channel let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name, topic: ch.topic, passwordProtected: ch.passwordProtected, password: nil, maxUsers: ch.maxUsers, sortOrder: ch.sortOrder, audio: ch.audio) let sheet = ChannelEditSheet(channels: channels, editing: info) sheet.onComplete = { [weak self] edited in guard let edited else { return } self?.client.editChannel(edited) } presentSheet(sheet) } @objc private func channelContextDelete(_ sender: NSMenuItem) { guard let node = sender.representedObject as? ChannelNode else { return } let alert = NSAlert() alert.messageText = "Delete channel?" alert.informativeText = "Delete \"\(node.channel.name)\"? This cannot be undone." alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning guard let window else { return } alert.beginSheetModal(for: window) { [weak self] response in if response == .alertFirstButtonReturn { self?.client.deleteChannel(node.channel.id) } } } @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) } @objc private func userContextKick() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } kickUser(u) } @objc private func userContextBan() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } banUser(u) } @objc private func userContextServerMute() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } toggleServerMute(u) } @objc private func userContextServerDeafen() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } toggleServerDeafen(u) } @objc private func userContextPermissions() { guard let u = selectedUserAt(userTableView.clickedRow) else { return } setPermissions(u) } private func selectedUserAt(_ row: Int) -> User? { guard row >= 0, row < displayedUsers.count else { return nil } return displayedUsers[row] } } // MARK: - PTT key name helper 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)" } // 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 joinVoiceItem = item 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 shareScreenItem = item 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 } }