Files
voice-cat/clients/windows/VoiceCat.App/Forms/MainForm.cs

938 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using VoiceCat.Interop;
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
{
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<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 Keys _pttKey = Keys.F8;
private bool _serverMuted;
private bool _serverDeafened;
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();
// M5: load own permissions and build permission-aware menus.
_ownPermissions = SafeGetPermissions();
BuildMenus();
BuildChannelContextMenu();
BuildUserContextMenu();
_pumpTimer.Start();
// 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();
// 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;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
}
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();
}
// ── Menu / context menu builders (M5) ─────────────────────────────────────
private PermissionsInfo SafeGetPermissions()
{
try { return _client.GetPermissions(); }
catch { return new PermissionsInfo(false, false, false, false, false, false); }
}
private void BuildMenus()
{
if (!_ownPermissions.CanAdminAccounts) return;
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());
}
};
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());
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());
}
}
};
lstUsers.ContextMenuStrip = ctx;
}
// ── 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();
RebuildScopeCombo();
}
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;
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;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
}
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 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);
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)";
if (user.SelfMicMuted || user.ServerMuted) label += " (muted)";
if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)";
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()
{
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")})";
}
// ── 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 (value1) / 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);
}
private void ChannelTreeJoinSelected()
{
if (tvChannels.SelectedNode?.Tag is uint channelId)
JoinChannelRequest(channelId);
}
// ── 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));
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;
// 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;
_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;
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;
}
}