using VoiceCat.Interop; namespace VoiceCat.App.Forms; /// /// 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. /// 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 _channels = []; private readonly Dictionary _users = []; private readonly HashSet _talkingUsers = []; // Voice state private uint _micStreamId; // 0 = not started private Keys _pttKey = Keys.F8; 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(); _pumpTimer.Start(); // Channel tree tvChannels.DoubleClick += TvChannels_DoubleClick; tvChannels.KeyDown += TvChannels_KeyDown; // User list — double-click or Enter for per-user tuning, right-click for context menu var ctxUsers = new ContextMenuStrip(); var miTune = new ToolStripMenuItem("&Adjust volume and noise settings..."); miTune.Click += (_, _) => OpenUserTuning(); ctxUsers.Opening += (_, _) => miTune.Enabled = lstUsers.SelectedItem is UserListItem; ctxUsers.Items.Add(miTune); lstUsers.ContextMenuStrip = ctxUsers; 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; 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; } 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(); } // ── 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.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); _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; } RefreshChannelTree(); RefreshUserList(); RebuildScopeCombo(); } private void HandleJoinResult(VoiceCatEvent ev) { if (ev.Result == VcResult.Ok) { _currentChannelId = ev.ChannelId; // Server doesn't echo UserJoined/UserUpdated back to the mover — patch our // own entry in _users so RefreshUserList shows us in the new 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 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); string prefix = ev.TextScope == VcTextScope.Private ? "(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; txtCompose.Enabled = false; btnSend.Enabled = false; btnMicToggle.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)"; 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() { if (_currentChannelId == 0) { lblStatus.Text = $"Connected as {_nickname} — 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} — {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); 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 − (value−1) / 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); } // ── 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(); // Server excludes sender from channel fan-out — echo our own message locally. string time = DateTime.Now.ToString("HH:mm"); string prefix = scope == VcTextScope.Private ? $"(private to {GetNickname(targetId)}) " : ""; rtbChat.AppendText($"[{time}] {prefix}{_nickname}: {msg}\n"); rtbChat.ScrollToCaret(); } // ── 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; _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; } }