Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
1643 lines
70 KiB
Swift
1643 lines
70 KiB
Swift
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<UInt32> = []
|
|
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 {
|
|
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
|
}
|
|
|
|
// 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()
|
|
|
|
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
|
|
channels.count, allUsers.count,
|
|
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
|
|
|
|
// 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) {
|
|
NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
|
|
event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
|
|
event.streamId, event.text ?? "(nil)")
|
|
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<Int16>, 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..<n {
|
|
let v = (Float(pcm[i]) * gain).rounded()
|
|
scaled[i] = Int16(max(-32768, min(32767, v)))
|
|
}
|
|
client.feedPcm(streamId: auxStreamId, pcm: scaled,
|
|
samplesPerChannel: samplesPerChannel, channels: channels)
|
|
} else {
|
|
client.feedPcm(streamId: auxStreamId, pcm: pcm,
|
|
samplesPerChannel: samplesPerChannel, channels: channels)
|
|
}
|
|
}
|
|
|
|
/// 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")
|
|
// The text shown beneath a custom-view toolbar item comes from the NSToolbarItem's
|
|
// label, not the inner button's title — update it too or the label stays "Join Voice".
|
|
joinVoiceItem?.label = joined ? "Leave Voice" : "Join Voice"
|
|
joinVoiceItem?.paletteLabel = 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 the user pick what to share (apps to include/exclude, screen-reader audio)
|
|
// before we announce anything. The picker remembers the previous choice.
|
|
let sheet = ScreenSharePickerSheet(selection: screenAudioSelection)
|
|
sheet.onComplete = { [weak self] selection in
|
|
guard let self, let selection else { return } // nil = cancelled
|
|
self.screenAudioSelection = selection
|
|
self.beginScreenAudioShare()
|
|
}
|
|
presentSheet(sheet)
|
|
} else {
|
|
stopScreenCapture()
|
|
client.stopStream(screenStreamId)
|
|
screenStreamId = 0
|
|
setShareScreenButton(active: false)
|
|
addActivity("Stopped sharing screen audio")
|
|
}
|
|
}
|
|
|
|
private func stopScreenAudio() {
|
|
guard screenStreamId != 0 else { return }
|
|
stopScreenCapture()
|
|
client.stopStream(screenStreamId)
|
|
screenStreamId = 0
|
|
setShareScreenButton(active: false)
|
|
addActivity("Stopped sharing screen audio")
|
|
}
|
|
|
|
/// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit
|
|
/// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
|
|
/// when the effective audio config — and thus the channel count — is known. See
|
|
/// startScreenCapture.
|
|
private func beginScreenAudioShare() {
|
|
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
|
|
if result == .ok {
|
|
screenStreamId = streamId
|
|
setShareScreenButton(active: true)
|
|
addActivity("Starting screen audio share…")
|
|
} else {
|
|
addActivity("Failed to start screen audio: \(result)")
|
|
}
|
|
}
|
|
|
|
private func setShareScreenButton(active: Bool) {
|
|
shareScreenButton?.title = active ? "Stop Screen Audio" : "Share Screen Audio"
|
|
shareScreenButton?.image = NSImage(
|
|
systemSymbolName: active ? "rectangle.on.rectangle.angled.fill" : "rectangle.on.rectangle.angled",
|
|
accessibilityDescription: active ? "Stop Screen Audio" : "Share Screen Audio")
|
|
// Update the toolbar item's label too (see setVoiceJoinedState) — the inner button's
|
|
// title alone doesn't change the text shown beneath a custom-view toolbar item.
|
|
shareScreenItem?.label = active ? "Stop Screen Audio" : "Share Screen Audio"
|
|
shareScreenItem?.paletteLabel = active ? "Stop Screen Audio" : "Share Screen Audio"
|
|
}
|
|
|
|
/// Start ScreenCaptureKit capture for our own SCREEN_AUDIO stream. Called from the
|
|
/// `.streamStarted` event handler, where the effective audio config is available. Captures
|
|
/// in the channel's mode (stereo when the channel is stereo) and feeds 20 ms PCM frames.
|
|
private func startScreenCapture() {
|
|
guard screenStreamId != 0, screenCapture == nil else { return }
|
|
let (cfgResult, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: screenStreamId)
|
|
let channels: UInt32 = (cfgResult == .ok && cfg?.stereo == true) ? 2 : 1
|
|
|
|
let streamId = screenStreamId
|
|
let capture = ScreenAudioCapture(channels: channels, selection: screenAudioSelection) { [weak self] pcm, samples, ch in
|
|
self?.client.feedPcm(streamId: streamId, pcm: pcm, samplesPerChannel: samples, channels: ch)
|
|
}
|
|
screenCapture = capture
|
|
|
|
Task { @MainActor in
|
|
do {
|
|
try await capture.start()
|
|
addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))"
|
|
+ "\(Self.scopeSuffix(for: screenAudioSelection))")
|
|
} catch {
|
|
// Most commonly: Screen Recording permission denied. Roll back the stream.
|
|
screenCapture = nil
|
|
if screenStreamId != 0 {
|
|
client.stopStream(screenStreamId)
|
|
screenStreamId = 0
|
|
setShareScreenButton(active: false)
|
|
}
|
|
addActivity("Screen audio capture failed — grant Screen Recording in System "
|
|
+ "Settings ▸ Privacy & Security, then try again. (\(error))")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopScreenCapture() {
|
|
screenCapture?.stop()
|
|
screenCapture = nil
|
|
}
|
|
|
|
/// A short human-readable description of the share scope for the activity log.
|
|
private static func scopeSuffix(for selection: ScreenAudioSelection) -> 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: - M5: 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)
|
|
}
|
|
|
|
/// 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)
|
|
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
|
|
}
|
|
}
|