938 lines
34 KiB
C#
938 lines
34 KiB
C#
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 − (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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|