feat(windows): UI overhaul -- toolbar, unified log, PM windows, channel counts, output volume

- Voice actions (Join Voice, Share Screen Audio) moved to a ToolStrip toolbar and
  a new Voice menu in the menu bar; removed from the bottom voice panel
- Activity log and chat log collapsed into a single RichTextBox (rtbLog); activity
  events appear in gray, chat messages in default color
- Private messaging reworked: each conversation opens in its own modeless
  PrivateMessageForm instead of sharing the main chat log via a scope dropdown;
  cboScope removed; main compose bar always sends to the current channel
- New "Messages -> New Private Message..." menu item (Ctrl+P) opens a UserPickerDialog
  listing all connected server users (not just the current channel) so you can PM
  anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)" -- counts sourced from
  the existing _users dictionary which already tracks all server users with channel IDs
- Global output volume slider (TrackBar, 0-100, default 80) added to the right panel;
  wired to new vc_set_output_volume C ABI function that applies a master gain multiplier
  in the audio engine playback callback after mixing all streams
- vc_set_output_volume added end-to-end: voicecat.h, audio_engine.h/.cpp,
  client.h/.cpp, voicecat.cpp, NativeMethods.cs, VoiceCatClient.cs
- Documented Windows PowerShell ctest requirement in AGENTS.md and CLAUDE.md:
  MinGW binaries exit 0xc0000139 in Git Bash; always run ctest/.exe via PowerShell

22/22 ctest green (PowerShell); dotnet build 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 14:24:54 +02:00
parent fdcd8d1427
commit 97fa659422
14 changed files with 504 additions and 236 deletions

View File

