feat(clients): expose all channel codec params + guest nickname everywhere

Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.

- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
  C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
  complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
  SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
  (backward-compatible Codable), shown in Guest mode, wired into the guest auth
  path -- guests could not set a display name on either before (only Windows)

Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
This commit is contained in:
2026-06-21 04:03:50 +02:00
parent b07362e525
commit 6b7f06a282
22 changed files with 292 additions and 24 deletions

View File

@@ -10,6 +10,31 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.**
- **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI
(`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a
latent ABI mismatch: Swift `AudioConfig` and the C# `VcAudioConfigNative` blittable struct
were each one `int` short of the native struct passed to `vc_create_channel`/`vc_edit_channel`.
Added `dred` through Swift (`Models.swift`, `Marshaling.swift`, `VoiceCatClient.toNative`) and
C# (`Structs.cs`, `Models.cs`, `Marshaling.cs`, `VoiceCatClient.cs`).
- **Windows:** added the one missing DRED checkbox to `ChannelEditDialog` (all other params
were already present).
- **macOS:** `ChannelEditSheet` now exposes the previously-hidden params — application profile,
sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX).
- **iOS:** `ChannelEditView` was name+topic only; rebuilt into a full create **and edit** form
(General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/
frame/application/packet-loss/complexity/FEC/DTX/DRED). Added `SessionState.editChannel` and an
"Edit" swipe action (admins) in `ChannelTreeView` + `ChannelBrowserView` (iOS previously had no
edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on
edit the audio fields start from codec defaults — same limitation as macOS/Windows.
- **Guest nickname.** Guests could not set a display name on iOS *or* macOS (the field was
absent/disabled; only Windows had it). Added a dedicated `nickname` to `SavedServer` on both
(backward-compatible Codable), a Nickname field shown in Guest mode (`AddServerView` /
`AddServerSheet`), and wired the guest auth path to use it (`AppState`, `ConnectWindowController`).
- **Verified:** `xcodebuild` Debug — macOS BUILD SUCCEEDED; iOS (sim, `ARCHS=arm64`) BUILD
SUCCEEDED. Core `ctest --preset dev` 22/23 (only `external_pcm` aborts on a pre-existing
shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed.
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView`
(people in the channel + sub-channels + an explicit "Join Channel" button with password

View File

@@ -94,7 +94,7 @@ internal enum Marshaling {
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
dtx: c.dtx != 0, complexity: c.complexity)
dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
}
static func permissions(_ p: vc_permissions) -> Permissions {

View File

@@ -193,15 +193,16 @@ public struct AudioConfig: Sendable, Equatable {
public let expectedPacketLoss: UInt32 // % 0..100
public let dtx: Bool
public let complexity: UInt32 // 0..10
public let dred: Bool // Deep REDundancy (Opus 1.6), off by default
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
complexity: UInt32 = 10) {
complexity: UInt32 = 10, dred: Bool = false) {
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
self.complexity = complexity
self.complexity = complexity; self.dred = dred
}
}

View File

@@ -586,6 +586,7 @@ extension AudioConfig {
n.expected_packet_loss = expectedPacketLoss
n.dtx = dtx ? 1 : 0
n.complexity = complexity
n.dred = dred ? 1 : 0
return n
}
}

View File

