using AppKit; 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, 300, 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; 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); ((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; if (audioBackend.Failure is { } deviceFailure) status.StringValue = "Audio device stopped: " + deviceFailure.Message; } 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 async Task JoinVoice() { var result = await client.SubscribeVoiceAsync(); if (!result.Ok) throw new InvalidOperationException(result.Error); try { playback = audioBackend.OpenPlayback(); client.Audio.MixedPcm += PlayMixedPcm; StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone"); microphoneStreamId = stream.StreamId; microphone = audioBackend.OpenCapture(null, 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 pcm, int channels) { uint streamId = microphoneStreamId; if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels); } private void PlayMixedPcm(ReadOnlySpan pcm) => Volatile.Read(ref playback)?.Write(pcm); private void StopAudioDevices() { microphoneStreamId = 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); } }