@@ -4,8 +4,6 @@ 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
{
@@ -28,6 +26,13 @@ public partial class MainForm : Form
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)
{
InitializeComponent();
@@ -41,7 +46,6 @@ public partial class MainForm : Form
_client.LevelChanged += OnLevelChanged;
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
// M5: load own permissions and build permission-aware menus.
_ownPermissions = SafeGetPermissions();
BuildMenus();
BuildChannelContextMenu();
@@ -49,6 +53,9 @@ public partial class MainForm : Form
_pumpTimer.Start();
// Apply initial output volume
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
// Channel tree
tvChannels.DoubleClick += TvChannels_DoubleClick;
tvChannels.KeyDown += TvChannels_KeyDown;
@@ -59,9 +66,14 @@ public partial class MainForm : Form
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
btnMicToggle.Click += BtnMicToggle_Click;
btnScreenShareToggle.Click += BtnScreenShareToggle_Click;
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
radioVad.CheckedChanged += RadioVad_CheckedChanged;
@@ -77,7 +89,7 @@ public partial class MainForm : Form
KeyUp += MainForm_KeyUp;
Deactivate += (_, _) =>
{
if (_micStreamId != 0) _client.SetPushToTalk(false); // release PTT on focus loss
if (_micStreamId != 0) _client.SetPushToTalk(false);
};
BootstrapFromServer();
@@ -101,7 +113,6 @@ public partial class MainForm : Form
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
UpdateStatusLabel();
AddActivity($"Connected to server as {_nickname}");
}
@@ -114,7 +125,7 @@ public partial class MainForm : Form
LoadInputDevices();
}
// ── Menu / context menu builders (M5) ─────────────────────────────────────
// ── Menu / context menu builders ─────────────────────────────────────────
private PermissionsInfo SafeGetPermissions()
{
@@ -124,17 +135,40 @@ public partial class MainForm : Form
private void BuildMenus()
{
if (!_ownPermissions.CanAdminAccounts) return;
// Voice menu — always visible
var voiceMenu = new ToolStripMenuItem("&Voice");
var adminMenu = new ToolStripMenuItem("&Admin");
var miAccounts = new ToolStripMenuItem("&Server accounts...");
miAccounts.Click += (_, _) =>
_miJoinVoice = new ToolStripMenuItem("&Join Voice");
_miJoinVoice.Click += BtnMicToggle_Click;
voiceMenu.DropDownItems.Add(_miJoinVoice);
_miScreenShare = new ToolStripMenuItem("Share Screen &Audio");
_miScreenShare.Click += BtnScreenShareToggle_Click;
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);
// Admin menu — only if permitted
if (_ownPermissions.CanAdminAccounts)
{
using var dlg = new AccountsDialog(_client);
dlg.ShowDialog(this);
};
adminMenu.DropDownItems.Add(miAccounts);
menuStrip.Items.Add(adminMenu);
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()
@@ -154,9 +188,7 @@ public partial class MainForm : Form
}
if (canCreate)
{
ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel());
}
if (hasSelection && isAdmin)
{
@@ -185,18 +217,18 @@ public partial class MainForm : Form
{
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());
@@ -274,7 +306,6 @@ public partial class MainForm : Form
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
}
private void HandleUserJoined(VoiceCatEvent ev)
@@ -282,8 +313,8 @@ public partial class MainForm : Form
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
false, false, false, false);
_users[ev.UserId] = user;
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
AddActivity($"{user.Nickname} joined the channel");
}
@@ -294,9 +325,11 @@ public partial class MainForm : Form
bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId;
_users.Remove(ev.UserId);
_talkingUsers.Remove(ev.UserId);
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
if (wasHere) AddActivity($"{user.Nickname} left the channel");
if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
}
private void HandleUserUpdated()
@@ -314,7 +347,6 @@ public partial class MainForm : Form
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
}
private void HandleJoinResult(VoiceCatEvent ev)
@@ -322,9 +354,6 @@ public partial class MainForm : Form
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();
@@ -354,16 +383,19 @@ public partial class MainForm : Form
.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}");
if (ev.TextScope == VcTextScope.Private)
{
// For our own outgoing PM, ev.ChannelId carries the recipient user ID.
uint otherUserId = ev.UserId == _selfUserId ? ev.ChannelId : ev.UserId;
var win = GetOrOpenPmWindow(otherUserId);
bool isSelf = ev.UserId == _selfUserId;
win.AppendMessage(time, isSelf, sender, ev.Text ?? "");
}
else
{
AppendChat(time, sender, ev.Text ?? "");
}
}
private void HandleTalkState(VoiceCatEvent ev)
@@ -409,8 +441,12 @@ public partial class MainForm : Form
_screenStreamId = 0;
txtCompose.Enabled = false;
btnSend.Enabled = false;
btnMicToggle.Enabled = false;
btnScreenShareToggle.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 ───────────────────────────────────────────────────────────
@@ -438,7 +474,8 @@ public partial class MainForm : Form
if (!byParent.TryGetValue(parentId, out var kids)) return;
foreach (var ch in kids.OrderBy(c => c.Name))
{
var label = ch.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 };
@@ -479,34 +516,7 @@ public partial class MainForm : Form
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;
UpdateStatusLabel();
}
private void UpdateStatusLabel()
@@ -536,7 +546,6 @@ public partial class MainForm : Form
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++)
@@ -548,7 +557,6 @@ public partial class MainForm : Form
}
}
}
// Select default device
for (int i = 0; i < cboInputDevice.Items.Count; i++)
{
if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault)
@@ -570,18 +578,11 @@ public partial class MainForm : Form
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;
SetVoiceJoinedState(true);
AddActivity("Joined voice — microphone active");
}
else
@@ -591,27 +592,29 @@ public partial class MainForm : Form
}
else
{
_client.SetPushToTalk(false); // release PTT if held
_client.SetPushToTalk(false);
_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;
SetVoiceJoinedState(false);
AddActivity("Left voice");
}
}
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;
radioVad.Enabled = joined;
radioPtt.Enabled = joined;
radioAlwaysOn.Enabled = joined;
}
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)
@@ -620,7 +623,8 @@ public partial class MainForm : Form
if (result == VcResult.Ok)
{
_screenStreamId = streamId;
btnScreenShareToggle.Text = "Stop Screen &Audio";
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
AddActivity("Started sharing screen audio");
}
else
@@ -632,11 +636,15 @@ public partial class MainForm : Form
{
_client.StopStream(_screenStreamId);
_screenStreamId = 0;
btnScreenShareToggle.Text = "Share Screen &Audio";
tsbScreenShare.Text = "Share Screen Audio";
_miScreenShare.Text = "Share Screen &Audio";
AddActivity("Stopped sharing screen audio");
}
}
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
{
if (!radioVad.Checked) return;
@@ -662,7 +670,7 @@ public partial class MainForm : Form
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.PushToTalk);
_client.SetPushToTalk(false); // start released
_client.SetPushToTalk(false);
}
}
@@ -682,7 +690,6 @@ public partial class MainForm : Form
_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);
@@ -713,7 +720,6 @@ public partial class MainForm : Form
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} ▶)";
@@ -765,6 +771,48 @@ public partial class MainForm : Form
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;
win.Show(this);
}
else
{
if (win.WindowState == FormWindowState.Minimized)
win.WindowState = FormWindowState.Normal;
win.BringToFront();
}
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)
@@ -796,14 +844,8 @@ public partial class MainForm : Form
if (channel is null) return;
var editInfo = new ChannelEditInfo(
channel.Id,
channel.ParentId,
channel.Name,
channel.Topic,
channel.PasswordProtected,
null,
channel.MaxUsers,
0,
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);
@@ -876,7 +918,6 @@ public partial class MainForm : Form
{
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;
@@ -908,32 +949,29 @@ public partial class MainForm : Form
{
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);
if (_currentChannelId == 0) return;
_client.SendText(VcTextScope.Channel, _currentChannelId, 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 ───────────────────────────────────────────────────────────────
// ── 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}";
lstActivity.Items.Add(entry);
if (lstActivity.Items.Count > 200) lstActivity.Items.RemoveAt(0);
lstActivity.TopIndex = lstActivity.Items.Count - 1;
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)
@@ -949,9 +987,8 @@ public partial class MainForm : Form
_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.
foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear();
if (_screenStreamId != 0) _client.StopStream(_screenStreamId);
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
@@ -961,13 +998,6 @@ public partial class MainForm : Form
// ── 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;