Files
voice-cat/clients/windows/VoiceCat.App/Forms/MainForm.cs
Talon 6fe7bf0158
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
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).
2026-06-24 14:29:39 +02:00

1310 lines
50 KiB
C#

using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.App.Native;
using VoiceCat.App.Notifications;
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
/// </summary>
public partial class MainForm : Form
{
private readonly VoiceCatClient _client;
private readonly uint _selfUserId;
private readonly string _nickname;
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();
// Channel / user state
private uint _currentChannelId;
private List<ChannelInfo> _channels = [];
private readonly Dictionary<uint, UserInfo> _users = [];
private readonly HashSet<uint> _talkingUsers = [];
private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false);
// Voice state
private uint _micStreamId; // 0 = not started
private uint _screenStreamId; // 0 = not sharing screen audio
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
private uint _auxStreamId; // 0 = aux (second input device) stream not active
private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream
private Keys _pttKey = Keys.F8;
private bool _pttEngaged; // guards the PTT cue against key-repeat
private bool _rawInputRegistered; // true while the system-wide PTT keyboard sink is active
private bool _serverMuted;
private bool _serverDeafened;
// Private message windows keyed by the other user's ID
private readonly Dictionary<uint, PrivateMessageForm> _pmWindows = [];
// Voice menu items (kept as fields so we can update their text/state)
private ToolStripMenuItem _miJoinVoice = null!;
private ToolStripMenuItem _miScreenShare = null!;
public MainForm(VoiceCatClient client, uint selfUserId, string nickname, string serverName)
{
InitializeComponent();
_client = client;
_selfUserId = selfUserId;
_nickname = nickname;
Text = string.IsNullOrWhiteSpace(serverName)
? $"VoiceCat — {nickname}"
: $"VoiceCat — {nickname} @ {serverName}";
_client.EventReceived += OnEvent;
_client.LevelChanged += OnLevelChanged;
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
_pumpTimer.Tick += (_, _) => PttWatchdog();
_ownPermissions = SafeGetPermissions();
BuildMenus();
BuildChannelContextMenu();
BuildUserContextMenu();
_pumpTimer.Start();
// Apply initial output volume
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
// Channel tree
tvChannels.DoubleClick += TvChannels_DoubleClick;
tvChannels.KeyDown += TvChannels_KeyDown;
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { OpenUserTuning(); e.Handled = e.SuppressKeyPress = true; } };
// Compose
txtCompose.KeyDown += TxtCompose_KeyDown;
btnSend.Click += (_, _) => SendText();
// Toolbar voice buttons
tsbJoinVoice.Click += BtnMicToggle_Click;
tsbScreenShare.Click += BtnScreenShareToggle_Click;
// Output volume slider
trkOutputVolume.Scroll += TrkOutputVolume_Scroll;
// Voice controls
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
// PTT and global hotkeys. The mute/deafen/screen-share toggles (MainForm_HotkeyDown) are
// always focus-scoped. PTT is focus-scoped via KeyDown/KeyUp when system-wide PTT is OFF;
// when ON, the WM_INPUT path (WndProc + Raw Input) owns PTT for both focused and unfocused
// cases and the KeyDown/KeyUp handlers early-return.
KeyDown += MainForm_KeyDown;
KeyDown += MainForm_HotkeyDown;
KeyUp += MainForm_KeyUp;
Deactivate += (_, _) =>
{
// With system-wide PTT we WANT transmission to continue while unfocused, so don't
// release on deactivate — the Raw Input key-up (and PttWatchdog) handle release.
if (!_voiceSettings.SystemWidePtt && _micStreamId != 0) _client.SetPushToTalk(false);
};
ApplyPersistedVoiceSettings();
BootstrapFromServer();
}
private void ApplyPersistedVoiceSettings() =>
_pttKey = (Keys)_voiceSettings.PttKey;
// ── Startup ──────────────────────────────────────────────────────────────
private void BootstrapFromServer()
{
_channels = _client.ListChannels();
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId)
{
_currentChannelId = u.ChannelId;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
}
RefreshChannelTree();
RefreshUserList();
UpdateStatusLabel();
AddActivity($"Connected to server as {_nickname}");
_feedback.PlaySound(SoundEvent.Login);
_feedback.Speak("Connected");
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
}
// ── Menu / context menu builders ─────────────────────────────────────────
private PermissionsInfo SafeGetPermissions()
{
try { return _client.GetPermissions(); }
catch { return new PermissionsInfo(false, false, false, false, false, false); }
}
private void BuildMenus()
{
// Voice menu — always visible
var voiceMenu = new ToolStripMenuItem("&Voice");
_miJoinVoice = new ToolStripMenuItem("&Join Voice");
_miJoinVoice.Click += BtnMicToggle_Click;
_miJoinVoice.ShortcutKeyDisplayString = "Ctrl+Shift+V";
voiceMenu.DropDownItems.Add(_miJoinVoice);
_miScreenShare = new ToolStripMenuItem("Share Screen &Audio");
_miScreenShare.Click += BtnScreenShareToggle_Click;
_miScreenShare.ShortcutKeyDisplayString = "Ctrl+Shift+S";
voiceMenu.DropDownItems.Add(_miScreenShare);
menuStrip.Items.Add(voiceMenu);
// Messages menu — always visible
var messagesMenu = new ToolStripMenuItem("&Messages");
var miNewPm = new ToolStripMenuItem("&New Private Message...");
miNewPm.ShortcutKeys = Keys.Control | Keys.P;
miNewPm.Click += (_, _) => OpenNewPmDialog();
messagesMenu.DropDownItems.Add(miNewPm);
menuStrip.Items.Add(messagesMenu);
// Settings menu — always visible
var settingsMenu = new ToolStripMenuItem("&Settings");
var miAudio = new ToolStripMenuItem("&Audio...");
miAudio.Click += (_, _) =>
{
using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId,
applyAuxEnabled: on =>
{
if (_micStreamId == 0) return; // not in voice — applied on next Join Voice
if (on) StartAuxStream(); else StopAuxStream();
},
applyAuxDevice: _ =>
{
if (_auxStreamId != 0) RestartAuxCapture(); // settings.AuxDeviceId already updated
});
dlg.ShowDialog(this);
_pttKey = (Keys)_voiceSettings.PttKey;
ApplySystemWidePtt(); // the system-wide toggle may have changed
};
settingsMenu.DropDownItems.Add(miAudio);
var miNotifications = new ToolStripMenuItem("&Notifications...");
miNotifications.Click += (_, _) =>
{
using var dlg = new NotificationSettingsForm(_feedback);
dlg.ShowDialog(this);
};
settingsMenu.DropDownItems.Add(miNotifications);
menuStrip.Items.Add(settingsMenu);
// Admin menu — only if permitted
if (_ownPermissions.CanAdminAccounts)
{
var adminMenu = new ToolStripMenuItem("&Admin");
var miAccounts = new ToolStripMenuItem("&Server accounts...");
miAccounts.Click += (_, _) =>
{
using var dlg = new AccountsDialog(_client);
dlg.ShowDialog(this);
};
adminMenu.DropDownItems.Add(miAccounts);
menuStrip.Items.Add(adminMenu);
}
}
private void BuildChannelContextMenu()
{
var ctx = new ContextMenuStrip();
ctx.Opening += (_, _) =>
{
ctx.Items.Clear();
bool hasSelection = tvChannels.SelectedNode?.Tag is uint;
bool canCreate = _ownPermissions.CanCreateTempChannel || _ownPermissions.IsAdmin;
bool isAdmin = _ownPermissions.IsAdmin;
if (hasSelection)
{
ctx.Items.Add("&Join", null, (_, _) => ChannelTreeJoinSelected());
ctx.Items.Add(new ToolStripSeparator());
}
if (canCreate)
ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel());
if (hasSelection && isAdmin)
{
ctx.Items.Add("&Edit channel...", null, (_, _) => EditSelectedChannel());
ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
}
};
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
tvChannels.ContextMenuStrip = ctx;
}
private void BuildUserContextMenu()
{
var ctx = new ContextMenuStrip();
ctx.Opening += (_, _) =>
{
ctx.Items.Clear();
if (lstUsers.SelectedItem is not UserListItem item) return;
if (!_users.TryGetValue(item.UserId, out var user)) return;
bool isSelf = user.Id == _selfUserId;
var miTune = new ToolStripMenuItem("&Adjust volume and noise settings...");
miTune.Click += (_, _) => OpenUserTuning();
ctx.Items.Add(miTune);
if (!isSelf)
{
ctx.Items.Add(new ToolStripSeparator());
var miPm = new ToolStripMenuItem("Send &Private Message");
miPm.Click += (_, _) => OpenPmWindow(user.Id);
ctx.Items.Add(miPm);
ctx.Items.Add(new ToolStripSeparator());
if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin)
ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser());
if (_ownPermissions.CanKick || _ownPermissions.IsAdmin)
ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser());
if (_ownPermissions.CanBan || _ownPermissions.IsAdmin)
ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser());
ctx.Items.Add(new ToolStripSeparator());
if (_ownPermissions.IsAdmin)
{
ctx.Items.Add(user.ServerMuted ? "Server &unmute" : "Server &mute",
null, (_, _) => ToggleServerMuteSelected());
ctx.Items.Add(user.ServerDeafened ? "Server un&deafen" : "Server &deafen",
null, (_, _) => ToggleServerDeafenSelected());
ctx.Items.Add("&Set permissions...", null, (_, _) => SetPermissionsSelected());
}
}
};
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
lstUsers.ContextMenuStrip = ctx;
}
// Work around the WinForms ContextMenuStrip accessibility bug: when opened by
// keyboard (Shift+F10 / Apps key) the focused item is not set, so screen readers
// stay silent until the first arrow key. Selecting the first item ourselves on
// Opened raises the UIA focus event immediately.
private static void SelectFirstMenuItem(ContextMenuStrip ctx)
{
foreach (ToolStripItem item in ctx.Items)
{
if (item is ToolStripMenuItem && item.Enabled)
{
item.Select();
break;
}
}
}
// ── Event dispatch ────────────────────────────────────────────────────────
private void OnEvent(VoiceCatEvent ev)
{
switch (ev.Type)
{
case VcEventType.ChannelList:
HandleChannelList();
break;
case VcEventType.UserJoined:
HandleUserJoined(ev);
break;
case VcEventType.UserLeft:
HandleUserLeft(ev);
break;
case VcEventType.UserUpdated:
HandleUserUpdated();
break;
case VcEventType.JoinResult:
HandleJoinResult(ev);
break;
case VcEventType.GenericResult:
HandleGenericResult(ev);
break;
case VcEventType.AccountList:
AddActivity("Account list updated");
break;
case VcEventType.TextMessage:
HandleTextMessage(ev);
break;
case VcEventType.TalkState:
HandleTalkState(ev);
break;
case VcEventType.StreamStarted:
HandleStreamStarted(ev);
break;
case VcEventType.StreamStopped:
if (ev.UserId == _selfUserId)
{
if (ev.StreamId == _micStreamId)
{
_micStreamId = 0;
pbLevel.Value = 0;
}
}
else if (_users.TryGetValue(ev.UserId, out var stUser) &&
stUser.ChannelId == _currentChannelId)
AddActivity($"{stUser.Nickname} stopped a stream");
break;
case VcEventType.VoiceState:
HandleVoiceState(ev);
break;
case VcEventType.Disconnected:
HandleDisconnected(ev);
break;
}
}
// ── Event handlers ────────────────────────────────────────────────────────
private void HandleChannelList()
{
_channels = _client.ListChannels();
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
}
RefreshChannelTree();
RefreshUserList();
}
private void HandleUserJoined(VoiceCatEvent ev)
{
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
false, false, false, false, false);
_users[ev.UserId] = user;
RefreshChannelTree();
RefreshUserList();
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
{
AddActivity($"{user.Nickname} joined the channel");
_feedback.PlaySound(SoundEvent.ChannelJoin);
_feedback.Speak($"{user.Nickname} joined");
}
}
private void HandleUserLeft(VoiceCatEvent ev)
{
if (!_users.TryGetValue(ev.UserId, out var user)) return;
bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId;
_users.Remove(ev.UserId);
_talkingUsers.Remove(ev.UserId);
RefreshChannelTree();
RefreshUserList();
if (wasHere)
{
AddActivity($"{user.Nickname} left the channel");
_feedback.PlaySound(SoundEvent.ChannelLeave);
_feedback.Speak($"{user.Nickname} left");
}
if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
}
private void HandleUserUpdated()
{
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId)
{
_currentChannelId = u.ChannelId;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
}
RefreshChannelTree();
RefreshUserList();
}
private void HandleJoinResult(VoiceCatEvent ev)
{
if (ev.Result == VcResult.Ok)
{
_currentChannelId = ev.ChannelId;
if (_users.TryGetValue(_selfUserId, out var self))
_users[_selfUserId] = self with { ChannelId = ev.ChannelId };
RefreshChannelTree();
RefreshUserList();
UpdateStatusLabel();
string chanName = _channels.FirstOrDefault(c => c.Id == ev.ChannelId)?.Name
?? $"Channel #{ev.ChannelId}";
AddActivity($"Joined {chanName}");
}
else
{
AddActivity($"Could not join channel: {ev.Text ?? ev.Result.ToString()}");
}
}
private void HandleGenericResult(VoiceCatEvent ev)
{
string prefix = ev.Result == VcResult.Ok ? "Success" : "Failed";
string detail = !string.IsNullOrEmpty(ev.Text) ? $": {ev.Text}" : "";
AddActivity($"{prefix}{detail} ({ev.Result})");
}
private void HandleTextMessage(VoiceCatEvent ev)
{
string time = ev.TimestampUnixMs > 0
? DateTimeOffset.FromUnixTimeMilliseconds((long)ev.TimestampUnixMs)
.LocalDateTime.ToString("HH:mm")
: DateTime.Now.ToString("HH:mm");
string sender = GetNickname(ev.UserId);
bool isSelf = ev.UserId == _selfUserId;
string body = ev.Text ?? "";
if (ev.TextScope == VcTextScope.Private)
{
// For our own outgoing PM, ev.ChannelId carries the recipient user ID.
uint otherUserId = isSelf ? ev.ChannelId : ev.UserId;
var win = GetOrOpenPmWindow(otherUserId);
win.AppendMessage(time, isSelf, sender, body);
_feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv);
if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}");
}
else
{
AppendChat(time, sender, body);
_feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv);
if (!isSelf) _feedback.Speak($"{sender}: {body}");
}
}
private void HandleTalkState(VoiceCatEvent ev)
{
bool talking = ev.U32a == 1;
if (talking) _talkingUsers.Add(ev.UserId);
else _talkingUsers.Remove(ev.UserId);
RefreshUserList();
if (ev.UserId == _selfUserId)
_feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop);
if (talking && ev.UserId != _selfUserId &&
_users.TryGetValue(ev.UserId, out var tUser) &&
tUser.ChannelId == _currentChannelId)
AddActivity($"{tUser.Nickname} started talking");
}
private void HandleStreamStarted(VoiceCatEvent ev)
{
if (!_users.TryGetValue(ev.UserId, out var sUser) ||
sUser.ChannelId != _currentChannelId) return;
var streams = _client.ListUserStreams(ev.UserId);
var stream = streams.FirstOrDefault(s => s.StreamId == ev.StreamId);
string kind = stream?.Kind switch
{
VcStreamKind.ScreenAudio => "screen audio",
VcStreamKind.AuxDevice => "aux device",
_ => "microphone",
};
AddActivity($"{sUser.Nickname} started {kind} stream");
}
private void HandleDisconnected(VoiceCatEvent ev)
{
string msg = string.IsNullOrEmpty(ev.Text)
? "Disconnected from server."
: $"Disconnected: {ev.Text}";
lblStatus.Text = msg;
AddActivity(msg);
if (ev.Result == VcResult.Ok)
{
_feedback.PlaySound(SoundEvent.Logout);
_feedback.Speak("Disconnected");
}
else
{
_feedback.PlaySound(SoundEvent.ConnectionLost);
_feedback.Speak("Connection lost");
}
tvChannels.Nodes.Clear();
lstUsers.Items.Clear();
_users.Clear();
_talkingUsers.Clear();
_currentChannelId = 0;
_micStreamId = 0;
_screenStreamId = 0;
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
DisposeAuxCapture(); _auxStreamId = 0; // connection gone — drop capture, no StopStream
txtCompose.Enabled = false;
btnSend.Enabled = false;
tsbJoinVoice.Enabled = false;
tsbScreenShare.Enabled = false;
_miJoinVoice.Enabled = false;
_miScreenShare.Enabled = false;
foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear();
}
// ── Level meter ───────────────────────────────────────────────────────────
private void OnLevelChanged(uint streamId, float rms)
{
if (streamId == _micStreamId)
pbLevel.Value = Math.Min(100, (int)(rms * 400));
}
// ── UI refresh helpers ────────────────────────────────────────────────────
private void RefreshChannelTree()
{
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
bool hadFocus = tvChannels.Focused;
tvChannels.BeginUpdate();
tvChannels.Nodes.Clear();
var byParent = _channels
.GroupBy(c => c.ParentId)
.ToDictionary(g => g.Key, g => g.ToList());
void AddChildren(TreeNodeCollection nodes, uint parentId)
{
if (!byParent.TryGetValue(parentId, out var kids)) return;
foreach (var ch in kids.OrderBy(c => c.Name))
{
int count = _users.Values.Count(u => u.ChannelId == ch.Id);
var label = $"{ch.Name} ({count})";
if (ch.PasswordProtected) label += " [password]";
if (ch.Id == _currentChannelId) label += " ►";
var node = new TreeNode(label) { Tag = ch.Id };
nodes.Add(node);
AddChildren(node.Nodes, ch.Id);
}
}
AddChildren(tvChannels.Nodes, 0);
tvChannels.ExpandAll();
SeekAndSelect(tvChannels.Nodes, toSelect);
tvChannels.EndUpdate();
// A Nodes.Clear()/rebuild can drop keyboard focus and leave the screen reader
// without a current node. If the tree was focused before the refresh, restore
// focus and re-announce the now-current node (null-then-reselect forces UIA to
// fire a fresh focus event).
if (hadFocus && tvChannels.SelectedNode != null)
{
var node = tvChannels.SelectedNode;
tvChannels.Focus();
tvChannels.SelectedNode = null;
tvChannels.SelectedNode = node;
}
}
private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
{
if (channelId == 0) return false;
foreach (TreeNode n in nodes)
{
if (n.Tag is uint id && id == channelId) { tvChannels.SelectedNode = n; return true; }
if (SeekAndSelect(n.Nodes, channelId)) return true;
}
return false;
}
private void RefreshUserList()
{
// Preserve the keyboard selection across the rebuild: clearing the list resets
// SelectedIndex to -1, which would throw focus around every time a talking/mute
// indicator toggles. Capture the selected user id and reselect it afterwards.
uint? prevSel = (lstUsers.SelectedItem as UserListItem)?.UserId;
lstUsers.BeginUpdate();
lstUsers.Items.Clear();
foreach (var user in _users.Values
.Where(u => u.ChannelId == _currentChannelId)
.OrderBy(u => u.Nickname))
{
string 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)";
lstUsers.Items.Add(new UserListItem(user.Id, label));
}
if (prevSel is uint sel)
{
for (int i = 0; i < lstUsers.Items.Count; i++)
if (lstUsers.Items[i] is UserListItem item && item.UserId == sel)
{
lstUsers.SelectedIndex = i;
break;
}
}
lstUsers.EndUpdate();
UpdateStatusLabel();
}
private void UpdateStatusLabel()
{
string suffix = "";
if (_serverMuted) suffix += " [server muted]";
if (_serverDeafened) suffix += " [server deafened]";
if (_currentChannelId == 0)
{
lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel.";
return;
}
string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name
?? $"Channel #{_currentChannelId}";
int count = _users.Values.Count(u => u.ChannelId == _currentChannelId);
lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
}
// ── Voice controls ────────────────────────────────────────────────────────
private void BtnMicToggle_Click(object? sender, EventArgs e)
{
if (_micStreamId == 0)
{
var result = _client.JoinVoice();
if (result != VcResult.Ok)
AddActivity($"Failed to join voice: {result}");
}
else
{
StopAuxStream();
if (_screenStreamId != 0) StopScreenAudio();
_client.SetPushToTalk(false);
_client.LeaveVoice();
}
}
private void SetVoiceJoinedState(bool joined)
{
tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice";
_miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice";
chkMute.Enabled = joined;
chkDeafen.Enabled = joined;
}
private void HandleVoiceState(VoiceCatEvent ev)
{
bool subscribed = ev.U32a != 0;
if (subscribed)
{
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
if (result == VcResult.Ok)
{
_micStreamId = streamId;
var mode = (VcInputMode)_voiceSettings.InputMode;
if (_voiceSettings.InputDeviceId is string devId)
_client.SetInputDevice(streamId, devId);
_client.SetCaptureChannels(streamId, _voiceSettings.StereoMic ? 2u : 1u);
_client.SetInputMode(mode);
if (mode == VcInputMode.VoiceActivation)
_client.SetVadThreshold(VadThresholdFromSettings());
_client.SetInputGain(_voiceSettings.MicGain / 100f);
_client.SetInputNoiseReduction(_voiceSettings.MicNoiseReduction);
SetVoiceJoinedState(true);
AddActivity("Joined voice — microphone active");
_feedback.PlaySound(SoundEvent.VoiceOn);
StartAuxStream();
}
else
{
AddActivity($"Failed to start microphone: {result}");
}
}
else
{
SetVoiceJoinedState(false);
AddActivity("Left voice");
_feedback.PlaySound(SoundEvent.VoiceOff);
}
}
private void ApplySelfMute() =>
_client.SetSelfMute(chkMute.Checked, chkDeafen.Checked);
private void BtnScreenShareToggle_Click(object? sender, EventArgs e)
{
if (_screenStreamId == 0)
{
StartScreenAudio();
}
else
{
StopScreenAudio();
}
}
private void StartScreenAudio()
{
using var picker = new AppAudioPickerDialog();
if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return;
var scope = picker.ChosenScope;
if (scope is EntireDesktop { ExcludeSelf: false })
{
// Existing whole-device WASAPI loopback path — core handles it.
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start screen audio: {result}");
return;
}
_screenStreamId = streamId;
AddActivity("Sharing screen audio: entire desktop");
}
else
{
// External-feed path: suppress core loopback, C# mixer feeds PCM. Covers the
// per-app modes and "entire desktop except VoiceCat" (single EXCLUDE of self).
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start screen audio: {result}");
return;
}
_screenStreamId = streamId;
_screenMixer = new ProcessAudioMixer();
_screenMixer.Start(scope, _client, streamId);
string desc = scope switch
{
EntireDesktop => "entire desktop (excluding VoiceCat)",
OnlyApps o => $"only {o.Pids.Count} app(s)",
AllExceptApps a => $"all except {a.Names[0]}",
_ => "apps",
};
AddActivity($"Sharing screen audio: {desc}");
}
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
}
private void StopScreenAudio()
{
_screenMixer?.Stop();
_screenMixer?.Dispose();
_screenMixer = null;
_client.StopStream(_screenStreamId);
_screenStreamId = 0;
tsbScreenShare.Text = "Share Screen Audio";
_miScreenShare.Text = "Share Screen &Audio";
AddActivity("Stopped sharing screen audio");
}
// ── Aux input stream (second hardware input device) ─────────────────────────
// A second outgoing stream (kind = AUX_DEVICE, external_feed). The core can't open a second
// capture device, so we capture the chosen device here and feed PCM in — the same external-
// feed pipeline as per-app screen audio. Tied to the voice session: started on Join Voice
// (when enabled) and stopped on Leave Voice. The aux is always-on (the core never gates
// AUX_DEVICE on VAD/PTT); volume is applied client-side before feeding.
private void StartAuxStream()
{
if (_auxStreamId != 0 || !_voiceSettings.AuxEnabled) return;
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.AuxDevice, "Aux device");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start aux stream: {result}");
return;
}
_auxStreamId = streamId;
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
if (!_auxCapture.Start())
{
AddActivity("Failed to open aux input device");
StopAuxStream();
return;
}
AddActivity("Aux input stream active");
}
private void StopAuxStream()
{
DisposeAuxCapture();
if (_auxStreamId != 0)
{
_client.StopStream(_auxStreamId);
_auxStreamId = 0;
}
}
// 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 void RestartAuxCapture()
{
if (_auxStreamId == 0) return;
DisposeAuxCapture();
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
if (!_auxCapture.Start())
AddActivity("Failed to open aux input device");
}
private void DisposeAuxCapture()
{
if (_auxCapture == null) return;
_auxCapture.PcmFrameReady -= OnAuxPcmFrame;
_auxCapture.Stop();
_auxCapture.Dispose();
_auxCapture = null;
}
// Fired on the capture thread. vc_stream_feed_pcm is thread-safe, so feed directly. Gain is
// read live from settings each frame (so the volume slider takes effect immediately).
private void OnAuxPcmFrame(short[] pcm, int samplesPerChannel, int channels)
{
if (_auxStreamId == 0) return;
float gain = _voiceSettings.AuxGain / 100f;
if (gain != 1f)
{
for (int i = 0; i < pcm.Length; i++)
pcm[i] = (short)Math.Clamp((int)MathF.Round(pcm[i] * gain),
short.MinValue, short.MaxValue);
}
_client.StreamFeedPcm(_auxStreamId, pcm, samplesPerChannel, (uint)channels);
}
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
private float VadThresholdFromSettings() =>
0.1f * (1f - (_voiceSettings.VadThresholdSlider - 1f) / 99f);
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
{
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
if (ActiveControl is TextBox or RichTextBox) return;
_client.SetPushToTalk(true);
if (!_pttEngaged) // first key-down only, not auto-repeat
{
_pttEngaged = true;
_feedback.PlaySound(SoundEvent.Ptt);
}
e.Handled = e.SuppressKeyPress = true;
}
private void MainForm_HotkeyDown(object? sender, KeyEventArgs e)
{
if (!e.Control || !e.Shift) return;
if (ActiveControl is TextBox or RichTextBox) return;
switch (e.KeyCode)
{
case Keys.V:
BtnMicToggle_Click(null, EventArgs.Empty);
break;
case Keys.S:
BtnScreenShareToggle_Click(null, EventArgs.Empty);
break;
case Keys.M:
chkMute.Checked = !chkMute.Checked;
ApplySelfMute();
break;
case Keys.D:
chkDeafen.Checked = !chkDeafen.Checked;
ApplySelfMute();
break;
default:
return;
}
// A handled hotkey: suppress the follow-on WM_CHAR so the focused control
// (channel tree / user list) doesn't emit the system "ding".
e.Handled = e.SuppressKeyPress = true;
}
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
{
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
_client.SetPushToTalk(false);
_pttEngaged = false;
e.Handled = e.SuppressKeyPress = true;
}
// ── System-wide PTT (Raw Input / WM_INPUT) ────────────────────────────────
// When the user enables "system-wide" PTT we observe the PTT key via the Raw Input API so it
// works while another app is focused. See VoiceCat.App.Native.RawInput for why this is used in
// preference to a low-level keyboard hook (antivirus keylogger heuristics).
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ApplySystemWidePtt();
}
protected override void OnHandleDestroyed(EventArgs e)
{
if (_rawInputRegistered)
{
RawInput.UnregisterKeyboardSink();
_rawInputRegistered = false;
}
base.OnHandleDestroyed(e);
}
protected override void WndProc(ref Message m)
{
if (m.Msg == RawInput.WM_INPUT && _voiceSettings.SystemWidePtt)
HandleRawInput(m.LParam);
base.WndProc(ref m);
}
/// <summary>Register or tear down the background keyboard sink to match the current
/// <see cref="VoiceSettings.SystemWidePtt"/> setting. Safe to call repeatedly.</summary>
private void ApplySystemWidePtt()
{
if (!IsHandleCreated) return; // OnHandleCreated will (re)apply once the handle exists
bool want = _voiceSettings.SystemWidePtt;
if (want && !_rawInputRegistered)
{
_rawInputRegistered = RawInput.RegisterKeyboardSink(Handle);
}
else if (!want && _rawInputRegistered)
{
RawInput.UnregisterKeyboardSink();
_rawInputRegistered = false;
// Release any PTT that was held via Raw Input so it can't stick after switching modes.
if (_micStreamId != 0) _client.SetPushToTalk(false);
_pttEngaged = false;
}
}
private void HandleRawInput(IntPtr lParam)
{
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk || _micStreamId == 0)
return;
if (!RawInput.TryParseKey(lParam, out ushort vkey, out bool keyUp)) return;
if (vkey != (ushort)_pttKey) return;
if (keyUp)
{
_client.SetPushToTalk(false);
_pttEngaged = false;
return;
}
// Key-down. Don't transmit while typing into our OWN text fields — matches the
// focus-scoped guard. When VoiceCat is in the background ContainsFocus is false, so the
// key still transmits (the whole point of system-wide PTT).
if (ContainsFocus && ActiveControl is TextBox or RichTextBox) return;
_client.SetPushToTalk(true);
if (!_pttEngaged) // first key-down only, not auto-repeat
{
_pttEngaged = true;
_feedback.PlaySound(SoundEvent.Ptt);
}
}
/// <summary>Watchdog (driven by the pump timer) that releases system-wide PTT if the key-up
/// was never observed — e.g. across an RDP or lock-screen focus switch — so PTT can't stick.</summary>
private void PttWatchdog()
{
if (!_pttEngaged || !_voiceSettings.SystemWidePtt || _micStreamId == 0) return;
if (!RawInput.IsKeyDown((int)_pttKey))
{
_client.SetPushToTalk(false);
_pttEngaged = false;
}
}
// ── Channel navigation ────────────────────────────────────────────────────
private void TvChannels_DoubleClick(object? sender, EventArgs e)
{
if (tvChannels.SelectedNode?.Tag is uint channelId)
JoinChannelRequest(channelId);
}
private void TvChannels_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter && tvChannels.SelectedNode?.Tag is uint channelId)
{
JoinChannelRequest(channelId);
e.Handled = e.SuppressKeyPress = true;
}
}
private void JoinChannelRequest(uint channelId)
{
if (channelId == _currentChannelId) return;
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
string? password = null;
if (channel?.PasswordProtected == true)
{
using var dlg = new PasswordPromptDialog($"Password for channel \"{channel.Name}\":");
if (dlg.ShowDialog(this) != DialogResult.OK) return;
password = dlg.Password;
}
_client.JoinChannel(channelId, password);
}
private void ChannelTreeJoinSelected()
{
if (tvChannels.SelectedNode?.Tag is uint channelId)
JoinChannelRequest(channelId);
}
// ── Private messaging ─────────────────────────────────────────────────────
private PrivateMessageForm GetOrOpenPmWindow(uint userId)
{
if (!_pmWindows.TryGetValue(userId, out var win) || win.IsDisposed)
{
string nick = GetNickname(userId);
win = new PrivateMessageForm(_client, userId, nick, _selfUserId);
win.FormClosed += (_, _) => _pmWindows.Remove(userId);
_pmWindows[userId] = win;
// Show without an owner: an owned form is forced to stay above MainForm and pulls
// focus back to itself, so the main window can't be worked in while a PM is open.
// OnFormClosed already closes any open PM windows, so this doesn't leak.
win.Show();
}
else
{
if (win.WindowState == FormWindowState.Minimized)
win.WindowState = FormWindowState.Normal;
// Raise it for this explicit user-initiated open without the owner-style focus trap.
win.Activate();
}
return win;
}
private void OpenPmWindow(uint userId) => GetOrOpenPmWindow(userId);
private void OpenNewPmDialog()
{
var others = _users.Values
.Where(u => u.Id != _selfUserId)
.OrderBy(u => u.Nickname)
.ToList();
if (others.Count == 0)
{
MessageBox.Show(this, "No other users are connected to the server.",
"New Private Message", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
using var dlg = new UserPickerDialog(others);
if (dlg.ShowDialog(this) == DialogResult.OK)
OpenPmWindow(dlg.SelectedUserId);
}
// ── M5: Moderation helpers ────────────────────────────────────────────────
private void UpdateSelfServerMuteState(bool muted, bool deafened)
{
bool wasMuted = _serverMuted;
bool wasDeafened = _serverDeafened;
_serverMuted = muted;
_serverDeafened = deafened;
if (muted && !wasMuted) AddActivity("You have been server-muted");
if (deafened && !wasDeafened) AddActivity("You have been server-deafened");
if (!muted && wasMuted) AddActivity("Server mute cleared");
if (!deafened && wasDeafened) AddActivity("Server deafen cleared");
UpdateStatusLabel();
}
private void CreateChannel()
{
using var dlg = new ChannelEditDialog(_channels);
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
_client.CreateChannel(dlg.Result);
}
private void EditSelectedChannel()
{
if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
if (channel is null) return;
var editInfo = new ChannelEditInfo(
channel.Id, channel.ParentId, channel.Name, channel.Topic,
channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder,
channel.Audio);
using var dlg = new ChannelEditDialog(_channels, editInfo);
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
_client.EditChannel(dlg.Result);
}
private void DeleteSelectedChannel()
{
if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
if (channel is null) return;
var confirm = MessageBox.Show(this, $"Delete channel \"{channel.Name}\"?",
"VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
_client.DeleteChannel(channelId);
}
private UserInfo? SelectedUser()
{
if (lstUsers.SelectedItem is not UserListItem item) return null;
_users.TryGetValue(item.UserId, out var user);
return user;
}
private void MoveSelectedUser()
{
var user = SelectedUser();
if (user is null) return;
using var dlg = new MoveUserDialog(_channels, user.ChannelId);
if (dlg.ShowDialog(this) != DialogResult.OK) return;
_client.MoveUser(user.Id, dlg.SelectedChannelId);
}
private void KickSelectedUser()
{
var user = SelectedUser();
if (user is null) return;
using var dlg = new InputDialog("Kick user", "&Reason:", "Kicked by admin");
string? reason = dlg.ShowDialog(this) == DialogResult.OK ? dlg.TextValue : null;
_client.KickUser(user.Id, reason);
}
private void BanSelectedUser()
{
var user = SelectedUser();
if (user is null) return;
using var dlg = new BanUserDialog(user.Nickname);
if (dlg.ShowDialog(this) != DialogResult.OK) return;
_client.BanUser(user.Id, dlg.Reason, dlg.ExpiresUnixMs);
}
private void ToggleServerMuteSelected()
{
var user = SelectedUser();
if (user is null) return;
_client.SetServerMute(user.Id, !user.ServerMuted, user.ServerDeafened);
}
private void ToggleServerDeafenSelected()
{
var user = SelectedUser();
if (user is null) return;
_client.SetServerMute(user.Id, user.ServerMuted, !user.ServerDeafened);
}
private void SetPermissionsSelected()
{
var user = SelectedUser();
if (user is null) return;
var current = new PermissionsInfo(false, false, false, false, false, false);
using var dlg = new PermissionsDialog(user.Nickname, current);
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
_client.SetPermission(user.Id, dlg.Result);
}
// ── Per-user tuning ───────────────────────────────────────────────────────
private void OpenUserTuning()
{
if (lstUsers.SelectedItem is not UserListItem item) return;
if (!_users.TryGetValue(item.UserId, out var user)) return;
using var dlg = new PerUserTuningDialog(_client, item.UserId, user.Nickname);
dlg.ShowDialog(this);
}
// ── Text chat ─────────────────────────────────────────────────────────────
private void TxtCompose_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
SendText();
e.Handled = e.SuppressKeyPress = true;
}
}
private void SendText()
{
string msg = txtCompose.Text.Trim();
if (string.IsNullOrEmpty(msg)) return;
if (_currentChannelId == 0) return;
_client.SendText(VcTextScope.Channel, _currentChannelId, msg);
txtCompose.Clear();
}
// ── Log helpers ───────────────────────────────────────────────────────────
private void AppendChat(string time, string sender, string text)
{
rtbLog.AppendText($"[{time}] {sender}: {text}\n");
rtbLog.ScrollToCaret();
}
private void AddActivity(string text)
{
string entry = $"[{DateTime.Now:HH:mm}] {text}\n";
int selStart = rtbLog.TextLength;
rtbLog.AppendText(entry);
rtbLog.Select(selStart, entry.Length);
rtbLog.SelectionColor = Color.Gray;
rtbLog.Select(rtbLog.TextLength, 0);
rtbLog.SelectionColor = rtbLog.ForeColor;
rtbLog.ScrollToCaret();
}
private string GetNickname(uint userId)
{
if (userId == _selfUserId) return _nickname;
return _users.TryGetValue(userId, out var u) ? u.Nickname : $"User#{userId}";
}
// ── Lifetime ──────────────────────────────────────────────────────────────
protected override void OnFormClosed(FormClosedEventArgs e)
{
_pumpTimer.Stop();
_client.LevelChanged -= OnLevelChanged;
_client.EventReceived -= OnEvent;
foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear();
if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
if (_auxStreamId != 0) StopAuxStream();
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
_client.Dispose();
_feedback.Dispose();
base.OnFormClosed(e);
}
// ── Private types ─────────────────────────────────────────────────────────
private sealed class UserListItem(uint userId, string display)
{
public uint UserId { get; } = userId;
public override string ToString() => display;
}
}