2026-09-16 17:34:16 +02:00
using AppKit ;
using CoreGraphics ;
using Foundation ;
2026-09-16 22:05:40 +02:00
using VoiceCat.Audio ;
2026-09-16 17:34:16 +02:00
using VoiceCat.Core ;
using Voicecat.V1 ;
namespace VoiceCat.Mac ;
internal sealed class MainWindowController : NSWindowController
{
private readonly VoiceCatClient client ;
2026-09-16 22:05:40 +02:00
private readonly MacAudioBackend audioBackend = new ();
2026-09-16 17:34:16 +02:00
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 };
2026-09-16 22:24:52 +02:00
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" };
2026-09-16 17:34:16 +02:00
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 ;
2026-09-16 22:05:40 +02:00
private uint microphoneStreamId ;
2026-09-16 17:34:16 +02:00
private bool joinedVoice ;
2026-09-16 22:16:18 +02:00
private bool changingChannel ;
2026-09-16 22:05:40 +02:00
private IAudioCapture ? microphone ;
private IAudioPlayback ? playback ;
2026-09-16 17:34:16 +02:00
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 !;
2026-09-16 22:05:40 +02:00
(( INSAccessibility ) channels ). AccessibilityLabel = "Channel" ; channels . Activated += ChangeChannel ; content . AddSubview ( channels );
(( INSAccessibility ) voice ). AccessibilityLabel = "Join or leave voice" ; voice . Activated += ToggleVoice ; content . AddSubview ( voice );
2026-09-16 17:34:16 +02:00
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 );
2026-09-16 22:24:52 +02:00
(( INSAccessibility ) messageTarget ). AccessibilityLabel = "Message recipient" ; content . AddSubview ( messageTarget );
2026-09-16 22:05:40 +02:00
(( INSAccessibility ) compose ). AccessibilityLabel = "Message" ; compose . Activated += Send ; content . AddSubview ( compose );
(( INSAccessibility ) send ). AccessibilityLabel = "Send message" ; send . Activated += Send ; content . AddSubview ( send );
2026-09-16 17:34:16 +02:00
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 ))
{
2026-09-16 22:24:52 +02:00
if ( envelope !. TextMessage is { } text ) Append ( FormatMessage ( text ));
2026-09-16 17:34:16 +02:00
if ( envelope . ServerState is not null || envelope . ChannelEvent is not null || envelope . UserEvent is not null ) RefreshState ();
2026-09-16 22:05:40 +02:00
if ( envelope . Disconnect is { } disconnected )
{
StopAudioDevices (); joinedVoice = false ; voice . Title = "Join voice" ;
2026-09-16 22:24:52 +02:00
status . StringValue = "Disconnected: " + disconnected . Reason ; voice . Enabled = send . Enabled = messageTarget . Enabled = false ;
2026-09-16 22:05:40 +02:00
}
2026-09-16 17:34:16 +02:00
}
if ( client . Audio . Failure is { } failure ) status . StringValue = "Audio stopped: " + failure . Message ;
2026-09-16 22:05:40 +02:00
if ( audioBackend . Failure is { } deviceFailure ) status . StringValue = "Audio device stopped: " + deviceFailure . Message ;
2026-09-16 17:34:16 +02:00
}
private void RefreshState ()
{
2026-09-16 22:24:52 +02:00
uint previousTarget = uint . TryParse ( messageTarget . SelectedItem ?. RepresentedObject ?. ToString (), out uint selectedTarget ) ? selectedTarget : 0 ;
2026-09-16 17:34:16 +02:00
channels . RemoveAllItems ();
2026-09-16 22:16:18 +02:00
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 ());
}
2026-09-16 17:34:16 +02:00
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 ));
2026-09-16 22:24:52 +02:00
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 ));
2026-09-16 17:34:16 +02:00
status . StringValue = $"Connected · {client.Users.Count} users" ;
}
private async void ChangeChannel ( object? sender , EventArgs args )
{
2026-09-16 22:16:18 +02:00
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 ;
2026-09-16 22:05:40 +02:00
try
{
2026-09-16 22:16:18 +02:00
changingChannel = true ; channels . Enabled = false ;
2026-09-16 22:05:40 +02:00
if ( resumeVoice ) await LeaveVoice ();
2026-09-16 22:16:18 +02:00
var result = ( await client . RequestAsync ( new () { JoinChannel = new () { ChannelId = id , Password = password } })). JoinChannelResult ;
if ( result . Ok ) currentChannel = id ;
2026-09-16 22:05:40 +02:00
if ( resumeVoice ) await JoinVoice ();
2026-09-16 22:16:18 +02:00
RefreshState ();
status . StringValue = result . Ok ? $"Joined {channel.Name}" : result . Error ;
2026-09-16 22:05:40 +02:00
}
2026-09-16 22:16:18 +02:00
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 ;
2026-09-16 17:34:16 +02:00
}
private void Send ( object? sender , EventArgs args )
{
string body = compose . StringValue . Trim (); if ( body . Length == 0 ) return ;
2026-09-16 22:24:52 +02:00
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 = "" ; }
2026-09-16 17:34:16 +02:00
catch ( Exception exception ) { status . StringValue = exception . Message ; }
}
private async void ToggleVoice ( object? sender , EventArgs args )
{
try
{
2026-09-16 22:05:40 +02:00
voice . Enabled = false ;
if (! joinedVoice ) await JoinVoice ();
else await LeaveVoice ();
2026-09-16 17:34:16 +02:00
}
catch ( Exception exception ) { status . StringValue = exception . Message ; }
2026-09-16 22:05:40 +02:00
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 < short > pcm , int channels )
{
uint streamId = microphoneStreamId ;
if ( streamId != 0 ) client . Audio . FeedPcm ( streamId , pcm , channels );
}
private void PlayMixedPcm ( ReadOnlySpan < short > 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 ();
2026-09-16 17:34:16 +02:00
}
private string Name ( uint id ) => client . Users . FirstOrDefault ( u => u . Id == id )?. Nickname ?? $"User {id}" ;
2026-09-16 22:24:52 +02:00
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}" ;
}
2026-09-16 22:05:40 +02:00
private void Append ( string line )
{
chat . Value = chat . Value . Length == 0 ? line : chat . Value + "\n" + line ;
chat . ScrollRangeToVisible ( new NSRange ( chat . Value . Length , 0 ));
}
2026-09-16 17:34:16 +02:00
protected override void Dispose ( bool disposing )
{
2026-09-16 22:05:40 +02:00
if ( disposing ) { timer . Invalidate (); StopAudioDevices (); client . DisposeAsync (). AsTask (). GetAwaiter (). GetResult (); }
2026-09-16 17:34:16 +02:00
base . Dispose ( disposing );
}
}