Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were
applied to the core + UI but never saved, so every relaunch reset to VAD
defaults. Each client now persists them and re-applies on connect:
- iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes)
- macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings
(settings window also restores the VAD slider from the stored threshold)
- Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json,
mirrors FeedbackSettings) loaded/applied in MainForm
Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in
on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings,
and a 0-300% (default 100%) mic-volume slider on all three clients.
Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0),
so channel messages went nowhere; now passes session.currentChannelId.
Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press
.contextMenu only (invisible to VoiceOver); UserRow now also exposes the same
buttons via .accessibilityActions (no visual change).
Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes,
reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build
SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built
(WinForms can't build on macOS) — follows existing patterns.
1216 lines
45 KiB
C#
1216 lines
45 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();
|
|
radioVad.CheckedChanged += RadioVad_CheckedChanged;
|
|
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
|
|
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
|
|
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
|
|
trkMicGain.Scroll += TrkMicGain_Scroll;
|
|
btnChangePtt.Click += BtnChangePtt_Click;
|
|
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
|
|
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restore the saved transmission mode / VAD sensitivity / mic gain / PTT key into the UI so a
|
|
/// relaunch keeps the user's input settings instead of resetting to the designer defaults. The
|
|
/// values are pushed into the core when the mic stream starts (<see cref="BtnMicToggle_Click"/>).
|
|
/// Setting the radio fires CheckedChanged, which only adjusts visibility while the mic is idle.
|
|
/// </summary>
|
|
private void ApplyPersistedVoiceSettings()
|
|
{
|
|
_pttKey = (Keys)_voiceSettings.PttKey;
|
|
|
|
trkVadThreshold.Value = Math.Clamp(_voiceSettings.VadThresholdSlider,
|
|
trkVadThreshold.Minimum, trkVadThreshold.Maximum);
|
|
trkMicGain.Value = Math.Clamp(_voiceSettings.MicGain,
|
|
trkMicGain.Minimum, trkMicGain.Maximum);
|
|
|
|
switch ((VcInputMode)_voiceSettings.InputMode)
|
|
{
|
|
case VcInputMode.PushToTalk: radioPtt.Checked = true; break;
|
|
case VcInputMode.AlwaysOn: radioAlwaysOn.Checked = true; break;
|
|
default: radioVad.Checked = true; break;
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
LoadInputDevices();
|
|
}
|
|
|
|
// ── 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 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")})";
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev)
|
|
_client.SetInputDevice(streamId, dev.Id);
|
|
_client.SetInputMode(CurrentInputMode());
|
|
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
|
|
_client.SetInputGain(trkMicGain.Value / 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;
|
|
radioVad.Enabled = joined;
|
|
radioPtt.Enabled = joined;
|
|
radioAlwaysOn.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 void RadioVad_CheckedChanged(object? sender, EventArgs e)
|
|
{
|
|
if (!radioVad.Checked) return;
|
|
lblPttKey.Visible = false;
|
|
btnChangePtt.Visible = false;
|
|
lblVadThreshold.Visible = true;
|
|
trkVadThreshold.Visible = true;
|
|
SaveInputMode(VcInputMode.VoiceActivation);
|
|
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;
|
|
SaveInputMode(VcInputMode.PushToTalk);
|
|
if (_micStreamId != 0)
|
|
{
|
|
_client.SetInputMode(VcInputMode.PushToTalk);
|
|
_client.SetPushToTalk(false);
|
|
}
|
|
}
|
|
|
|
private void RadioAlwaysOn_CheckedChanged(object? sender, EventArgs e)
|
|
{
|
|
if (!radioAlwaysOn.Checked) return;
|
|
lblPttKey.Visible = false;
|
|
btnChangePtt.Visible = false;
|
|
lblVadThreshold.Visible = false;
|
|
trkVadThreshold.Visible = false;
|
|
SaveInputMode(VcInputMode.AlwaysOn);
|
|
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
|
|
}
|
|
|
|
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
|
|
{
|
|
_voiceSettings.VadThresholdSlider = trkVadThreshold.Value;
|
|
_voiceSettings.Save();
|
|
if (_micStreamId != 0 && radioVad.Checked)
|
|
_client.SetVadThreshold(VadThresholdFromSlider());
|
|
}
|
|
|
|
private void TrkMicGain_Scroll(object? sender, EventArgs e)
|
|
{
|
|
_voiceSettings.MicGain = trkMicGain.Value;
|
|
_voiceSettings.Save();
|
|
if (_micStreamId != 0) _client.SetInputGain(trkMicGain.Value / 100f);
|
|
}
|
|
|
|
private void SaveInputMode(VcInputMode mode)
|
|
{
|
|
_voiceSettings.InputMode = (int)mode;
|
|
_voiceSettings.Save();
|
|
}
|
|
|
|
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})";
|
|
_voiceSettings.PttKey = (int)_pttKey;
|
|
_voiceSettings.Save();
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (ActiveControl is TextBox or RichTextBox) return;
|
|
_client.SetPushToTalk(true);
|
|
lblPttKey.Text = $"({_pttKey} ▶)";
|
|
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 (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
|
|
_client.SetPushToTalk(false);
|
|
_pttEngaged = 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);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|