.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
272 lines
14 KiB
C#
272 lines
14 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 NSTextField status = NSTextField.CreateLabel("Connected");
|
|
private readonly NSTimer timer;
|
|
private uint currentChannel = 1;
|
|
private uint microphoneStreamId;
|
|
private bool joinedVoice;
|
|
private bool changingChannel;
|
|
private IAudioCapture? microphone;
|
|
private IAudioPlayback? playback;
|
|
private long lastRemoteAudioTick;
|
|
|
|
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 760, 570),
|
|
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
|
|
{
|
|
this.client = client; this.selfId = selfId;
|
|
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);
|
|
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, 700, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status);
|
|
timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
|
|
RefreshState();
|
|
}
|
|
|
|
private void Pump()
|
|
{
|
|
while (client.TryReadEvent(out Envelope? envelope))
|
|
{
|
|
if (envelope!.TextMessage is { } text) Append(FormatMessage(text));
|
|
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;
|
|
}
|
|
}
|
|
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);
|
|
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 RefreshState()
|
|
{
|
|
uint previousTarget = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
|
|
channels.RemoveAllItems();
|
|
foreach (var channel in client.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name))
|
|
{
|
|
channels.AddItem(channel.Name + (channel.PasswordProtected ? " [password]" : ""));
|
|
channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString());
|
|
}
|
|
currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel;
|
|
int selectedIndex = client.Channels.ToList().FindIndex(c => c.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 => (u.Id == selfId ? "You — " : "") + u.Nickname));
|
|
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 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
|
|
{
|
|
playback = audioBackend.OpenPlayback(SelectedDevice(outputDevice));
|
|
client.Audio.MixedPcm += PlayMixedPcm;
|
|
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone");
|
|
microphoneStreamId = stream.StreamId;
|
|
microphone = audioBackend.OpenCapture(SelectedDevice(inputDevice), false, FeedMicrophone);
|
|
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
|
|
}
|
|
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";
|
|
}
|
|
|
|
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()
|
|
{
|
|
microphoneStreamId = 0;
|
|
Volatile.Write(ref lastRemoteAudioTick, 0);
|
|
Interlocked.Exchange(ref microphone, null)?.Dispose();
|
|
client.Audio.MixedPcm -= PlayMixedPcm;
|
|
Interlocked.Exchange(ref playback, null)?.Dispose();
|
|
}
|
|
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));
|
|
}
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) { timer.Invalidate(); StopAudioDevices(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|