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:
@@ -55,6 +55,12 @@ cmake --build --preset skeleton
|
||||
ctest --preset skeleton
|
||||
```
|
||||
|
||||
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.**
|
||||
> MinGW-built executables fail in Git Bash with exit code `0xc0000139`
|
||||
> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable.
|
||||
> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`,
|
||||
> `voicecat-server.exe`, or `vccli.exe` invocation on Windows.
|
||||
|
||||
Other presets: `release` (optimized + tests), `server-release` (optimized + stripped,
|
||||
deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/
|
||||
`apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md)
|
||||
|
||||
@@ -29,9 +29,11 @@ cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
|
||||
# Run the tests (21 behavior tests — grows per milestone)
|
||||
# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash
|
||||
# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly.
|
||||
ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure
|
||||
|
||||
# Run the binaries (Windows adds .exe; Linux/macOS no extension)
|
||||
# Run the binaries — same Windows rule: use PowerShell, not Git Bash
|
||||
./build/dev/bin/vccli # headless test client
|
||||
./build/dev/bin/voicecat-server --help
|
||||
./build/dev/bin/voicecat-server --name "My Server"
|
||||
|
||||
176
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
176
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
@@ -4,8 +4,11 @@ partial class MainForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null!;
|
||||
|
||||
// Menu bar
|
||||
// Menu bar + toolbar
|
||||
private MenuStrip menuStrip = null!;
|
||||
private ToolStrip toolStrip = null!;
|
||||
private ToolStripButton tsbJoinVoice = null!;
|
||||
private ToolStripButton tsbScreenShare = null!;
|
||||
|
||||
// Status bar
|
||||
private Label lblStatus = null!;
|
||||
@@ -20,23 +23,20 @@ partial class MainForm
|
||||
private Label lblUsers = null!;
|
||||
private ListBox lstUsers = null!;
|
||||
|
||||
// Right panel: chat transcript, compose row, activity log
|
||||
// Right panel: unified log, compose row, output volume
|
||||
private TableLayoutPanel tblRight = null!;
|
||||
private Label lblChat = null!;
|
||||
private RichTextBox rtbChat = null!;
|
||||
private Label lblLog = null!;
|
||||
private RichTextBox rtbLog = null!;
|
||||
private TableLayoutPanel tblCompose = null!;
|
||||
private ComboBox cboScope = null!;
|
||||
private TextBox txtCompose = null!;
|
||||
private Button btnSend = null!;
|
||||
private Label lblActivity = null!;
|
||||
private ListBox lstActivity = null!;
|
||||
private Label lblOutputVolume = null!;
|
||||
private TrackBar trkOutputVolume = null!;
|
||||
|
||||
// Voice control panel (docked Bottom)
|
||||
private Panel pnlVoice = null!;
|
||||
private FlowLayoutPanel flpVoiceTop = null!;
|
||||
private FlowLayoutPanel flpVoiceBottom = null!;
|
||||
private Button btnMicToggle = null!;
|
||||
private Button btnScreenShareToggle = null!;
|
||||
private CheckBox chkMute = null!;
|
||||
private CheckBox chkDeafen = null!;
|
||||
private RadioButton radioVad = null!;
|
||||
@@ -70,19 +70,16 @@ partial class MainForm
|
||||
lblUsers = new Label();
|
||||
lstUsers = new ListBox();
|
||||
tblRight = new TableLayoutPanel();
|
||||
lblChat = new Label();
|
||||
rtbChat = new RichTextBox();
|
||||
lblLog = new Label();
|
||||
rtbLog = new RichTextBox();
|
||||
tblCompose = new TableLayoutPanel();
|
||||
cboScope = new ComboBox();
|
||||
txtCompose = new TextBox();
|
||||
btnSend = new Button();
|
||||
lblActivity = new Label();
|
||||
lstActivity = new ListBox();
|
||||
lblOutputVolume = new Label();
|
||||
trkOutputVolume = new TrackBar();
|
||||
pnlVoice = new Panel();
|
||||
flpVoiceTop = new FlowLayoutPanel();
|
||||
flpVoiceBottom = new FlowLayoutPanel();
|
||||
btnMicToggle = new Button();
|
||||
btnScreenShareToggle = new Button();
|
||||
chkMute = new CheckBox();
|
||||
chkDeafen = new CheckBox();
|
||||
radioVad = new RadioButton();
|
||||
@@ -97,6 +94,10 @@ partial class MainForm
|
||||
pbLevel = new ProgressBar();
|
||||
lblVadThreshold = new Label();
|
||||
trkVadThreshold = new TrackBar();
|
||||
menuStrip = new MenuStrip();
|
||||
toolStrip = new ToolStrip();
|
||||
tsbJoinVoice = new ToolStripButton();
|
||||
tsbScreenShare = new ToolStripButton();
|
||||
|
||||
// ── Status label ──────────────────────────────────────────────────────
|
||||
lblStatus.AccessibleName = "Connection status";
|
||||
@@ -144,78 +145,72 @@ partial class MainForm
|
||||
splitLeft.Panel2.Controls.Add(lstUsers);
|
||||
splitLeft.Panel2.Controls.Add(lblUsers);
|
||||
|
||||
// ── Chat transcript ───────────────────────────────────────────────────
|
||||
lblChat.Text = "Chat:";
|
||||
lblChat.AutoSize = true;
|
||||
lblChat.Padding = new Padding(2, 2, 2, 1);
|
||||
// ── Unified log ───────────────────────────────────────────────────────
|
||||
lblLog.Text = "Chat & Activity:";
|
||||
lblLog.AutoSize = true;
|
||||
lblLog.Padding = new Padding(2, 2, 2, 1);
|
||||
|
||||
rtbChat.AccessibleName = "Chat transcript";
|
||||
rtbChat.AccessibleDescription = "History of channel and private messages.";
|
||||
rtbChat.Dock = DockStyle.Fill;
|
||||
rtbChat.ReadOnly = true;
|
||||
rtbChat.ScrollBars = RichTextBoxScrollBars.Vertical;
|
||||
rtbChat.BackColor = SystemColors.Window;
|
||||
rtbChat.TabIndex = 0;
|
||||
rtbLog.AccessibleName = "Chat and activity log";
|
||||
rtbLog.AccessibleDescription = "Combined history of chat messages (normal) and activity events (gray).";
|
||||
rtbLog.Dock = DockStyle.Fill;
|
||||
rtbLog.ReadOnly = true;
|
||||
rtbLog.ScrollBars = RichTextBoxScrollBars.Vertical;
|
||||
rtbLog.BackColor = SystemColors.Window;
|
||||
rtbLog.TabIndex = 0;
|
||||
|
||||
// ── Compose row ───────────────────────────────────────────────────────
|
||||
cboScope.AccessibleName = "Send to";
|
||||
cboScope.AccessibleDescription =
|
||||
"Choose Channel to send to everyone, or a specific user for a private message.";
|
||||
cboScope.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
cboScope.Dock = DockStyle.Fill;
|
||||
cboScope.TabIndex = 1;
|
||||
|
||||
txtCompose.AccessibleName = "Message text";
|
||||
txtCompose.AccessibleDescription = "Type your message. Press Enter or click Send to send.";
|
||||
txtCompose.AccessibleDescription = "Type your channel message. Press Enter or click Send to send.";
|
||||
txtCompose.Dock = DockStyle.Fill;
|
||||
txtCompose.TabIndex = 2;
|
||||
txtCompose.TabIndex = 1;
|
||||
|
||||
btnSend.Text = "&Send";
|
||||
btnSend.Dock = DockStyle.Fill;
|
||||
btnSend.TabIndex = 3;
|
||||
btnSend.TabIndex = 2;
|
||||
|
||||
tblCompose.ColumnCount = 3;
|
||||
tblCompose.ColumnCount = 2;
|
||||
tblCompose.RowCount = 1;
|
||||
tblCompose.Dock = DockStyle.Fill;
|
||||
tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 165F));
|
||||
tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F));
|
||||
tblCompose.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||
tblCompose.Padding = new Padding(0, 3, 0, 0);
|
||||
tblCompose.Controls.Add(cboScope, 0, 0);
|
||||
tblCompose.Controls.Add(txtCompose, 1, 0);
|
||||
tblCompose.Controls.Add(btnSend, 2, 0);
|
||||
tblCompose.Controls.Add(txtCompose, 0, 0);
|
||||
tblCompose.Controls.Add(btnSend, 1, 0);
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
lblActivity.Text = "Activity:";
|
||||
lblActivity.AutoSize = true;
|
||||
lblActivity.Padding = new Padding(2, 4, 2, 1);
|
||||
// ── Output volume row ─────────────────────────────────────────────────
|
||||
lblOutputVolume.Text = "Output volume:";
|
||||
lblOutputVolume.AutoSize = true;
|
||||
lblOutputVolume.Padding = new Padding(2, 6, 4, 0);
|
||||
|
||||
lstActivity.AccessibleName = "Activity log";
|
||||
lstActivity.AccessibleDescription =
|
||||
"Record of joins, leaves, talk-state changes, and server messages.";
|
||||
lstActivity.Dock = DockStyle.Fill;
|
||||
lstActivity.HorizontalScrollbar = true;
|
||||
lstActivity.TabIndex = 4;
|
||||
trkOutputVolume.AccessibleName = "Output volume";
|
||||
trkOutputVolume.AccessibleDescription = "Global playback volume for all incoming audio.";
|
||||
trkOutputVolume.Minimum = 0;
|
||||
trkOutputVolume.Maximum = 100;
|
||||
trkOutputVolume.Value = 80;
|
||||
trkOutputVolume.TickFrequency = 10;
|
||||
trkOutputVolume.SmallChange = 1;
|
||||
trkOutputVolume.LargeChange = 10;
|
||||
trkOutputVolume.Dock = DockStyle.Fill;
|
||||
trkOutputVolume.TabIndex = 3;
|
||||
|
||||
// ── Right table layout ────────────────────────────────────────────────
|
||||
tblRight.ColumnCount = 1;
|
||||
tblRight.RowCount = 5;
|
||||
tblRight.Dock = DockStyle.Fill;
|
||||
tblRight.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F));
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 120F));
|
||||
tblRight.Controls.Add(lblChat, 0, 0);
|
||||
tblRight.Controls.Add(rtbChat, 0, 1);
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 0: lblLog
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); // row 1: rtbLog
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F)); // row 2: compose
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 3: vol label
|
||||
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 45F)); // row 4: vol slider
|
||||
tblRight.Controls.Add(lblLog, 0, 0);
|
||||
tblRight.Controls.Add(rtbLog, 0, 1);
|
||||
tblRight.Controls.Add(tblCompose, 0, 2);
|
||||
tblRight.Controls.Add(lblActivity, 0, 3);
|
||||
tblRight.Controls.Add(lstActivity, 0, 4);
|
||||
tblRight.Controls.Add(lblOutputVolume, 0, 3);
|
||||
tblRight.Controls.Add(trkOutputVolume, 0, 4);
|
||||
|
||||
// ── Main split ────────────────────────────────────────────────────────
|
||||
// SplitterDistance set in OnLoad — see MainForm.cs.
|
||||
splitMain.Dock = DockStyle.Fill;
|
||||
splitMain.Panel1MinSize = 150;
|
||||
splitMain.TabIndex = 1;
|
||||
@@ -223,26 +218,6 @@ partial class MainForm
|
||||
splitMain.Panel2.Controls.Add(tblRight);
|
||||
|
||||
// ── Voice control panel ───────────────────────────────────────────────
|
||||
// Top row: mic start/stop, mute/deafen, VAD/PTT
|
||||
btnMicToggle.Text = "&Join Voice";
|
||||
btnMicToggle.AutoSize = true;
|
||||
btnMicToggle.Margin = new Padding(0, 2, 6, 0);
|
||||
btnMicToggle.TabIndex = 0;
|
||||
|
||||
// Screen-audio share — independent of mic voice (can share without joining voice and
|
||||
// vice versa). The core's WASAPI loopback path (VOICECAT_HAS_LOOPBACK, always on for
|
||||
// the windows-client preset) captures the default render endpoint; see docs/voice.md
|
||||
// §9. Whole-device loopback inherently re-captures this app's own incoming voice mix —
|
||||
// an accepted self-echo characteristic, not a bug.
|
||||
btnScreenShareToggle.Text = "Share Screen &Audio";
|
||||
btnScreenShareToggle.AccessibleName = "Share screen audio";
|
||||
btnScreenShareToggle.AccessibleDescription =
|
||||
"Start or stop sharing your computer's audio (desktop/system audio) with the channel. " +
|
||||
"Independent of the microphone. Captures everything playing through your default speakers.";
|
||||
btnScreenShareToggle.AutoSize = true;
|
||||
btnScreenShareToggle.Margin = new Padding(0, 2, 12, 0);
|
||||
btnScreenShareToggle.TabIndex = 10;
|
||||
|
||||
chkMute.Text = "&Mute mic";
|
||||
chkMute.AutoSize = true;
|
||||
chkMute.Enabled = false;
|
||||
@@ -291,8 +266,6 @@ partial class MainForm
|
||||
flpVoiceTop.Height = 34;
|
||||
flpVoiceTop.AutoSize = false;
|
||||
flpVoiceTop.Padding = new Padding(4, 2, 4, 0);
|
||||
flpVoiceTop.Controls.Add(btnMicToggle);
|
||||
flpVoiceTop.Controls.Add(btnScreenShareToggle);
|
||||
flpVoiceTop.Controls.Add(chkMute);
|
||||
flpVoiceTop.Controls.Add(chkDeafen);
|
||||
flpVoiceTop.Controls.Add(lblMode);
|
||||
@@ -330,12 +303,12 @@ partial class MainForm
|
||||
pbLevel.Maximum = 100;
|
||||
pbLevel.Margin = new Padding(0, 6, 0, 0);
|
||||
pbLevel.Style = ProgressBarStyle.Continuous;
|
||||
pbLevel.TabStop = false; // informational, not actionable
|
||||
pbLevel.TabStop = false;
|
||||
|
||||
lblVadThreshold.Text = "Sensitivity:";
|
||||
lblVadThreshold.AutoSize = true;
|
||||
lblVadThreshold.Margin = new Padding(12, 5, 4, 0);
|
||||
lblVadThreshold.Visible = true; // shown when VAD mode active
|
||||
lblVadThreshold.Visible = true;
|
||||
|
||||
trkVadThreshold.AccessibleName = "VAD sensitivity";
|
||||
trkVadThreshold.AccessibleDescription =
|
||||
@@ -343,7 +316,7 @@ partial class MainForm
|
||||
"Range 1–100; default 25.";
|
||||
trkVadThreshold.Minimum = 1;
|
||||
trkVadThreshold.Maximum = 100;
|
||||
trkVadThreshold.Value = 76; // maps to ~0.024 (≈ default 0.025 threshold)
|
||||
trkVadThreshold.Value = 76;
|
||||
trkVadThreshold.TickFrequency = 10;
|
||||
trkVadThreshold.SmallChange = 1;
|
||||
trkVadThreshold.LargeChange = 10;
|
||||
@@ -369,8 +342,23 @@ partial class MainForm
|
||||
pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first
|
||||
pnlVoice.Controls.Add(flpVoiceTop); // Top — added last
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||
tsbJoinVoice.Text = "Join Voice";
|
||||
tsbJoinVoice.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||
tsbJoinVoice.CheckOnClick = false;
|
||||
|
||||
tsbScreenShare.Text = "Share Screen Audio";
|
||||
tsbScreenShare.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||
tsbScreenShare.CheckOnClick = false;
|
||||
|
||||
toolStrip.Dock = DockStyle.Top;
|
||||
toolStrip.Items.Add(tsbJoinVoice);
|
||||
toolStrip.Items.Add(new ToolStripSeparator());
|
||||
toolStrip.Items.Add(tsbScreenShare);
|
||||
toolStrip.TabIndex = 1;
|
||||
toolStrip.AccessibleName = "Toolbar";
|
||||
|
||||
// ── Menu strip ────────────────────────────────────────────────────────
|
||||
menuStrip = new MenuStrip();
|
||||
menuStrip.AccessibleName = "Main menu";
|
||||
menuStrip.Dock = DockStyle.Top;
|
||||
menuStrip.TabIndex = 0;
|
||||
@@ -380,12 +368,12 @@ partial class MainForm
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(960, 680);
|
||||
MinimumSize = new Size(700, 540);
|
||||
KeyPreview = true; // form sees KeyDown/KeyUp before focused control (needed for PTT)
|
||||
// Controls added in reverse docking priority: Fill first, then Bottom, then Top.
|
||||
Controls.Add(splitMain); // DockStyle.Fill
|
||||
Controls.Add(pnlVoice); // DockStyle.Bottom
|
||||
Controls.Add(lblStatus); // DockStyle.Top
|
||||
Controls.Add(menuStrip); // DockStyle.Top (placed at the very top)
|
||||
KeyPreview = true;
|
||||
Controls.Add(splitMain); // DockStyle.Fill
|
||||
Controls.Add(pnlVoice); // DockStyle.Bottom
|
||||
Controls.Add(lblStatus); // DockStyle.Top
|
||||
Controls.Add(toolStrip); // DockStyle.Top (above status)
|
||||
Controls.Add(menuStrip); // DockStyle.Top (topmost)
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Text = "VoiceCat";
|
||||
}
|
||||
|
||||
@@ -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 − (value−1) / 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;
|
||||
|
||||
115
clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs
Normal file
115
clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Modeless window for a single private message conversation.
|
||||
/// Created by MainForm and shown non-modally; MainForm routes incoming PMs here.
|
||||
/// </summary>
|
||||
public sealed class PrivateMessageForm : Form
|
||||
{
|
||||
private readonly VoiceCatClient _client;
|
||||
private readonly uint _userId;
|
||||
private readonly uint _selfUserId;
|
||||
|
||||
private readonly RichTextBox _rtbHistory;
|
||||
private readonly TextBox _txtCompose;
|
||||
private readonly Button _btnSend;
|
||||
|
||||
public PrivateMessageForm(VoiceCatClient client, uint userId, string nickname, uint selfUserId)
|
||||
{
|
||||
_client = client;
|
||||
_userId = userId;
|
||||
_selfUserId = selfUserId;
|
||||
|
||||
Text = $"Private Message — {nickname}";
|
||||
ClientSize = new Size(480, 360);
|
||||
MinimumSize = new Size(320, 240);
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
|
||||
_rtbHistory = new RichTextBox
|
||||
{
|
||||
ReadOnly = true,
|
||||
Dock = DockStyle.Fill,
|
||||
ScrollBars = RichTextBoxScrollBars.Vertical,
|
||||
BackColor = SystemColors.Window,
|
||||
AccessibleName = "Message history",
|
||||
};
|
||||
|
||||
var composePanel = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 34,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(0, 3, 0, 0),
|
||||
};
|
||||
composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F));
|
||||
composePanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||
|
||||
_txtCompose = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AccessibleName = "Message text",
|
||||
};
|
||||
_btnSend = new Button
|
||||
{
|
||||
Text = "&Send",
|
||||
Dock = DockStyle.Fill,
|
||||
};
|
||||
|
||||
composePanel.Controls.Add(_txtCompose, 0, 0);
|
||||
composePanel.Controls.Add(_btnSend, 1, 0);
|
||||
|
||||
Controls.Add(_rtbHistory);
|
||||
Controls.Add(composePanel);
|
||||
|
||||
_txtCompose.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Enter) { Send(); e.Handled = e.SuppressKeyPress = true; }
|
||||
};
|
||||
_btnSend.Click += (_, _) => Send();
|
||||
}
|
||||
|
||||
// Called by MainForm when a PM message arrives (or we send one and the server echoes it).
|
||||
public void AppendMessage(string time, bool isSelf, string sender, string text)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
string line = $"[{time}] {sender}: {text}\n";
|
||||
int selStart = _rtbHistory.TextLength;
|
||||
_rtbHistory.AppendText(line);
|
||||
if (isSelf)
|
||||
{
|
||||
_rtbHistory.Select(selStart, line.Length);
|
||||
_rtbHistory.SelectionColor = Color.Gray;
|
||||
_rtbHistory.Select(_rtbHistory.TextLength, 0);
|
||||
_rtbHistory.SelectionColor = _rtbHistory.ForeColor;
|
||||
}
|
||||
_rtbHistory.ScrollToCaret();
|
||||
}
|
||||
|
||||
// Called by MainForm for status events (e.g., the other user disconnected).
|
||||
public void AppendActivity(string text)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
string line = $"[{DateTime.Now:HH:mm}] {text}\n";
|
||||
int selStart = _rtbHistory.TextLength;
|
||||
_rtbHistory.AppendText(line);
|
||||
_rtbHistory.Select(selStart, line.Length);
|
||||
_rtbHistory.SelectionColor = Color.Gray;
|
||||
_rtbHistory.Select(_rtbHistory.TextLength, 0);
|
||||
_rtbHistory.SelectionColor = _rtbHistory.ForeColor;
|
||||
_rtbHistory.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void Send()
|
||||
{
|
||||
string msg = _txtCompose.Text.Trim();
|
||||
if (string.IsNullOrEmpty(msg)) return;
|
||||
_client.SendText(VcTextScope.Private, _userId, msg);
|
||||
_txtCompose.Clear();
|
||||
// No optimistic echo: the server relays our own message back, so AppendMessage is
|
||||
// called from MainForm.HandleTextMessage when the echo arrives.
|
||||
}
|
||||
}
|
||||
99
clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs
Normal file
99
clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Simple modal dialog for picking one user from a list of all server users.
|
||||
/// Used by "New Private Message" to let the user send a PM to anyone on the server.
|
||||
/// </summary>
|
||||
public sealed class UserPickerDialog : Form
|
||||
{
|
||||
private readonly ListBox _lstUsers;
|
||||
private readonly Button _btnOk;
|
||||
private readonly Button _btnCancel;
|
||||
|
||||
private readonly List<UserInfo> _users;
|
||||
|
||||
public uint SelectedUserId { get; private set; }
|
||||
|
||||
public UserPickerDialog(List<UserInfo> users)
|
||||
{
|
||||
_users = users;
|
||||
|
||||
Text = "New Private Message";
|
||||
ClientSize = new Size(280, 320);
|
||||
MinimumSize = new Size(220, 240);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
|
||||
var lbl = new Label
|
||||
{
|
||||
Text = "Select a user:",
|
||||
AutoSize = true,
|
||||
Padding = new Padding(6, 6, 6, 2),
|
||||
Dock = DockStyle.Top,
|
||||
};
|
||||
|
||||
_lstUsers = new ListBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AccessibleName = "User list",
|
||||
};
|
||||
foreach (var u in users)
|
||||
_lstUsers.Items.Add(new UserItem(u.Id, u.Nickname));
|
||||
|
||||
var btnPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Height = 40,
|
||||
Padding = new Padding(4),
|
||||
AutoSize = false,
|
||||
};
|
||||
|
||||
_btnCancel = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
AutoSize = true,
|
||||
};
|
||||
_btnOk = new Button
|
||||
{
|
||||
Text = "OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
AutoSize = true,
|
||||
Enabled = false,
|
||||
};
|
||||
AcceptButton = _btnOk;
|
||||
CancelButton = _btnCancel;
|
||||
|
||||
btnPanel.Controls.Add(_btnCancel);
|
||||
btnPanel.Controls.Add(_btnOk);
|
||||
|
||||
Controls.Add(_lstUsers);
|
||||
Controls.Add(lbl);
|
||||
Controls.Add(btnPanel);
|
||||
|
||||
_lstUsers.SelectedIndexChanged += (_, _) =>
|
||||
_btnOk.Enabled = _lstUsers.SelectedItem is UserItem;
|
||||
_lstUsers.DoubleClick += (_, _) =>
|
||||
{
|
||||
if (_lstUsers.SelectedItem is UserItem) DialogResult = DialogResult.OK;
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (DialogResult == DialogResult.OK && _lstUsers.SelectedItem is UserItem item)
|
||||
SelectedUserId = item.UserId;
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
private sealed class UserItem(uint userId, string nickname)
|
||||
{
|
||||
public uint UserId { get; } = userId;
|
||||
public override string ToString() => nickname;
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,9 @@ internal static partial class NativeMethods
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||
float gain, int muted, int noiseReduction);
|
||||
|
||||
@@ -204,6 +204,9 @@ public sealed class VoiceCatClient : IDisposable
|
||||
public VcResult SetSelfMute(bool micMuted, bool deafened) =>
|
||||
NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0);
|
||||
|
||||
public VcResult SetOutputVolume(float gain) =>
|
||||
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
||||
|
||||
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||
|
||||
@@ -374,6 +374,10 @@ VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold);
|
||||
VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */);
|
||||
VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
|
||||
|
||||
/* Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||
* 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. */
|
||||
VC_API vc_result vc_set_output_volume(vc_client* c, float gain);
|
||||
|
||||
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
|
||||
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
|
||||
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||
|
||||
@@ -659,8 +659,11 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
|
||||
}
|
||||
}
|
||||
|
||||
for (ma_uint32 i = 0; i < frames * pb_channels; ++i)
|
||||
out[i] = static_cast<int16_t>(std::clamp(mix[i], -32768, 32767));
|
||||
const float ovol = output_volume_.load(std::memory_order_relaxed);
|
||||
for (ma_uint32 i = 0; i < frames * pb_channels; ++i) {
|
||||
int32_t s = static_cast<int32_t>(static_cast<float>(mix[i]) * ovol);
|
||||
out[i] = static_cast<int16_t>(std::clamp(s, -32768, 32767));
|
||||
}
|
||||
#else
|
||||
(void)out;
|
||||
(void)frames;
|
||||
|
||||
@@ -163,6 +163,9 @@ class AudioEngine {
|
||||
// Called by the net thread when a decoded voice frame arrives for a remote stream.
|
||||
void push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f);
|
||||
|
||||
// Global output volume applied after mixing all streams (default 1.0). Thread-safe.
|
||||
void set_output_volume(float gain) { output_volume_.store(gain, std::memory_order_relaxed); }
|
||||
|
||||
// Per-stream receive-side controls (safe from any thread).
|
||||
void set_stream_gain(uint32_t ssrc, float gain); // 0.0–2.0, default 1.0
|
||||
void set_stream_mute(uint32_t ssrc, bool mute);
|
||||
@@ -337,7 +340,8 @@ class AudioEngine {
|
||||
|
||||
AudioParams params_{};
|
||||
CaptureCallback capture_cb_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<float> output_volume_{1.0f};
|
||||
|
||||
// Capture-side frame accumulators: miniaudio fires the capture (and loopback) callback at
|
||||
// whatever period the hardware/driver chooses — commonly 480 samples (10 ms) on WASAPI
|
||||
|
||||
@@ -1365,6 +1365,11 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::set_output_volume(float gain) {
|
||||
audio_engine_.set_output_volume(gain < 0.0f ? 0.0f : gain);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
|
||||
bool muted, bool noise_reduction) {
|
||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
|
||||
@@ -54,6 +54,7 @@ struct vc_client {
|
||||
vc_result set_vad_threshold(float threshold);
|
||||
vc_result set_push_to_talk(bool active);
|
||||
vc_result set_self_mute(bool mic_muted, bool deafened);
|
||||
vc_result set_output_volume(float gain);
|
||||
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
||||
bool noise_reduction);
|
||||
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
||||
|
||||
@@ -117,6 +117,11 @@ vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened) {
|
||||
return c->set_self_mute(mic_muted != 0, deafened != 0);
|
||||
}
|
||||
|
||||
vc_result vc_set_output_volume(vc_client* c, float gain) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->set_output_volume(gain);
|
||||
}
|
||||
|
||||
vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain,
|
||||
int muted, int noise_reduction) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
|
||||
Reference in New Issue
Block a user