Files
voice-cat/clients/windows/VoiceCat.App/Forms/MainForm.cs
Talon a88656f2fa feat(windows): wire screen-audio sharing into the WinForms client
The core already supported SCREEN_AUDIO capture on Windows (post-M3 WASAPI
loopback via VOICECAT_HAS_LOOPBACK) and the C# Interop layer was complete
(VcStreamKind.ScreenAudio, StartStream/StopStream/SetRemoteStream). Only the
UI was missing -- no core, proto, or C ABI changes needed.

Adds a 'Share Screen Audio' toggle to the voice panel, independent of mic
voice (can share without joining voice). Disconnect/teardown now stops the
screen stream cleanly. New smoke test exercises the full StartStream ->
StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
2026-06-17 22:47:28 +02:00

977 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
/// Phase E: channel tree, user list, chat. Phase F: voice controls, device pickers, PTT,
/// per-user tuning.
/// </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 };
// 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 Keys _pttKey = Keys.F8;
private bool _serverMuted;
private bool _serverDeafened;
public MainForm(VoiceCatClient client, uint selfUserId, string nickname)
{
InitializeComponent();
_client = client;
_selfUserId = selfUserId;
_nickname = nickname;
Text = $"VoiceCat — {nickname}";
_client.EventReceived += OnEvent;
_client.LevelChanged += OnLevelChanged;
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
// M5: load own permissions and build permission-aware menus.
_ownPermissions = SafeGetPermissions();
BuildMenus();
BuildChannelContextMenu();
BuildUserContextMenu();
_pumpTimer.Start();
// Channel tree
tvChannels.DoubleClick += TvChannels_DoubleClick;
tvChannels.KeyDown += TvChannels_KeyDown;
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); };
// Compose
txtCompose.KeyDown += TxtCompose_KeyDown;
btnSend.Click += (_, _) => SendText();
// Voice controls
btnMicToggle.Click += BtnMicToggle_Click;
btnScreenShareToggle.Click += BtnScreenShareToggle_Click;
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
radioVad.CheckedChanged += RadioVad_CheckedChanged;
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
btnChangePtt.Click += BtnChangePtt_Click;
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
// PTT (focus-scoped — works only while this form has focus; documented limitation)
KeyDown += MainForm_KeyDown;
KeyUp += MainForm_KeyUp;
Deactivate += (_, _) =>
{
if (_micStreamId != 0) _client.SetPushToTalk(false); // release PTT on focus loss
};
BootstrapFromServer();
}
// ── 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();
RebuildScopeCombo();
UpdateStatusLabel();
AddActivity($"Connected to server as {_nickname}");
}
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);
LoadInputDevices();
}
// ── Menu / context menu builders (M5) ─────────────────────────────────────
private PermissionsInfo SafeGetPermissions()
{
try { return _client.GetPermissions(); }
catch { return new PermissionsInfo(false, false, false, false, false, false); }
}
private void BuildMenus()
{
if (!_ownPermissions.CanAdminAccounts) return;
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());
}
};
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());
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());
}
}
};
lstUsers.ContextMenuStrip = ctx;
}
// ── 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 (_users.TryGetValue(ev.UserId, out var stUser) &&
stUser.ChannelId == _currentChannelId)
AddActivity($"{stUser.Nickname} stopped a stream");
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();
RebuildScopeCombo();
}
private void HandleUserJoined(VoiceCatEvent ev)
{
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
false, false, false, false);
_users[ev.UserId] = user;
RefreshUserList();
RebuildScopeCombo();
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
AddActivity($"{user.Nickname} joined the channel");
}
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);
RefreshUserList();
RebuildScopeCombo();
if (wasHere) AddActivity($"{user.Nickname} left the channel");
}
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();
RebuildScopeCombo();
}
private void HandleJoinResult(VoiceCatEvent ev)
{
if (ev.Result == VcResult.Ok)
{
_currentChannelId = ev.ChannelId;
// The authoritative UserEvent::UPDATED broadcast also reflects this move, but it
// may arrive after this result — patch our own entry now for instant, flicker-free
// feedback. The later UPDATED is idempotent (sets the same channel).
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);
// For a private message, ev.ChannelId carries target_id (the recipient user_id).
// When the server relays our own private message back to us, label it with the
// recipient; an incoming private message just shows "(private)".
string prefix = ev.TextScope == VcTextScope.Private
? (ev.UserId == _selfUserId ? $"(private to {GetNickname(ev.ChannelId)}) " : "(private) ")
: "";
rtbChat.AppendText($"[{time}] {prefix}{sender}: {ev.Text ?? ""}\n");
rtbChat.ScrollToCaret();
if (ev.TextScope == VcTextScope.Private && ev.UserId != _selfUserId)
AddActivity($"Private message from {sender}");
}
private void HandleTalkState(VoiceCatEvent ev)
{
bool talking = ev.U32a == 1;
if (talking) _talkingUsers.Add(ev.UserId);
else _talkingUsers.Remove(ev.UserId);
RefreshUserList();
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);
tvChannels.Nodes.Clear();
lstUsers.Items.Clear();
_users.Clear();
_talkingUsers.Clear();
_currentChannelId = 0;
_micStreamId = 0;
_screenStreamId = 0;
txtCompose.Enabled = false;
btnSend.Enabled = false;
btnMicToggle.Enabled = false;
btnScreenShareToggle.Enabled = false;
}
// ── 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;
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))
{
var label = ch.Name;
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();
}
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()
{
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));
}
lstUsers.EndUpdate();
}
private void RebuildScopeCombo()
{
uint prevTarget = (cboScope.SelectedItem is ScopeItem prev &&
prev.Scope == VcTextScope.Private)
? prev.TargetId : 0u;
cboScope.Items.Clear();
cboScope.Items.Add(new ScopeItem("Channel", VcTextScope.Channel, 0));
foreach (var u in _users.Values.OrderBy(u => u.Nickname))
{
if (u.Id == _selfUserId) continue;
cboScope.Items.Add(new ScopeItem($"Private: {u.Nickname}", VcTextScope.Private, u.Id));
}
if (prevTarget != 0)
{
for (int i = 1; i < cboScope.Items.Count; i++)
{
if (cboScope.Items[i] is ScopeItem si && si.TargetId == prevTarget)
{
cboScope.SelectedIndex = i;
return;
}
}
}
if (cboScope.Items.Count > 0) cboScope.SelectedIndex = 0;
}
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")})";
}
// ── Device management ─────────────────────────────────────────────────────
private void LoadInputDevices()
{
var devices = _client.ListDevices(VcDeviceKind.Input);
DeviceInfo? prevDevice = cboInputDevice.SelectedItem as DeviceInfo;
cboInputDevice.Items.Clear();
foreach (var d in devices) cboInputDevice.Items.Add(d);
// Restore selection or pick default
if (prevDevice is not null)
{
for (int i = 0; i < cboInputDevice.Items.Count; i++)
{
if (cboInputDevice.Items[i] is DeviceInfo d && d.Id == prevDevice.Id)
{
cboInputDevice.SelectedIndex = i;
return;
}
}
}
// Select default device
for (int i = 0; i < cboInputDevice.Items.Count; i++)
{
if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault)
{
cboInputDevice.SelectedIndex = i;
return;
}
}
if (cboInputDevice.Items.Count > 0) cboInputDevice.SelectedIndex = 0;
}
// ── Voice controls ────────────────────────────────────────────────────────
private void BtnMicToggle_Click(object? sender, EventArgs e)
{
if (_micStreamId == 0)
{
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
if (result == VcResult.Ok)
{
_micStreamId = streamId;
// Apply selected device if not default
if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev)
_client.SetInputDevice(streamId, dev.Id);
// Apply current mode
_client.SetInputMode(CurrentInputMode());
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
btnMicToggle.Text = "Leave &Voice";
chkMute.Enabled = true;
chkDeafen.Enabled = true;
radioVad.Enabled = true;
radioPtt.Enabled = true;
radioAlwaysOn.Enabled = true;
AddActivity("Joined voice — microphone active");
}
else
{
AddActivity($"Failed to start microphone: {result}");
}
}
else
{
_client.SetPushToTalk(false); // release PTT if held
_client.StopStream(_micStreamId);
_micStreamId = 0;
pbLevel.Value = 0;
btnMicToggle.Text = "&Join Voice";
chkMute.Enabled = false;
chkDeafen.Enabled = false;
radioVad.Enabled = false;
radioPtt.Enabled = false;
radioAlwaysOn.Enabled = false;
AddActivity("Left voice");
}
}
private void ApplySelfMute() =>
_client.SetSelfMute(chkMute.Checked, chkDeafen.Checked);
// Screen-audio share — independent of mic voice. The core bypasses VAD/PTT/self-mute/
// server-mute for non-MIC kinds (client.cpp on_capture_frame), so no input-mode or mute
// state applies here. WASAPI loopback captures the default render endpoint (whole-device,
// not process-specific — docs/voice.md §9).
private void BtnScreenShareToggle_Click(object? sender, EventArgs e)
{
if (_screenStreamId == 0)
{
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
if (result == VcResult.Ok)
{
_screenStreamId = streamId;
btnScreenShareToggle.Text = "Stop Screen &Audio";
AddActivity("Started sharing screen audio");
}
else
{
AddActivity($"Failed to start screen audio: {result}");
}
}
else
{
_client.StopStream(_screenStreamId);
_screenStreamId = 0;
btnScreenShareToggle.Text = "Share Screen &Audio";
AddActivity("Stopped sharing screen audio");
}
}
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
{
if (!radioVad.Checked) return;
lblPttKey.Visible = false;
btnChangePtt.Visible = false;
lblVadThreshold.Visible = true;
trkVadThreshold.Visible = true;
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.VoiceActivation);
_client.SetVadThreshold(VadThresholdFromSlider());
}
}
private void RadioPtt_CheckedChanged(object? sender, EventArgs e)
{
if (!radioPtt.Checked) return;
lblPttKey.Text = $"({_pttKey})";
lblPttKey.Visible = true;
btnChangePtt.Visible = true;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.PushToTalk);
_client.SetPushToTalk(false); // start released
}
}
private void RadioAlwaysOn_CheckedChanged(object? sender, EventArgs e)
{
if (!radioAlwaysOn.Checked) return;
lblPttKey.Visible = false;
btnChangePtt.Visible = false;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
}
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
{
if (_micStreamId != 0 && radioVad.Checked)
_client.SetVadThreshold(VadThresholdFromSlider());
}
// threshold = 0.1 × (1 (value1) / 99): slider=1→0.1 (least sensitive), slider=100→0.001
private float VadThresholdFromSlider() =>
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
private VcInputMode CurrentInputMode() =>
radioPtt.Checked ? VcInputMode.PushToTalk :
radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
VcInputMode.VoiceActivation;
private void BtnChangePtt_Click(object? sender, EventArgs e)
{
using var dlg = new PttKeyCaptureDialog(_pttKey);
if (dlg.ShowDialog(this) == DialogResult.OK)
{
_pttKey = dlg.CapturedKey;
lblPttKey.Text = $"({_pttKey})";
}
}
private void CboInputDevice_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_micStreamId == 0) return;
string? deviceId = (cboInputDevice.SelectedItem as DeviceInfo)?.Id;
_client.SetInputDevice(_micStreamId, deviceId);
}
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
{
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
// Don't intercept PTT key while user is typing in a text control
if (ActiveControl is TextBox or RichTextBox) return;
_client.SetPushToTalk(true);
lblPttKey.Text = $"({_pttKey} ▶)";
e.Handled = true;
}
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
{
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
_client.SetPushToTalk(false);
lblPttKey.Text = $"({_pttKey})";
e.Handled = true;
}
// ── 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);
}
// ── 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,
0,
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10));
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;
// The C ABI does not expose a user's current permissions, so start unchecked.
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;
var scope = VcTextScope.Channel;
uint targetId = _currentChannelId;
if (cboScope.SelectedItem is ScopeItem { Scope: VcTextScope.Private } si)
{
scope = VcTextScope.Private;
targetId = si.TargetId;
}
if (scope == VcTextScope.Channel && _currentChannelId == 0) return;
_client.SendText(scope, targetId, msg);
txtCompose.Clear();
// No optimistic echo: the server relays our own message back to us (it no longer
// excludes the sender), so HandleTextMessage renders it through the same path as
// every other message. Echoing here too would double it.
}
// ── Utility ───────────────────────────────────────────────────────────────
private void AddActivity(string text)
{
string entry = $"[{DateTime.Now:HH:mm}] {text}";
lstActivity.Items.Add(entry);
if (lstActivity.Items.Count > 200) lstActivity.Items.RemoveAt(0);
lstActivity.TopIndex = lstActivity.Items.Count - 1;
}
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;
// Stop any active local streams before tearing down — the core stops loopback in
// stream_stop/destroy, but explicit stops ensure clean StreamStop protocol messages
// go out before Disconnect closes the control channel.
if (_screenStreamId != 0) _client.StopStream(_screenStreamId);
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
_client.Dispose();
base.OnFormClosed(e);
}
// ── Private types ─────────────────────────────────────────────────────────
private sealed class ScopeItem(string display, VcTextScope scope, uint targetId)
{
public VcTextScope Scope { get; } = scope;
public uint TargetId { get; } = targetId;
public override string ToString() => display;
}
private sealed class UserListItem(uint userId, string display)
{
public uint UserId { get; } = userId;
public override string ToString() => display;
}
}