Audio input settings (device picker, VAD/PTT mode, VAD sensitivity, mic gain, PTT key) move from the always-visible bottom panel into Settings > Audio..., matching the macOS/iOS pattern. The device ComboBox now uses DataSource + DisplayMember="Name" instead of Items.Add() with no DisplayMember — this fixes both the display bug (was showing the full DeviceInfo record ToString()) and the NVDA silence on dropdown open (DataSource binding exposes proper MSAA text per item). Changes apply live for immediate feedback; Cancel reverts. VoiceSettings gains InputDeviceId to persist the chosen device across sessions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1075 lines
40 KiB
C#
1075 lines
40 KiB
C#
using VoiceCat.App.Audio;
|
|
using VoiceCat.App.Models;
|
|
using VoiceCat.App.Notifications;
|
|
using VoiceCat.Interop;
|
|
|
|
namespace VoiceCat.App.Forms;
|
|
|
|
/// <summary>
|
|
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
|
|
/// </summary>
|
|
public partial class MainForm : Form
|
|
{
|
|
private readonly VoiceCatClient _client;
|
|
private readonly uint _selfUserId;
|
|
private readonly string _nickname;
|
|
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
|
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
|
|
private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();
|
|
|
|
// Channel / user state
|
|
private uint _currentChannelId;
|
|
private List<ChannelInfo> _channels = [];
|
|
private readonly Dictionary<uint, UserInfo> _users = [];
|
|
private readonly HashSet<uint> _talkingUsers = [];
|
|
private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false);
|
|
|
|
// Voice state
|
|
private uint _micStreamId; // 0 = not started
|
|
private uint _screenStreamId; // 0 = not sharing screen audio
|
|
private 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<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();
|
|
_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;
|
|
}
|
|
}
|