487 lines
28 KiB
C#
487 lines
28 KiB
C#
using AppKit;
|
|
using AVFoundation;
|
|
using CoreGraphics;
|
|
using Foundation;
|
|
using VoiceCat.Audio;
|
|
using VoiceCat.Core;
|
|
using Voicecat.V1;
|
|
|
|
namespace VoiceCat.Mac;
|
|
|
|
internal sealed class MainWindowController : NSWindowController
|
|
{
|
|
private readonly VoiceCatClient client;
|
|
private readonly MacAudioBackend audioBackend = new();
|
|
private readonly uint selfId;
|
|
private readonly NSPopUpButton channels = new(new CGRect(20, 515, 245, 28), false);
|
|
private readonly NSPopUpButton inputDevice = new(new CGRect(275, 515, 165, 28), false);
|
|
private readonly NSPopUpButton outputDevice = new(new CGRect(450, 515, 165, 28), false);
|
|
private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true };
|
|
private readonly NSTextView chat = new(new CGRect(0, 0, 510, 390)) { Editable = false, Selectable = true };
|
|
private readonly NSPopUpButton messageTarget = new(new CGRect(230, 55, 160, 28), false);
|
|
private readonly NSTextField compose = new(new CGRect(400, 55, 230, 28)) { PlaceholderString = "Message" };
|
|
private readonly NSButton send = new(new CGRect(640, 53, 90, 32)) { Title = "Send" };
|
|
private readonly NSButton voice = new(new CGRect(630, 510, 100, 32)) { Title = "Join voice" };
|
|
private readonly NSButton settingsButton = new(new CGRect(740, 510, 90, 32)) { Title = "Settings" };
|
|
private readonly NSButton screenAudioButton = new(new CGRect(840, 510, 130, 32)) { Title = "Share audio" };
|
|
private readonly NSButton administrationButton = new(new CGRect(840, 20, 130, 28)) { Title = "Administration" };
|
|
private readonly NSButton privateMessageButton = new(new CGRect(700, 20, 130, 28)) { Title = "Private chat" };
|
|
private readonly NSButton muteButton = NSButton.CreateCheckbox("Mute", () => { });
|
|
private readonly NSButton deafenButton = NSButton.CreateCheckbox("Deafen", () => { });
|
|
private readonly NSTextField status = NSTextField.CreateLabel("Connected");
|
|
private readonly MacSettings settings = new();
|
|
private readonly EventFeedback feedback;
|
|
private readonly NSTimer timer;
|
|
private uint currentChannel = 1;
|
|
private uint microphoneStreamId;
|
|
private uint auxiliaryStreamId;
|
|
private uint screenAudioStreamId;
|
|
private bool joinedVoice;
|
|
private bool changingChannel;
|
|
private IAudioCapture? microphone;
|
|
private IAudioCapture? auxiliary;
|
|
private IAudioPlayback? playback;
|
|
private ScreenAudioCapture? screenAudio;
|
|
private ScreenAudioSelection screenAudioSelection = ScreenAudioSelection.Default;
|
|
private SettingsWindowController? settingsWindow;
|
|
private AdministrationWindowController? administrationWindow;
|
|
private readonly Dictionary<uint, PrivateMessageWindowController> privateWindows = [];
|
|
private readonly HashSet<uint> talkingUsers = [];
|
|
private bool lastTalking;
|
|
private string? activeInputDevice;
|
|
private string? activeOutputDevice;
|
|
private string? activeAuxiliaryDevice;
|
|
private bool activeStereo;
|
|
private NSObject? pushToTalkMonitor;
|
|
private bool pushToTalkEngaged;
|
|
private long lastRemoteAudioTick;
|
|
|
|
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 1000, 570),
|
|
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
|
|
{
|
|
this.client = client; this.selfId = selfId; settings.Load(); feedback = new(settings);
|
|
Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
|
|
var content = Window.ContentView!;
|
|
((INSAccessibility)channels).AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
|
|
PopulateAudioDevices(inputDevice, true); PopulateAudioDevices(outputDevice, false);
|
|
((INSAccessibility)inputDevice).AccessibilityLabel = "Microphone input device"; inputDevice.Activated += ChangeAudioDevice; content.AddSubview(inputDevice);
|
|
((INSAccessibility)outputDevice).AccessibilityLabel = "Audio output device"; outputDevice.Activated += ChangeAudioDevice; content.AddSubview(outputDevice);
|
|
((INSAccessibility)voice).AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice);
|
|
((INSAccessibility)settingsButton).AccessibilityLabel = "Open audio and notification settings"; settingsButton.Activated += OpenSettings; content.AddSubview(settingsButton);
|
|
((INSAccessibility)screenAudioButton).AccessibilityLabel = "Start or stop sharing screen audio"; screenAudioButton.Activated += ToggleScreenAudio; content.AddSubview(screenAudioButton);
|
|
((INSAccessibility)administrationButton).AccessibilityLabel = "Open server moderation and administration"; administrationButton.Activated += OpenAdministration; content.AddSubview(administrationButton);
|
|
((INSAccessibility)privateMessageButton).AccessibilityLabel = "Open a private conversation with the selected message recipient"; privateMessageButton.Activated += OpenPrivateMessage; content.AddSubview(privateMessageButton);
|
|
muteButton.Frame = new CGRect(275, 490, 75, 22); deafenButton.Frame = new CGRect(355, 490, 85, 22);
|
|
((INSAccessibility)muteButton).AccessibilityLabel = "Mute microphone"; ((INSAccessibility)deafenButton).AccessibilityLabel = "Deafen playback";
|
|
muteButton.Activated += SelfAudioChanged; deafenButton.Activated += SelfAudioChanged; content.AddSubview(muteButton); content.AddSubview(deafenButton);
|
|
var userScroll = new NSScrollView(new CGRect(20, 90, 190, 410)) { HasVerticalScroller = true, DocumentView = users }; userScroll.AccessibilityLabel = "Users in channel"; content.AddSubview(userScroll);
|
|
var chatScroll = new NSScrollView(new CGRect(230, 90, 500, 410)) { HasVerticalScroller = true, DocumentView = chat }; chatScroll.AccessibilityLabel = "Channel messages"; content.AddSubview(chatScroll);
|
|
((INSAccessibility)messageTarget).AccessibilityLabel = "Message recipient"; content.AddSubview(messageTarget);
|
|
((INSAccessibility)compose).AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose);
|
|
((INSAccessibility)send).AccessibilityLabel = "Send message"; send.Activated += Send; content.AddSubview(send);
|
|
status.Frame = new CGRect(20, 22, 660, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status);
|
|
timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
|
|
ApplyEngineSettings(); feedback.Play(SoundEvent.Login); feedback.Speak("Connected");
|
|
pushToTalkMonitor = NSEvent.AddLocalMonitorForEventsMatchingMask(NSEventMask.KeyDown | NSEventMask.KeyUp, value => HandlePushToTalk(value)!);
|
|
RefreshState();
|
|
}
|
|
private NSEvent? HandlePushToTalk(NSEvent value)
|
|
{
|
|
if (settings.InputMode != AudioInputMode.PushToTalk || value.KeyCode != settings.PushToTalkKeyCode) return value;
|
|
bool engaged = value.Type == NSEventType.KeyDown;
|
|
client.Audio.PushToTalk = engaged;
|
|
if (engaged && !pushToTalkEngaged) feedback.Play(SoundEvent.PushToTalk);
|
|
pushToTalkEngaged = engaged; return null;
|
|
}
|
|
|
|
private void Pump()
|
|
{
|
|
while (client.TryReadEvent(out Envelope? envelope))
|
|
{
|
|
if (envelope!.TextMessage is { } text)
|
|
{
|
|
if (text.Scope == TextScope.TextPrivate)
|
|
{
|
|
uint other = text.SenderId == selfId ? text.TargetId : text.SenderId;
|
|
GetPrivateWindow(other).Append(text, Name(text.SenderId));
|
|
feedback.Play(text.SenderId == selfId ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived);
|
|
if (text.SenderId != selfId) feedback.Speak($"Private message from {Name(text.SenderId)}: {text.Body}");
|
|
}
|
|
else { Append(FormatMessage(text)); feedback.Play(text.SenderId == selfId ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived); }
|
|
}
|
|
if (envelope.StreamState is { } streamState)
|
|
{
|
|
if (streamState.Talking) talkingUsers.Add(streamState.UserId); else talkingUsers.Remove(streamState.UserId);
|
|
RefreshState();
|
|
}
|
|
if (envelope.UserEvent is { } userEvent)
|
|
{
|
|
string name = userEvent.User?.Nickname ?? Name(userEvent.LeftId);
|
|
if (userEvent.Kind == UserEvent.Types.Kind.Joined && userEvent.User?.ChannelId == currentChannel && userEvent.User.Id != selfId)
|
|
{ AppendActivity($"{name} joined the channel"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); }
|
|
else if (userEvent.Kind == UserEvent.Types.Kind.Left)
|
|
{ talkingUsers.Remove(userEvent.LeftId); AppendActivity($"{name} left the server"); feedback.Play(SoundEvent.ChannelLeave); }
|
|
}
|
|
if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState();
|
|
if (envelope.Disconnect is { } disconnected)
|
|
{
|
|
StopAudioDevices(); joinedVoice = false; voice.Title = "Join voice";
|
|
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.Enabled = false;
|
|
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost"); Announce(status.StringValue);
|
|
}
|
|
}
|
|
if (client.Audio.Failure is { } failure) { status.StringValue = "Audio stopped: " + failure.Message; return; }
|
|
if (audioBackend.Failure is { } deviceFailure) { status.StringValue = "Audio device stopped: " + deviceFailure.Message; return; }
|
|
if (joinedVoice)
|
|
{
|
|
(float level, bool talking) = client.Audio.GetLocalLevel(microphoneStreamId);
|
|
if (talking != lastTalking)
|
|
{
|
|
lastTalking = talking; client.PublishStreamState(microphoneStreamId, talking);
|
|
feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
|
|
}
|
|
bool receiving = Environment.TickCount64 - Volatile.Read(ref lastRemoteAudioTick) < 1_000;
|
|
status.StringValue = $"Voice connected · Mic {(talking ? "sending" : "idle")} {level:P0} · Remote audio {(receiving ? "active" : "idle")}";
|
|
}
|
|
}
|
|
private void OpenSettings(object? sender, EventArgs args)
|
|
{
|
|
settingsWindow ??= new(this, settings, audioBackend);
|
|
settingsWindow.ShowWindow(this); settingsWindow.Window?.MakeKeyAndOrderFront(this);
|
|
}
|
|
private void OpenAdministration(object? sender, EventArgs args)
|
|
{
|
|
administrationWindow ??= new(client, selfId); administrationWindow.ShowWindow(this); administrationWindow.Window?.MakeKeyAndOrderFront(this);
|
|
}
|
|
private void OpenPrivateMessage(object? sender, EventArgs args)
|
|
{
|
|
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selected) ? selected : 0;
|
|
if (target == 0) { status.StringValue = "Choose a private message recipient first."; return; }
|
|
PrivateMessageWindowController window = GetPrivateWindow(target); window.ShowWindow(this); window.Window?.MakeKeyAndOrderFront(this);
|
|
}
|
|
private PrivateMessageWindowController GetPrivateWindow(uint userId)
|
|
{
|
|
if (privateWindows.TryGetValue(userId, out PrivateMessageWindowController? existing)) return existing;
|
|
var created = new PrivateMessageWindowController(client, userId, selfId, Name(userId)); privateWindows[userId] = created; return created;
|
|
}
|
|
|
|
private void SelfAudioChanged(object? sender, EventArgs args)
|
|
{
|
|
bool muted = muteButton.State == NSCellStateValue.On;
|
|
bool deafened = deafenButton.State == NSCellStateValue.On;
|
|
client.SetSelfAudioState(muted, deafened);
|
|
status.StringValue = deafened ? "Deafened" : muted ? "Microphone muted" : "Voice active";
|
|
}
|
|
|
|
private async void ToggleScreenAudio(object? sender, EventArgs args)
|
|
{
|
|
try
|
|
{
|
|
screenAudioButton.Enabled = false;
|
|
if (screenAudioStreamId != 0) { StopScreenAudio(); return; }
|
|
if (!joinedVoice) throw new InvalidOperationException("Join voice before sharing screen audio.");
|
|
ScreenAudioSelection? selection = await ScreenAudioPicker.ChooseAsync(screenAudioSelection);
|
|
if (selection is null) return;
|
|
screenAudioSelection = selection;
|
|
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamScreenAudio, "Desktop audio", 2);
|
|
screenAudioStreamId = stream.StreamId;
|
|
int channelCount = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
|
|
var capture = new ScreenAudioCapture(channelCount, screenAudioSelection,
|
|
(pcm, channels) => { uint id = screenAudioStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels); });
|
|
capture.Failed += exception => NSApplication.SharedApplication.InvokeOnMainThread(() => { status.StringValue = exception.Message; StopScreenAudio(); });
|
|
screenAudio = capture; await capture.StartAsync(); screenAudioButton.Title = "Stop sharing";
|
|
status.StringValue = "Sharing desktop audio";
|
|
}
|
|
catch (Exception exception) { StopScreenAudio(); status.StringValue = exception.Message; }
|
|
finally { screenAudioButton.Enabled = true; }
|
|
}
|
|
|
|
private void StopScreenAudio()
|
|
{
|
|
Interlocked.Exchange(ref screenAudio, null)?.Dispose();
|
|
uint id = screenAudioStreamId; screenAudioStreamId = 0;
|
|
TryStopStream(id);
|
|
screenAudioButton.Title = "Share audio";
|
|
}
|
|
|
|
private void ApplyEngineSettings()
|
|
{
|
|
client.Audio.InputMode = settings.InputMode; client.Audio.VadThreshold = settings.VadThreshold;
|
|
if (settings.InputMode != AudioInputMode.PushToTalk) { client.Audio.PushToTalk = false; pushToTalkEngaged = false; }
|
|
client.Audio.InputGain = settings.InputGain; client.Audio.OutputGain = settings.OutputGain;
|
|
client.Audio.InputNoiseReduction = settings.InputNoiseReduction;
|
|
client.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
|
|
if (playback is not null) playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
|
if (auxiliaryStreamId != 0) client.Audio.SetLocalGain(auxiliaryStreamId, settings.AuxiliaryGain);
|
|
}
|
|
|
|
internal async Task ApplySettingsAsync()
|
|
{
|
|
ApplyEngineSettings();
|
|
if (!joinedVoice) return;
|
|
bool restart = activeInputDevice != settings.InputDeviceId || activeOutputDevice != settings.OutputDeviceId || activeStereo != settings.StereoMicrophone;
|
|
if (restart) { await LeaveVoice(); await JoinVoice(); return; }
|
|
if (settings.AuxiliaryEnabled && auxiliaryStreamId == 0) await StartAuxiliaryAsync();
|
|
else if (!settings.AuxiliaryEnabled && auxiliaryStreamId != 0) StopAuxiliary();
|
|
else if (settings.AuxiliaryEnabled && activeAuxiliaryDevice != settings.AuxiliaryDeviceId) { StopAuxiliary(); await StartAuxiliaryAsync(); }
|
|
}
|
|
private void RefreshState()
|
|
{
|
|
uint previousTarget = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
|
|
channels.RemoveAllItems();
|
|
IReadOnlyList<(Channel Channel, int Depth)> hierarchy = FlattenChannels(client.Channels);
|
|
foreach ((Channel channel, int depth) in hierarchy)
|
|
{
|
|
int members = client.Users.Count(user => user.ChannelId == channel.Id);
|
|
channels.AddItem(new string(' ', depth * 3) + channel.Name + $" ({members})" + (channel.PasswordProtected ? " [password]" : ""));
|
|
channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString());
|
|
channels.LastItem!.ToolTip = channel.Topic;
|
|
}
|
|
currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel;
|
|
int selectedIndex = hierarchy.ToList().FindIndex(item => item.Channel.Id == currentChannel); if (selectedIndex >= 0) channels.SelectItem(selectedIndex);
|
|
users.Value = string.Join("\n", client.Users.Where(u => u.ChannelId == currentChannel).OrderBy(u => u.Nickname).Select(u =>
|
|
(talkingUsers.Contains(u.Id) ? "Speaking — " : "") + (u.Id == selfId ? "You — " : "") + u.Nickname +
|
|
(u.ServerDeafened ? " [server deafened]" : u.ServerMuted ? " [server muted]" : u.SelfDeafened ? " [deafened]" : u.SelfMicMuted ? " [muted]" : "")));
|
|
messageTarget.RemoveAllItems(); messageTarget.AddItem("Current channel"); messageTarget.LastItem!.RepresentedObject = new NSString("0");
|
|
foreach (var user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname))
|
|
{
|
|
messageTarget.AddItem("Private: " + user.Nickname);
|
|
messageTarget.LastItem!.RepresentedObject = new NSString(user.Id.ToString());
|
|
}
|
|
int targetIndex = client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname).ToList().FindIndex(user => user.Id == previousTarget) + 1;
|
|
messageTarget.SelectItem(Math.Max(0, targetIndex));
|
|
status.StringValue = $"Connected · {client.Users.Count} users";
|
|
}
|
|
private static IReadOnlyList<(Channel Channel, int Depth)> FlattenChannels(IReadOnlyList<Channel> values)
|
|
{
|
|
var result = new List<(Channel, int)>(); var visited = new HashSet<uint>();
|
|
void AddChildren(uint parent, int depth)
|
|
{
|
|
foreach (Channel channel in values.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name))
|
|
if (visited.Add(channel.Id)) { result.Add((channel, depth)); AddChildren(channel.Id, depth + 1); }
|
|
}
|
|
AddChildren(0, 0);
|
|
foreach (Channel channel in values.Where(channel => !visited.Contains(channel.Id)).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) result.Add((channel, 0));
|
|
return result;
|
|
}
|
|
private async void ChangeChannel(object? sender, EventArgs args)
|
|
{
|
|
if (changingChannel || !uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return;
|
|
var channel = client.Channels.FirstOrDefault(item => item.Id == id);
|
|
if (channel is null) { RefreshState(); return; }
|
|
string? password = channel.PasswordProtected ? PromptForChannelPassword(channel.Name) : "";
|
|
if (password is null) { RefreshState(); return; }
|
|
bool resumeVoice = joinedVoice;
|
|
try
|
|
{
|
|
changingChannel = true; channels.Enabled = false;
|
|
if (resumeVoice) await LeaveVoice();
|
|
var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id, Password = password } })).JoinChannelResult;
|
|
if (result.Ok) currentChannel = id;
|
|
if (resumeVoice) await JoinVoice();
|
|
RefreshState();
|
|
status.StringValue = result.Ok ? $"Joined {channel.Name}" : result.Error;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
string message = exception.Message;
|
|
if (resumeVoice && !joinedVoice)
|
|
{
|
|
try { await JoinVoice(); }
|
|
catch (Exception restoreException) { message += " Voice could not be restored: " + restoreException.Message; }
|
|
}
|
|
RefreshState(); status.StringValue = message;
|
|
}
|
|
finally { changingChannel = false; channels.Enabled = true; }
|
|
}
|
|
|
|
private string? PromptForChannelPassword(string channelName)
|
|
{
|
|
var field = new NSSecureTextField(new CGRect(0, 0, 320, 26)) { PlaceholderString = "Channel password" };
|
|
((INSAccessibility)field).AccessibilityLabel = $"Password for {channelName}";
|
|
var alert = new NSAlert
|
|
{
|
|
MessageText = $"Join {channelName}",
|
|
InformativeText = "Enter the channel password.",
|
|
AlertStyle = NSAlertStyle.Informational,
|
|
AccessoryView = field
|
|
};
|
|
alert.AddButton("Join"); alert.AddButton("Cancel");
|
|
if (alert.RunModal() != 1000) { field.StringValue = ""; return null; }
|
|
string value = field.StringValue; field.StringValue = ""; return value;
|
|
}
|
|
private void Send(object? sender, EventArgs args)
|
|
{
|
|
string body = compose.StringValue.Trim(); if (body.Length == 0) return;
|
|
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
|
|
try { client.Send(new() { TextMessage = new() { Scope = target == 0 ? TextScope.TextChannel : TextScope.TextPrivate, TargetId = target == 0 ? currentChannel : target, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); compose.StringValue = ""; }
|
|
catch (Exception exception) { status.StringValue = exception.Message; }
|
|
}
|
|
private async void ToggleVoice(object? sender, EventArgs args)
|
|
{
|
|
try
|
|
{
|
|
voice.Enabled = false;
|
|
if (!joinedVoice) await JoinVoice();
|
|
else await LeaveVoice();
|
|
}
|
|
catch (Exception exception) { status.StringValue = exception.Message; }
|
|
finally { voice.Enabled = true; }
|
|
}
|
|
|
|
private void PopulateAudioDevices(NSPopUpButton menu, bool input)
|
|
{
|
|
IReadOnlyList<AudioDeviceInfo> devices = audioBackend.Enumerate(input);
|
|
foreach (AudioDeviceInfo device in devices)
|
|
{
|
|
menu.AddItem(device.Name + (device.IsDefault ? " (default)" : ""));
|
|
menu.LastItem!.RepresentedObject = new NSString(device.Id);
|
|
}
|
|
int selected = devices.ToList().FindIndex(device => device.IsDefault);
|
|
if (selected >= 0) menu.SelectItem(selected);
|
|
menu.Enabled = devices.Count > 0;
|
|
}
|
|
|
|
private async void ChangeAudioDevice(object? sender, EventArgs args)
|
|
{
|
|
if (!joinedVoice) return;
|
|
try { await LeaveVoice(); await JoinVoice(); }
|
|
catch (Exception exception) { status.StringValue = exception.Message; }
|
|
}
|
|
|
|
private async Task JoinVoice()
|
|
{
|
|
AVAuthorizationStatus permission = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Audio);
|
|
if (permission == AVAuthorizationStatus.NotDetermined)
|
|
{
|
|
bool granted = await AVCaptureDevice.RequestAccessForMediaTypeAsync(AVAuthorizationMediaType.Audio);
|
|
permission = granted ? AVAuthorizationStatus.Authorized : AVAuthorizationStatus.Denied;
|
|
}
|
|
if (permission != AVAuthorizationStatus.Authorized)
|
|
throw new UnauthorizedAccessException("Microphone access is disabled. Enable VoiceCat in System Settings → Privacy & Security → Microphone, then join voice again.");
|
|
|
|
var result = await client.SubscribeVoiceAsync();
|
|
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
|
try
|
|
{
|
|
ApplyEngineSettings();
|
|
activeInputDevice = settings.InputDeviceId ?? SelectedDevice(inputDevice);
|
|
activeOutputDevice = settings.OutputDeviceId ?? SelectedDevice(outputDevice);
|
|
activeStereo = settings.StereoMicrophone;
|
|
playback = audioBackend.OpenPlayback(activeOutputDevice);
|
|
playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
|
client.Audio.MixedPcm += PlayMixedPcm;
|
|
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone", settings.StereoMicrophone ? 2 : 1);
|
|
microphoneStreamId = stream.StreamId;
|
|
microphone = audioBackend.OpenCapture(activeInputDevice, false, FeedMicrophone);
|
|
if (settings.AuxiliaryEnabled) await StartAuxiliaryAsync();
|
|
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
|
|
feedback.Play(SoundEvent.VoiceOn);
|
|
}
|
|
catch
|
|
{
|
|
try { if (microphoneStreamId != 0) client.StopStream(microphoneStreamId); } catch { }
|
|
StopAudioDevices();
|
|
try { await client.SubscribeVoiceAsync(false); } catch { }
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task LeaveVoice()
|
|
{
|
|
uint streamId = microphoneStreamId;
|
|
joinedVoice = false; voice.Title = "Join voice";
|
|
StopAudioDevices();
|
|
if (streamId != 0) client.StopStream(streamId);
|
|
await client.SubscribeVoiceAsync(false);
|
|
status.StringValue = "Voice disconnected";
|
|
feedback.Play(SoundEvent.VoiceOff);
|
|
}
|
|
|
|
private async Task StartAuxiliaryAsync()
|
|
{
|
|
if (auxiliaryStreamId != 0) return;
|
|
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamAuxDevice, "Aux device", 2);
|
|
auxiliaryStreamId = stream.StreamId; client.Audio.SetLocalGain(stream.StreamId, settings.AuxiliaryGain);
|
|
try { activeAuxiliaryDevice = settings.AuxiliaryDeviceId; auxiliary = audioBackend.OpenCapture(activeAuxiliaryDevice, false, FeedAuxiliary); }
|
|
catch { client.StopStream(auxiliaryStreamId); auxiliaryStreamId = 0; throw; }
|
|
}
|
|
|
|
private void StopAuxiliary()
|
|
{
|
|
Interlocked.Exchange(ref auxiliary, null)?.Dispose();
|
|
activeAuxiliaryDevice = null;
|
|
uint id = auxiliaryStreamId; auxiliaryStreamId = 0;
|
|
TryStopStream(id);
|
|
}
|
|
|
|
private void FeedAuxiliary(ReadOnlySpan<short> pcm, int channels)
|
|
{
|
|
uint id = auxiliaryStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels);
|
|
}
|
|
|
|
private void FeedMicrophone(ReadOnlySpan<short> pcm, int channels)
|
|
{
|
|
uint streamId = microphoneStreamId;
|
|
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
|
|
}
|
|
|
|
private static string? SelectedDevice(NSPopUpButton menu) => menu.SelectedItem?.RepresentedObject?.ToString();
|
|
|
|
private void PlayMixedPcm(ReadOnlySpan<short> pcm)
|
|
{
|
|
bool signal = false;
|
|
foreach (short sample in pcm)
|
|
if (sample is > 64 or < -64) { signal = true; break; }
|
|
if (signal) Volatile.Write(ref lastRemoteAudioTick, Environment.TickCount64);
|
|
Volatile.Read(ref playback)?.Write(pcm);
|
|
}
|
|
|
|
private void StopAudioDevices()
|
|
{
|
|
StopScreenAudio();
|
|
StopAuxiliary();
|
|
microphoneStreamId = 0;
|
|
Volatile.Write(ref lastRemoteAudioTick, 0);
|
|
Interlocked.Exchange(ref microphone, null)?.Dispose();
|
|
client.Audio.MixedPcm -= PlayMixedPcm;
|
|
Interlocked.Exchange(ref playback, null)?.Dispose();
|
|
activeInputDevice = activeOutputDevice = null; lastTalking = false;
|
|
}
|
|
private void TryStopStream(uint id)
|
|
{
|
|
if (id == 0 || client.State != ClientConnectionState.Connected) return;
|
|
try { client.StopStream(id); }
|
|
catch (InvalidOperationException) { }
|
|
catch (IOException) { }
|
|
}
|
|
private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}";
|
|
private string FormatMessage(TextMessage text)
|
|
{
|
|
string prefix = text.Scope switch
|
|
{
|
|
TextScope.TextPrivate => $"[private: {Name(text.SenderId == selfId ? text.TargetId : text.SenderId)}] ",
|
|
TextScope.TextServer => "[server] ",
|
|
_ => ""
|
|
};
|
|
return $"[{DateTime.Now:t}] {prefix}{Name(text.SenderId)}: {text.Body}";
|
|
}
|
|
private void Append(string line)
|
|
{
|
|
chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line;
|
|
chat.ScrollRangeToVisible(new NSRange(chat.Value.Length, 0));
|
|
}
|
|
private void AppendActivity(string line) => Append($"[{DateTime.Now:t}] — {line}");
|
|
private void Announce(string value)
|
|
{
|
|
NSString[] keys = [NSAccessibilityNotificationUserInfoKeys.AnnouncementKey, NSAccessibilityNotificationUserInfoKeys.PriorityKey];
|
|
NSObject[] values = [(NSString)value, NSNumber.FromInt32(1)];
|
|
NSAccessibility.PostNotification(status, NSView.AnnouncementRequestedNotification, new NSDictionary<NSString, NSObject>(keys, values));
|
|
}
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) { timer.Invalidate(); if (pushToTalkMonitor is not null) NSEvent.RemoveMonitor(pushToTalkMonitor); client.Audio.PushToTalk = false; settingsWindow?.Close(); administrationWindow?.Close(); foreach (PrivateMessageWindowController window in privateWindows.Values) window.Close(); privateWindows.Clear(); StopAudioDevices(); feedback.Dispose(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|