@@ -76,7 +76,7 @@ final class AppState {
// Auth is queued immediately the core serialises it behind TLS + TOFU.
switch server.authMode {
case .guest:
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
client.authenticateGuest(nick)
case .password:
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)

View File

@@ -6,17 +6,22 @@ struct SavedServer: Codable, Identifiable, Equatable {
var port: UInt16
var authMode: AuthMode
var savedUsername: String
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
var nickname: String?
var keychainTag: String
enum AuthMode: String, Codable { case guest, password }
init(id: UUID = UUID(), host: String, port: UInt16,
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
authMode: AuthMode = .guest, savedUsername: String = "",
nickname: String? = nil, keychainTag: String = "") {
self.id = id
self.host = host
self.port = port
self.authMode = authMode
self.savedUsername = savedUsername
self.nickname = nickname
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
}

View File

@@ -342,13 +342,14 @@ final class SessionState {
client.setServerMute(userId, muted: muted, deafened: deafened)
}
func createChannel(name: String, topic: String) {
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
passwordProtected: false, password: nil,
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
func createChannel(_ info: ChannelEdit) {
client.createChannel(info)
}
func editChannel(_ info: ChannelEdit) {
client.editChannel(info)
}
func deleteChannel(_ channelId: UInt32) {
client.deleteChannel(channelId)
}

View File

@@ -9,6 +9,7 @@ struct AddServerView: View {
@State private var host = ""
@State private var port = "7878"
@State private var authMode = SavedServer.AuthMode.guest
@State private var nickname = ""
@State private var username = ""
@State private var password = ""
@State private var savePassword = false
@@ -35,6 +36,13 @@ struct AddServerView: View {
.pickerStyle(.segmented)
.accessibilityLabel("Authentication mode")
if authMode == .guest {
TextField("Nickname (optional)", text: $nickname)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Guest nickname, optional display name")
}
if authMode == .password {
TextField("Username", text: $username)
.textContentType(.username)
@@ -67,6 +75,7 @@ struct AddServerView: View {
port = "\(s.port)"
authMode = s.authMode
username = s.savedUsername
nickname = s.nickname ?? ""
}
}
}
@@ -76,17 +85,21 @@ struct AddServerView: View {
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
if var s = editing {
s.host = trimmedHost
s.port = portNum
s.authMode = authMode
s.savedUsername = authMode == .password ? username : ""
s.nickname = nick
appState.updateServer(s, password: pw)
} else {
let s = SavedServer(host: trimmedHost, port: portNum,
authMode: authMode,
savedUsername: authMode == .password ? username : "")
savedUsername: authMode == .password ? username : "",
nickname: nick)
appState.addServer(s, password: pw)
}
dismiss()

View File

@@ -7,6 +7,7 @@ import VoiceCatCore
struct ChannelBrowserView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
@State private var editChannel: Channel?
var body: some View {
NavigationStack {
@@ -26,6 +27,16 @@ struct ChannelBrowserView: View {
}
}
}
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = ch
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
}
.navigationTitle("Channels")
@@ -44,6 +55,9 @@ struct ChannelBrowserView: View {
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
}
}

View File

@@ -1,12 +1,34 @@
import SwiftUI
import VoiceCatCore
struct ChannelEditView: View {
let channelId: UInt32?
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
// General
@State private var name = ""
@State private var topic = ""
@State private var parentId: UInt32 = 0
@State private var passwordProtected = false
@State private var password = ""
@State private var maxUsers = "0"
@State private var sortOrder = "0"
// Audio (Opus). Note: the channel list does not carry the current audio config, so when
// editing an existing channel these start from the codec defaults (same as macOS/Windows).
@State private var stereo = false
@State private var bitrate = "64000"
@State private var sampleRate = "48000"
@State private var frameMs: UInt32 = 20
@State private var application: UInt32 = 0
@State private var packetLoss = "5"
@State private var complexity = 10
@State private var fec = true
@State private var dtx = false
@State private var dred = false
private var isEditing: Bool { channelId != nil }
var body: some View {
NavigationStack {
@@ -17,9 +39,57 @@ struct ChannelEditView: View {
.accessibilityLabel("Channel name")
TextField("Topic (optional)", text: $topic)
.accessibilityLabel("Channel topic, optional")
Picker("Parent", selection: $parentId) {
Text("(root)").tag(UInt32(0))
ForEach(parentOptions) { ch in
Text(ch.name).tag(ch.id)
}
}
.accessibilityLabel("Parent channel")
Toggle("Password protected", isOn: $passwordProtected)
if passwordProtected {
SecureField("Password (blank keeps existing)", text: $password)
.accessibilityLabel("Channel password")
}
TextField("Max users (0 = unlimited)", text: $maxUsers)
.keyboardType(.numberPad)
.accessibilityLabel("Maximum users, zero means unlimited")
TextField("Sort order", text: $sortOrder)
.keyboardType(.numberPad)
.accessibilityLabel("Sort order")
}
Section("Audio (Opus)") {
Toggle("Stereo", isOn: $stereo)
TextField("Bitrate (bps)", text: $bitrate)
.keyboardType(.numberPad)
.accessibilityLabel("Bitrate in bits per second")
TextField("Sample rate (Hz)", text: $sampleRate)
.keyboardType(.numberPad)
.accessibilityLabel("Sample rate in Hz")
Picker("Frame", selection: $frameMs) {
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
Text("\(ms) ms").tag(ms)
}
}
.accessibilityLabel("Opus frame duration")
Picker("Application", selection: $application) {
Text("VoIP").tag(UInt32(0))
Text("Audio").tag(UInt32(1))
Text("Low delay").tag(UInt32(2))
}
.accessibilityLabel("Opus application profile")
TextField("Expected packet loss %", text: $packetLoss)
.keyboardType(.numberPad)
.accessibilityLabel("Expected packet loss percent, 0 to 100")
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
.accessibilityLabel("Opus complexity, 0 to 10")
Toggle("FEC (forward error correction)", isOn: $fec)
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
Toggle("DRED (deep redundancy)", isOn: $dred)
}
}
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -27,12 +97,67 @@ struct ChannelEditView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
session.createChannel(name: name, topic: topic)
save()
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear(perform: loadIfEditing)
}
}
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
private var parentOptions: [Channel] {
session.channels
.filter { $0.id != channelId }
.sorted { $0.name < $1.name }
}
private func loadIfEditing() {
guard let id = channelId,
let ch = session.channels.first(where: { $0.id == id }) else { return }
name = ch.name
topic = ch.topic
parentId = ch.parentId
passwordProtected = ch.passwordProtected
maxUsers = "\(ch.maxUsers)"
}
private func save() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return }
let audio = AudioConfig(
stereo: stereo,
sampleRate: UInt32(sampleRate) ?? 48000,
bitrateBps: UInt32(bitrate) ?? 64000,
frameMs: frameMs,
application: application,
fec: fec,
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
dtx: dtx,
complexity: UInt32(complexity),
dred: dred
)
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
let info = ChannelEdit(
id: channelId ?? 0,
parentId: parentId,
name: trimmedName,
topic: topic,
passwordProtected: passwordProtected,
password: pw,
maxUsers: UInt32(maxUsers) ?? 0,
sortOrder: UInt32(sortOrder) ?? 0,
audio: audio
)
if isEditing {
session.editChannel(info)
} else {
session.createChannel(info)
}
}
}

