using VoiceCat.App.Audio; using VoiceCat.App.Models; using VoiceCat.App.Notifications; using VoiceCat.Interop; namespace VoiceCat.App.Forms; /// /// Post-auth main window. Owns the VoiceCatClient for its entire lifetime. /// public partial class MainForm : Form { private readonly VoiceCatClient _client; private readonly uint _selfUserId; private readonly string _nickname; private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 }; private readonly EventFeedback _feedback = new(FeedbackSettings.Load()); private readonly VoiceSettings _voiceSettings = VoiceSettings.Load(); // Channel / user state private uint _currentChannelId; private List _channels = []; private readonly Dictionary _users = []; private readonly HashSet _talkingUsers = []; private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false); // Voice state private uint _micStreamId; // 0 = not started private uint _screenStreamId; // 0 = not sharing screen audio private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode private Keys _pttKey = Keys.F8; private bool _pttEngaged; // guards the PTT cue against key-repeat private bool _serverMuted; private bool _serverDeafened; // Private message windows keyed by the other user's ID private readonly Dictionary _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(); _client = client; _selfUserId = selfUserId; _nickname = nickname; Text = $"VoiceCat — {nickname}"; _client.EventReceived += OnEvent; _client.LevelChanged += OnLevelChanged; _pumpTimer.Tick += (_, _) => _client.PumpEvents(); _ownPermissions = SafeGetPermissions(); BuildMenus(); BuildChannelContextMenu(); BuildUserContextMenu(); _pumpTimer.Start(); // Apply initial output volume _client.SetOutputVolume(trkOutputVolume.Value / 100f); // Channel tree tvChannels.DoubleClick += TvChannels_DoubleClick; tvChannels.KeyDown += TvChannels_KeyDown; lstUsers.DoubleClick += (_, _) => OpenUserTuning(); lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); }; // Compose txtCompose.KeyDown += TxtCompose_KeyDown; btnSend.Click += (_, _) => SendText(); // Toolbar voice buttons tsbJoinVoice.Click += BtnMicToggle_Click; tsbScreenShare.Click += BtnScreenShareToggle_Click; // Output volume slider trkOutputVolume.Scroll += TrkOutputVolume_Scroll; // Voice controls chkMute.CheckedChanged += (_, _) => ApplySelfMute(); chkDeafen.CheckedChanged += (_, _) => ApplySelfMute(); // PTT and global hotkeys (focus-scoped — work only while this form has focus) KeyDown += MainForm_KeyDown; KeyDown += MainForm_HotkeyDown; KeyUp += MainForm_KeyUp; Deactivate += (_, _) => { if (_micStreamId != 0) _client.SetPushToTalk(false); }; ApplyPersistedVoiceSettings(); BootstrapFromServer(); } private void ApplyPersistedVoiceSettings() => _pttKey = (Keys)_voiceSettings.PttKey; // ── Startup ────────────────────────────────────────────────────────────── private void BootstrapFromServer() { _channels = _client.ListChannels(); var users = _client.ListUsers(); _users.Clear(); foreach (var u in users) { _users[u.Id] = u; if (u.Id == _selfUserId) { _currentChannelId = u.ChannelId; UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened); } } RefreshChannelTree(); RefreshUserList(); UpdateStatusLabel(); AddActivity($"Connected to server as {_nickname}"); _feedback.PlaySound(SoundEvent.Login); _feedback.Speak("Connected"); } protected override void OnLoad(EventArgs e) { base.OnLoad(e); splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304); splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84); } // ── Menu / context menu builders ───────────────────────────────────────── private PermissionsInfo SafeGetPermissions() { try { return _client.GetPermissions(); } catch { return new PermissionsInfo(false, false, false, false, false, false); } } private void BuildMenus() { // Voice menu — always visible var voiceMenu = new ToolStripMenuItem("&Voice"); _miJoinVoice = new ToolStripMenuItem("&Join Voice"); _miJoinVoice.Click += BtnMicToggle_Click; _miJoinVoice.ShortcutKeyDisplayString = "Ctrl+Shift+V"; voiceMenu.DropDownItems.Add(_miJoinVoice); _miScreenShare = new ToolStripMenuItem("Share Screen &Audio"); _miScreenShare.Click += BtnScreenShareToggle_Click; _miScreenShare.ShortcutKeyDisplayString = "Ctrl+Shift+S"; voiceMenu.DropDownItems.Add(_miScreenShare); menuStrip.Items.Add(voiceMenu); // Messages menu — always visible var messagesMenu = new ToolStripMenuItem("&Messages"); var miNewPm = new ToolStripMenuItem("&New Private Message..."); miNewPm.ShortcutKeys = Keys.Control | Keys.P; miNewPm.Click += (_, _) => OpenNewPmDialog(); messagesMenu.DropDownItems.Add(miNewPm); menuStrip.Items.Add(messagesMenu); // Settings menu — always visible var settingsMenu = new ToolStripMenuItem("&Settings"); var miAudio = new ToolStripMenuItem("&Audio..."); miAudio.Click += (_, _) => { using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId); dlg.ShowDialog(this); _pttKey = (Keys)_voiceSettings.PttKey; }; settingsMenu.DropDownItems.Add(miAudio); var miNotifications = new ToolStripMenuItem("&Notifications..."); miNotifications.Click += (_, _) => { using var dlg = new NotificationSettingsForm(_feedback); dlg.ShowDialog(this); }; settingsMenu.DropDownItems.Add(miNotifications); menuStrip.Items.Add(settingsMenu); // Admin menu — only if permitted if (_ownPermissions.CanAdminAccounts) { var adminMenu = new ToolStripMenuItem("&Admin"); var miAccounts = new ToolStripMenuItem("&Server accounts..."); miAccounts.Click += (_, _) => { using var dlg = new AccountsDialog(_client); dlg.ShowDialog(this); }; adminMenu.DropDownItems.Add(miAccounts); menuStrip.Items.Add(adminMenu); } } private void BuildChannelContextMenu() { var ctx = new ContextMenuStrip(); ctx.Opening += (_, _) => { ctx.Items.Clear(); bool hasSelection = tvChannels.SelectedNode?.Tag is uint; bool canCreate = _ownPermissions.CanCreateTempChannel || _ownPermissions.IsAdmin; bool isAdmin = _ownPermissions.IsAdmin; if (hasSelection) { ctx.Items.Add("&Join", null, (_, _) => ChannelTreeJoinSelected()); ctx.Items.Add(new ToolStripSeparator()); } if (canCreate) ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel()); if (hasSelection && isAdmin) { ctx.Items.Add("&Edit channel...", null, (_, _) => EditSelectedChannel()); ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel()); } }; ctx.Opened += (_, _) => SelectFirstMenuItem(ctx); tvChannels.ContextMenuStrip = ctx; } private void BuildUserContextMenu() { var ctx = new ContextMenuStrip(); ctx.Opening += (_, _) => { ctx.Items.Clear(); if (lstUsers.SelectedItem is not UserListItem item) return; if (!_users.TryGetValue(item.UserId, out var user)) return; bool isSelf = user.Id == _selfUserId; var miTune = new ToolStripMenuItem("&Adjust volume and noise settings..."); miTune.Click += (_, _) => OpenUserTuning(); ctx.Items.Add(miTune); if (!isSelf) { ctx.Items.Add(new ToolStripSeparator()); var miPm = new ToolStripMenuItem("Send &Private Message"); miPm.Click += (_, _) => OpenPmWindow(user.Id); ctx.Items.Add(miPm); ctx.Items.Add(new ToolStripSeparator()); if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin) ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser()); if (_ownPermissions.CanKick || _ownPermissions.IsAdmin) ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser()); if (_ownPermissions.CanBan || _ownPermissions.IsAdmin) ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser()); ctx.Items.Add(new ToolStripSeparator()); if (_ownPermissions.IsAdmin) { ctx.Items.Add(user.ServerMuted ? "Server &unmute" : "Server &mute", null, (_, _) => ToggleServerMuteSelected()); ctx.Items.Add(user.ServerDeafened ? "Server un&deafen" : "Server &deafen", null, (_, _) => ToggleServerDeafenSelected()); ctx.Items.Add("&Set permissions...", null, (_, _) => SetPermissionsSelected()); } } }; ctx.Opened += (_, _) => SelectFirstMenuItem(ctx); lstUsers.ContextMenuStrip = ctx; } // Work around the WinForms ContextMenuStrip accessibility bug: when opened by // keyboard (Shift+F10 / Apps key) the focused item is not set, so screen readers // stay silent until the first arrow key. Selecting the first item ourselves on // Opened raises the UIA focus event immediately. private static void SelectFirstMenuItem(ContextMenuStrip ctx) { foreach (ToolStripItem item in ctx.Items) { if (item is ToolStripMenuItem && item.Enabled) { item.Select(); break; } } } // ── Event dispatch ──────────────────────────────────────────────────────── private void OnEvent(VoiceCatEvent ev) { switch (ev.Type) { case VcEventType.ChannelList: HandleChannelList(); break; case VcEventType.UserJoined: HandleUserJoined(ev); break; case VcEventType.UserLeft: HandleUserLeft(ev); break; case VcEventType.UserUpdated: HandleUserUpdated(); break; case VcEventType.JoinResult: HandleJoinResult(ev); break; case VcEventType.GenericResult: HandleGenericResult(ev); break; case VcEventType.AccountList: AddActivity("Account list updated"); break; case VcEventType.TextMessage: HandleTextMessage(ev); break; case VcEventType.TalkState: HandleTalkState(ev); break; case VcEventType.StreamStarted: HandleStreamStarted(ev); break; case VcEventType.StreamStopped: if (_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(); } 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; RefreshChannelTree(); RefreshUserList(); if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId) { AddActivity($"{user.Nickname} joined the channel"); _feedback.PlaySound(SoundEvent.ChannelJoin); _feedback.Speak($"{user.Nickname} joined"); } } private void HandleUserLeft(VoiceCatEvent ev) { if (!_users.TryGetValue(ev.UserId, out var user)) return; bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId; _users.Remove(ev.UserId); _talkingUsers.Remove(ev.UserId); RefreshChannelTree(); RefreshUserList(); if (wasHere) { AddActivity($"{user.Nickname} left the channel"); _feedback.PlaySound(SoundEvent.ChannelLeave); _feedback.Speak($"{user.Nickname} left"); } if (_pmWindows.TryGetValue(ev.UserId, out var pmWin)) pmWin.AppendActivity($"{user.Nickname} disconnected from server"); } private void HandleUserUpdated() { var users = _client.ListUsers(); _users.Clear(); foreach (var u in users) { _users[u.Id] = u; if (u.Id == _selfUserId) { _currentChannelId = u.ChannelId; UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened); } } RefreshChannelTree(); RefreshUserList(); } private void HandleJoinResult(VoiceCatEvent ev) { if (ev.Result == VcResult.Ok) { _currentChannelId = ev.ChannelId; if (_users.TryGetValue(_selfUserId, out var self)) _users[_selfUserId] = self with { ChannelId = ev.ChannelId }; RefreshChannelTree(); RefreshUserList(); UpdateStatusLabel(); string chanName = _channels.FirstOrDefault(c => c.Id == ev.ChannelId)?.Name ?? $"Channel #{ev.ChannelId}"; AddActivity($"Joined {chanName}"); } else { AddActivity($"Could not join channel: {ev.Text ?? ev.Result.ToString()}"); } } private void HandleGenericResult(VoiceCatEvent ev) { string prefix = ev.Result == VcResult.Ok ? "Success" : "Failed"; string detail = !string.IsNullOrEmpty(ev.Text) ? $": {ev.Text}" : ""; AddActivity($"{prefix}{detail} ({ev.Result})"); } private void HandleTextMessage(VoiceCatEvent ev) { string time = ev.TimestampUnixMs > 0 ? DateTimeOffset.FromUnixTimeMilliseconds((long)ev.TimestampUnixMs) .LocalDateTime.ToString("HH:mm") : DateTime.Now.ToString("HH:mm"); string sender = GetNickname(ev.UserId); bool isSelf = ev.UserId == _selfUserId; string body = ev.Text ?? ""; if (ev.TextScope == VcTextScope.Private) { // For our own outgoing PM, ev.ChannelId carries the recipient user ID. uint otherUserId = isSelf ? ev.ChannelId : ev.UserId; var win = GetOrOpenPmWindow(otherUserId); win.AppendMessage(time, isSelf, sender, body); _feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv); if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}"); } else { AppendChat(time, sender, body); _feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv); if (!isSelf) _feedback.Speak($"{sender}: {body}"); } } private void HandleTalkState(VoiceCatEvent ev) { bool talking = ev.U32a == 1; if (talking) _talkingUsers.Add(ev.UserId); else _talkingUsers.Remove(ev.UserId); RefreshUserList(); if (ev.UserId == _selfUserId) _feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop); if (talking && ev.UserId != _selfUserId && _users.TryGetValue(ev.UserId, out var tUser) && tUser.ChannelId == _currentChannelId) AddActivity($"{tUser.Nickname} started talking"); } private void HandleStreamStarted(VoiceCatEvent ev) { if (!_users.TryGetValue(ev.UserId, out var sUser) || sUser.ChannelId != _currentChannelId) return; var streams = _client.ListUserStreams(ev.UserId); var stream = streams.FirstOrDefault(s => s.StreamId == ev.StreamId); string kind = stream?.Kind switch { VcStreamKind.ScreenAudio => "screen audio", VcStreamKind.AuxDevice => "aux device", _ => "microphone", }; AddActivity($"{sUser.Nickname} started {kind} stream"); } private void HandleDisconnected(VoiceCatEvent ev) { string msg = string.IsNullOrEmpty(ev.Text) ? "Disconnected from server." : $"Disconnected: {ev.Text}"; lblStatus.Text = msg; AddActivity(msg); if (ev.Result == VcResult.Ok) { _feedback.PlaySound(SoundEvent.Logout); _feedback.Speak("Disconnected"); } else { _feedback.PlaySound(SoundEvent.ConnectionLost); _feedback.Speak("Connection lost"); } tvChannels.Nodes.Clear(); lstUsers.Items.Clear(); _users.Clear(); _talkingUsers.Clear(); _currentChannelId = 0; _micStreamId = 0; _screenStreamId = 0; _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; txtCompose.Enabled = false; btnSend.Enabled = false; tsbJoinVoice.Enabled = false; tsbScreenShare.Enabled = false; _miJoinVoice.Enabled = false; _miScreenShare.Enabled = false; foreach (var win in _pmWindows.Values.ToList()) win.Close(); _pmWindows.Clear(); } // ── Level meter ─────────────────────────────────────────────────────────── private void OnLevelChanged(uint streamId, float rms) { if (streamId == _micStreamId) pbLevel.Value = Math.Min(100, (int)(rms * 400)); } // ── UI refresh helpers ──────────────────────────────────────────────────── private void RefreshChannelTree() { uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId; bool hadFocus = tvChannels.Focused; tvChannels.BeginUpdate(); tvChannels.Nodes.Clear(); var byParent = _channels .GroupBy(c => c.ParentId) .ToDictionary(g => g.Key, g => g.ToList()); void AddChildren(TreeNodeCollection nodes, uint parentId) { if (!byParent.TryGetValue(parentId, out var kids)) return; foreach (var ch in kids.OrderBy(c => c.Name)) { int count = _users.Values.Count(u => u.ChannelId == ch.Id); var label = $"{ch.Name} ({count})"; if (ch.PasswordProtected) label += " [password]"; if (ch.Id == _currentChannelId) label += " ►"; var node = new TreeNode(label) { Tag = ch.Id }; nodes.Add(node); AddChildren(node.Nodes, ch.Id); } } AddChildren(tvChannels.Nodes, 0); tvChannels.ExpandAll(); SeekAndSelect(tvChannels.Nodes, toSelect); tvChannels.EndUpdate(); // A Nodes.Clear()/rebuild can drop keyboard focus and leave the screen reader // without a current node. If the tree was focused before the refresh, restore // focus and re-announce the now-current node (null-then-reselect forces UIA to // fire a fresh focus event). if (hadFocus && tvChannels.SelectedNode != null) { var node = tvChannels.SelectedNode; tvChannels.Focus(); tvChannels.SelectedNode = null; tvChannels.SelectedNode = node; } } private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId) { if (channelId == 0) return false; foreach (TreeNode n in nodes) { if (n.Tag is uint id && id == channelId) { tvChannels.SelectedNode = n; return true; } if (SeekAndSelect(n.Nodes, channelId)) return true; } return false; } private void RefreshUserList() { 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(); UpdateStatusLabel(); } private void UpdateStatusLabel() { string suffix = ""; if (_serverMuted) suffix += " [server muted]"; if (_serverDeafened) suffix += " [server deafened]"; if (_currentChannelId == 0) { lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel."; return; } string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name ?? $"Channel #{_currentChannelId}"; int count = _users.Values.Count(u => u.ChannelId == _currentChannelId); lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})"; } // ── Voice controls ──────────────────────────────────────────────────────── private void BtnMicToggle_Click(object? sender, EventArgs e) { if (_micStreamId == 0) { var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone"); if (result == VcResult.Ok) { _micStreamId = streamId; var mode = (VcInputMode)_voiceSettings.InputMode; if (_voiceSettings.InputDeviceId is string devId) _client.SetInputDevice(streamId, devId); _client.SetInputMode(mode); if (mode == VcInputMode.VoiceActivation) _client.SetVadThreshold(VadThresholdFromSettings()); _client.SetInputGain(_voiceSettings.MicGain / 100f); SetVoiceJoinedState(true); AddActivity("Joined voice — microphone active"); _feedback.PlaySound(SoundEvent.VoiceOn); } else { AddActivity($"Failed to start microphone: {result}"); } } else { _client.SetPushToTalk(false); _client.StopStream(_micStreamId); _micStreamId = 0; pbLevel.Value = 0; SetVoiceJoinedState(false); AddActivity("Left voice"); _feedback.PlaySound(SoundEvent.VoiceOff); } } private void SetVoiceJoinedState(bool joined) { tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice"; _miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice"; chkMute.Enabled = joined; chkDeafen.Enabled = joined; } private void ApplySelfMute() => _client.SetSelfMute(chkMute.Checked, chkDeafen.Checked); private void BtnScreenShareToggle_Click(object? sender, EventArgs e) { if (_screenStreamId == 0) { StartScreenAudio(); } else { StopScreenAudio(); } } private void StartScreenAudio() { using var picker = new AppAudioPickerDialog(); if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return; var scope = picker.ChosenScope; if (scope is EntireDesktop { ExcludeSelf: false }) { // Existing whole-device WASAPI loopback path — core handles it. var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio"); if (result != VcResult.Ok) { AddActivity($"Failed to start screen audio: {result}"); return; } _screenStreamId = streamId; AddActivity("Sharing screen audio: entire desktop"); } else { // External-feed path: suppress core loopback, C# mixer feeds PCM. Covers the // per-app modes and "entire desktop except VoiceCat" (single EXCLUDE of self). var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio"); if (result != VcResult.Ok) { AddActivity($"Failed to start screen audio: {result}"); return; } _screenStreamId = streamId; _screenMixer = new ProcessAudioMixer(); _screenMixer.Start(scope, _client, streamId); string desc = scope switch { EntireDesktop => "entire desktop (excluding VoiceCat)", OnlyApps o => $"only {o.Pids.Count} app(s)", AllExceptApps a => $"all except {a.Names[0]}", _ => "apps", }; AddActivity($"Sharing screen audio: {desc}"); } tsbScreenShare.Text = "Stop Screen Audio"; _miScreenShare.Text = "Stop Screen &Audio"; } private void StopScreenAudio() { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); _screenStreamId = 0; tsbScreenShare.Text = "Share Screen Audio"; _miScreenShare.Text = "Share Screen &Audio"; AddActivity("Stopped sharing screen audio"); } private void TrkOutputVolume_Scroll(object? sender, EventArgs e) => _client.SetOutputVolume(trkOutputVolume.Value / 100f); private float VadThresholdFromSettings() => 0.1f * (1f - (_voiceSettings.VadThresholdSlider - 1f) / 99f); // ── PTT key handling (focus-scoped) ─────────────────────────────────────── private void MainForm_KeyDown(object? sender, KeyEventArgs e) { if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return; if (e.KeyCode != _pttKey || _micStreamId == 0) return; if (ActiveControl is TextBox or RichTextBox) return; _client.SetPushToTalk(true); if (!_pttEngaged) // first key-down only, not auto-repeat { _pttEngaged = true; _feedback.PlaySound(SoundEvent.Ptt); } e.Handled = true; } private void MainForm_HotkeyDown(object? sender, KeyEventArgs e) { if (!e.Control || !e.Shift) return; if (ActiveControl is TextBox or RichTextBox) return; switch (e.KeyCode) { case Keys.V: BtnMicToggle_Click(null, EventArgs.Empty); e.Handled = true; break; case Keys.S: BtnScreenShareToggle_Click(null, EventArgs.Empty); e.Handled = true; break; case Keys.M: chkMute.Checked = !chkMute.Checked; ApplySelfMute(); e.Handled = true; break; case Keys.D: chkDeafen.Checked = !chkDeafen.Checked; ApplySelfMute(); e.Handled = true; break; } } private void MainForm_KeyUp(object? sender, KeyEventArgs e) { if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return; if (e.KeyCode != _pttKey || _micStreamId == 0) return; _client.SetPushToTalk(false); _pttEngaged = false; 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); } // ── 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) { 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, false)); using var dlg = new ChannelEditDialog(_channels, editInfo); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; _client.EditChannel(dlg.Result); } private void DeleteSelectedChannel() { if (tvChannels.SelectedNode?.Tag is not uint channelId) return; var channel = _channels.FirstOrDefault(c => c.Id == channelId); if (channel is null) return; var confirm = MessageBox.Show(this, $"Delete channel \"{channel.Name}\"?", "VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (confirm != DialogResult.Yes) return; _client.DeleteChannel(channelId); } private UserInfo? SelectedUser() { if (lstUsers.SelectedItem is not UserListItem item) return null; _users.TryGetValue(item.UserId, out var user); return user; } private void MoveSelectedUser() { var user = SelectedUser(); if (user is null) return; using var dlg = new MoveUserDialog(_channels, user.ChannelId); if (dlg.ShowDialog(this) != DialogResult.OK) return; _client.MoveUser(user.Id, dlg.SelectedChannelId); } private void KickSelectedUser() { var user = SelectedUser(); if (user is null) return; using var dlg = new InputDialog("Kick user", "&Reason:", "Kicked by admin"); string? reason = dlg.ShowDialog(this) == DialogResult.OK ? dlg.TextValue : null; _client.KickUser(user.Id, reason); } private void BanSelectedUser() { var user = SelectedUser(); if (user is null) return; using var dlg = new BanUserDialog(user.Nickname); if (dlg.ShowDialog(this) != DialogResult.OK) return; _client.BanUser(user.Id, dlg.Reason, dlg.ExpiresUnixMs); } private void ToggleServerMuteSelected() { var user = SelectedUser(); if (user is null) return; _client.SetServerMute(user.Id, !user.ServerMuted, user.ServerDeafened); } private void ToggleServerDeafenSelected() { var user = SelectedUser(); if (user is null) return; _client.SetServerMute(user.Id, user.ServerMuted, !user.ServerDeafened); } private void SetPermissionsSelected() { var user = SelectedUser(); if (user is null) return; var current = new PermissionsInfo(false, false, false, false, false, false); using var dlg = new PermissionsDialog(user.Nickname, current); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; _client.SetPermission(user.Id, dlg.Result); } // ── Per-user tuning ─────────────────────────────────────────────────────── private void OpenUserTuning() { if (lstUsers.SelectedItem is not UserListItem item) return; if (!_users.TryGetValue(item.UserId, out var user)) return; using var dlg = new PerUserTuningDialog(_client, item.UserId, user.Nickname); dlg.ShowDialog(this); } // ── Text chat ───────────────────────────────────────────────────────────── private void TxtCompose_KeyDown(object? sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { SendText(); e.Handled = e.SuppressKeyPress = true; } } private void SendText() { string msg = txtCompose.Text.Trim(); if (string.IsNullOrEmpty(msg)) return; if (_currentChannelId == 0) return; _client.SendText(VcTextScope.Channel, _currentChannelId, msg); txtCompose.Clear(); } // ── Log helpers ─────────────────────────────────────────────────────────── private void AppendChat(string time, string sender, string text) { rtbLog.AppendText($"[{time}] {sender}: {text}\n"); rtbLog.ScrollToCaret(); } private void AddActivity(string text) { string entry = $"[{DateTime.Now:HH:mm}] {text}\n"; int selStart = rtbLog.TextLength; rtbLog.AppendText(entry); rtbLog.Select(selStart, entry.Length); rtbLog.SelectionColor = Color.Gray; rtbLog.Select(rtbLog.TextLength, 0); rtbLog.SelectionColor = rtbLog.ForeColor; rtbLog.ScrollToCaret(); } private string GetNickname(uint userId) { if (userId == _selfUserId) return _nickname; return _users.TryGetValue(userId, out var u) ? u.Nickname : $"User#{userId}"; } // ── Lifetime ────────────────────────────────────────────────────────────── protected override void OnFormClosed(FormClosedEventArgs e) { _pumpTimer.Stop(); _client.LevelChanged -= OnLevelChanged; _client.EventReceived -= OnEvent; foreach (var win in _pmWindows.Values.ToList()) win.Close(); _pmWindows.Clear(); if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); } if (_micStreamId != 0) _client.StopStream(_micStreamId); _client.Disconnect(); _client.Dispose(); _feedback.Dispose(); base.OnFormClosed(e); } // ── Private types ───────────────────────────────────────────────────────── private sealed class UserListItem(uint userId, string display) { public uint UserId { get; } = userId; public override string ToString() => display; } }