View File

@@ -12,6 +12,7 @@ struct ChannelNode: Identifiable {
struct ChannelTreeView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
@State private var editChannel: Channel?
@State private var channelPassword = ""
@State private var passwordChannelId: UInt32?
@@ -34,6 +35,16 @@ struct ChannelTreeView: View {
}
}
}
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = node.channel
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
.listStyle(.sidebar)
.toolbar {
@@ -59,6 +70,9 @@ struct ChannelTreeView: View {
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
.alert("Channel Password", isPresented: Binding(
get: { passwordChannelId != nil },
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }

View File

@@ -11,6 +11,9 @@ struct SavedServer: Codable, Identifiable {
var port: UInt16
var authMode: AuthMode
var savedUsername: String?
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty/nil falls back to the system full name.
var nickname: String?
var keychainTag: String?
var displayString: String {

View File

@@ -13,6 +13,7 @@ final class AddServerSheet: NSViewController {
return f
}()
private let authPicker = NSPopUpButton()
private let nicknameField = NSTextField()
private let usernameField = NSTextField()
private let passwordField = NSSecureTextField()
private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil)
@@ -35,6 +36,7 @@ final class AddServerSheet: NSViewController {
hostField.stringValue = s.host
portField.stringValue = "\(s.port)"
authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account")
nicknameField.stringValue = s.nickname ?? ""
usernameField.stringValue = s.savedUsername ?? ""
updateAuthVisibility()
}
@@ -47,6 +49,7 @@ final class AddServerSheet: NSViewController {
let hostLabel = NSTextField(labelWithString: "Host:")
let portLabel = NSTextField(labelWithString: "Port:")
let authLabel = NSTextField(labelWithString: "Auth:")
let nickLabel = NSTextField(labelWithString: "Nickname:")
let userLabel = NSTextField(labelWithString: "Username:")
let pwLabel = NSTextField(labelWithString: "Password:")
@@ -60,6 +63,9 @@ final class AddServerSheet: NSViewController {
authPicker.target = self; authPicker.action = #selector(authChanged)
authPicker.setAccessibilityLabel("Authentication mode")
nicknameField.placeholderString = "leave blank to use your system name"
nicknameField.setAccessibilityLabel("Guest nickname (display name)")
usernameField.placeholderString = "username"
usernameField.setAccessibilityLabel("Username")
@@ -79,6 +85,7 @@ final class AddServerSheet: NSViewController {
[hostLabel, hostField],
[portLabel, portField],
[authLabel, authPicker],
[nickLabel, nicknameField],
[userLabel, usernameField],
[pwLabel, passwordField],
[NSView(), savePwCheckbox],
@@ -110,6 +117,7 @@ final class AddServerSheet: NSViewController {
private func updateAuthVisibility() {
let isAccount = authPicker.titleOfSelectedItem == "Account"
nicknameField.isEnabled = !isAccount
usernameField.isEnabled = isAccount
passwordField.isEnabled = isAccount
savePwCheckbox.isEnabled = isAccount
@@ -130,6 +138,8 @@ final class AddServerSheet: NSViewController {
server.host = host; server.port = port
server.authMode = isGuest ? .guest : .password
server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces)
let nick = nicknameField.stringValue.trimmingCharacters(in: .whitespaces)
server.nickname = nick.isEmpty ? nil : nick
if !isGuest && savePwCheckbox.state == .on {
let pw = passwordField.stringValue

View File

@@ -25,7 +25,18 @@ final class ChannelEditSheet: NSViewController {
}()
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
private let dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
private let frameMsPicker = NSPopUpButton()
private let applicationPicker = NSPopUpButton()
private let sampleRateField: NSTextField = {
let f = NSTextField(); f.stringValue = "48000"; return f
}()
private let packetLossField: NSTextField = {
let f = NSTextField(); f.stringValue = "5"; return f
}()
private let complexityField: NSTextField = {
let f = NSTextField(); f.stringValue = "10"; return f
}()
init(channels: [Channel], editing: ChannelEdit?) {
self.channels = channels
@@ -72,14 +83,24 @@ final class ChannelEditSheet: NSViewController {
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
sortOrderField.setAccessibilityLabel("Sort order")
for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
for ms in ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
frameMsPicker.selectItem(withTitle: "20 ms")
frameMsPicker.setAccessibilityLabel("Opus frame duration")
applicationPicker.addItem(withTitle: "VoIP")
applicationPicker.addItem(withTitle: "Audio")
applicationPicker.addItem(withTitle: "Low delay")
applicationPicker.setAccessibilityLabel("Opus application profile")
fecCheckbox.state = .on
stereoCheckbox.setAccessibilityLabel("Stereo audio")
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
fecCheckbox.setAccessibilityLabel("Forward error correction")
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
let generalGrid = NSGridView(views: [
[NSTextField(labelWithString: "Name:"), nameField],
@@ -94,9 +115,13 @@ final class ChannelEditSheet: NSViewController {
let audioGrid = NSGridView(views: [
[NSTextField(labelWithString: "Bitrate:"), bitrateField],
[NSTextField(labelWithString: "Sample rate:"), sampleRateField],
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
[NSTextField(labelWithString: "Application:"), applicationPicker],
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
[NSTextField(labelWithString: "Complexity:"), complexityField],
[stereoCheckbox, fecCheckbox],
[dtxCheckbox, NSView()],
[dtxCheckbox, dredCheckbox],
])
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
audioGrid.column(at: 0).xPlacement = .trailing
@@ -149,8 +174,13 @@ final class ChannelEditSheet: NSViewController {
sortOrderField.stringValue = "\(e.sortOrder)"
stereoCheckbox.state = e.audio.stereo ? .on : .off
bitrateField.stringValue = "\(e.audio.bitrateBps)"
sampleRateField.stringValue = "\(e.audio.sampleRate)"
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
complexityField.stringValue = "\(e.audio.complexity)"
fecCheckbox.state = e.audio.fec ? .on : .off
dtxCheckbox.state = e.audio.dtx ? .on : .off
dredCheckbox.state = e.audio.dred ? .on : .off
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
let frameStr = "\(e.audio.frameMs) ms"
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
}
@@ -167,15 +197,24 @@ final class ChannelEditSheet: NSViewController {
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
let bitrate = UInt32(bitrateField.stringValue) ?? 64000
let sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
let frameMs = UInt32(frameMsStr) ?? 20
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
let audio = AudioConfig(
stereo: stereoCheckbox.state == .on,
sampleRate: sampleRate,
bitrateBps: bitrate,
frameMs: frameMs,
application: application,
fec: fecCheckbox.state == .on,
dtx: dtxCheckbox.state == .on
expectedPacketLoss: packetLoss,
dtx: dtxCheckbox.state == .on,
complexity: complexity,
dred: dredCheckbox.state == .on
)
let pwProtected = pwCheckbox.state == .on
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil

View File

@@ -216,7 +216,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
switch server.authMode {
case .guest:
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()
let nick = server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName()
newClient.authenticateGuest(nick)
case .password:
let username = server.savedUsername ?? ""
@@ -259,7 +259,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
case .authResult:
if event.result == .ok {
let nickname = server.authMode == .guest
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName())
? (server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName())
: (server.savedUsername ?? "")
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
} else {

View File

@@ -28,6 +28,7 @@ public sealed class ChannelEditDialog : Form
private CheckBox _chkFec = null!;
private NumericUpDown _numExpectedLoss = null!;
private CheckBox _chkDtx = null!;
private CheckBox _chkDred = null!;
private NumericUpDown _numComplexity = null!;
public ChannelEditInfo? Result { get; private set; }
@@ -183,7 +184,7 @@ public sealed class ChannelEditDialog : Form
private void BuildAudioPage(TabPage page, AudioConfigInfo? audio)
{
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10);
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false);
int y = 16;
int labelWidth = 150;
@@ -314,6 +315,17 @@ public sealed class ChannelEditDialog : Form
TabIndex = 18,
};
page.Controls.Add(_chkDtx);
y += 28;
_chkDred = new CheckBox
{
Text = "D&RED (deep redundancy)",
Location = new Point(inputX, y),
AutoSize = true,
Checked = audio.Dred,
TabIndex = 19,
};
page.Controls.Add(_chkDred);
}
private static void AddLabel(Control parent, string text, int x, int y, int width)
@@ -362,7 +374,8 @@ public sealed class ChannelEditDialog : Form
Fec: _chkFec.Checked,
ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
Dtx: _chkDtx.Checked,
Complexity: (uint)_numComplexity.Value);
Complexity: (uint)_numComplexity.Value,
Dred: _chkDred.Checked);
Result = new ChannelEditInfo(
Id: _editingId,

View File

@@ -876,7 +876,7 @@ public partial class MainForm : Form
var editInfo = new ChannelEditInfo(
channel.Id, channel.ParentId, channel.Name, channel.Topic,
channel.PasswordProtected, null, channel.MaxUsers, 0,
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10));
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false));
using var dlg = new ChannelEditDialog(_channels, editInfo);
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;

View File

@@ -188,7 +188,7 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
Password: null,
MaxUsers: 42,
SortOrder: 0,
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10))));
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateChannel did not succeed");
@@ -208,7 +208,7 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
null,
100,
0,
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10))));
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),

View File

@@ -96,7 +96,8 @@ internal static class Marshaling
native.Fec != 0,
native.ExpectedPacketLoss,
native.Dtx != 0,
native.Complexity);
native.Complexity,
native.Dred != 0);
public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new(
native.CanCreateTempChannel != 0,

View File

@@ -71,4 +71,5 @@ public sealed record AudioConfigInfo(
bool Fec,
uint ExpectedPacketLoss,
bool Dtx,
uint Complexity);
uint Complexity,
bool Dred);

View File

@@ -61,6 +61,7 @@ internal struct VcAudioConfigNative
public uint ExpectedPacketLoss;
public int Dtx;
public uint Complexity;
public int Dred;
}
[StructLayout(LayoutKind.Sequential)]

View File

@@ -346,6 +346,7 @@ public sealed class VoiceCatClient : IDisposable
ExpectedPacketLoss = info.Audio.ExpectedPacketLoss,
Dtx = info.Audio.Dtx ? 1 : 0,
Complexity = info.Audio.Complexity,
Dred = info.Audio.Dred ? 1 : 0,
}
};
}