2026-09-16 17:34:16 +02:00
using AppKit ;
2026-09-18 18:51:53 +02:00
using AVFoundation ;
2026-09-16 17:34:16 +02:00
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 ;
2026-09-18 18:51:53 +02:00
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 );
2026-09-16 17:34:16 +02:00
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" };
2026-09-19 00:39:04 +02:00
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" , () => { });
2026-09-16 17:34:16 +02:00
private readonly NSTextField status = NSTextField . CreateLabel ( "Connected" );
2026-09-19 00:39:04 +02:00
private readonly MacSettings settings = new ();
private readonly EventFeedback feedback ;
2026-09-16 17:34:16 +02:00
private readonly NSTimer timer ;
private uint currentChannel = 1 ;
2026-09-16 22:05:40 +02:00
private uint microphoneStreamId ;
2026-09-19 00:39:04 +02:00
private uint auxiliaryStreamId ;
private uint screenAudioStreamId ;
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 ;
2026-09-19 00:39:04 +02:00
private IAudioCapture ? auxiliary ;
2026-09-16 22:05:40 +02:00
private IAudioPlayback ? playback ;
2026-09-19 00:39:04 +02:00
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 ;
2026-09-18 18:51:53 +02:00
private long lastRemoteAudioTick ;
2026-09-16 17:34:16 +02:00
2026-09-19 00:39:04 +02:00
internal MainWindowController ( VoiceCatClient client , uint selfId , string nickname ) : base ( new NSWindow ( new CGRect ( 0 , 0 , 1000 , 570 ),
2026-09-16 17:34:16 +02:00
NSWindowStyle . Titled | NSWindowStyle . Closable | NSWindowStyle . Resizable | NSWindowStyle . Miniaturizable , NSBackingStore . Buffered , false ))
{
2026-09-19 00:39:04 +02:00
this . client = client ; this . selfId = selfId ; settings . Load (); feedback = new ( settings );
2026-09-16 17:34:16 +02:00
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 );
2026-09-18 18:51:53 +02:00
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 );
2026-09-16 22:05:40 +02:00
(( INSAccessibility ) voice ). AccessibilityLabel = "Join or leave voice" ; voice . Activated += ToggleVoice ; content . AddSubview ( voice );
2026-09-19 00:39:04 +02:00
(( 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 );
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-19 00:39:04 +02:00
status . Frame = new CGRect ( 20 , 22 , 660 , 22 ); status . AccessibilityLabel = "Connection status" ; content . AddSubview ( status );
2026-09-16 17:34:16 +02:00
timer = NSTimer . CreateRepeatingScheduledTimer ( TimeSpan . FromMilliseconds ( 50 ), _ => Pump ());
2026-09-19 00:39:04 +02:00
ApplyEngineSettings (); feedback . Play ( SoundEvent . Login ); feedback . Speak ( "Connected" );
pushToTalkMonitor = NSEvent . AddLocalMonitorForEventsMatchingMask ( NSEventMask . KeyDown | NSEventMask . KeyUp , value => HandlePushToTalk ( value )!);
2026-09-16 17:34:16 +02:00
RefreshState ();
}
2026-09-19 00:39:04 +02:00
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 ;
}
2026-09-16 17:34:16 +02:00
private void Pump ()
{
while ( client . TryReadEvent ( out Envelope ? envelope ))
{
2026-09-19 00:39:04 +02:00
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 ); }
}
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-19 00:39:04 +02:00
feedback . Play ( SoundEvent . ConnectionLost ); feedback . Speak ( "Connection lost" ); Announce ( status . StringValue );
2026-09-16 22:05:40 +02:00
}
2026-09-16 17:34:16 +02:00
}
2026-09-18 18:51:53 +02:00
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 );
2026-09-19 00:39:04 +02:00
if ( talking != lastTalking )
{
lastTalking = talking ; client . PublishStreamState ( microphoneStreamId , talking );
feedback . Play ( talking ? SoundEvent . VoiceStart : SoundEvent . VoiceStop );
}
2026-09-18 18:51:53 +02:00
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 ")}" ;
}
2026-09-16 17:34:16 +02:00
}
2026-09-19 00:39:04 +02:00
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 ;
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 (); }
}
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-19 00:39:04 +02:00
IReadOnlyList <( Channel Channel , int Depth )> hierarchy = FlattenChannels ( client . Channels );
foreach (( Channel channel , int depth ) in hierarchy )
2026-09-16 22:16:18 +02:00
{
2026-09-19 00:39:04 +02:00
int members = client . Users . Count ( user => user . ChannelId == channel . Id );
channels . AddItem ( new string ( ' ' , depth * 3 ) + channel . Name + $" ({members})" + ( channel . PasswordProtected ? " [password]" : "" ));
2026-09-16 22:16:18 +02:00
channels . LastItem !. RepresentedObject = new NSString ( channel . Id . ToString ());
2026-09-19 00:39:04 +02:00
channels . LastItem !. ToolTip = channel . Topic ;
2026-09-16 22:16:18 +02:00
}
2026-09-16 17:34:16 +02:00
currentChannel = client . Users . FirstOrDefault ( u => u . Id == selfId )?. ChannelId ?? currentChannel ;
2026-09-19 00:39:04 +02:00
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]" : "" )));
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" ;
}
2026-09-19 00:39:04 +02:00
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 ;
}
2026-09-16 17:34:16 +02:00
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 ; }
}
2026-09-18 18:51:53 +02:00
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 ; }
}
2026-09-16 22:05:40 +02:00
private async Task JoinVoice ()
{
2026-09-18 18:51:53 +02:00
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." );
2026-09-16 22:05:40 +02:00
var result = await client . SubscribeVoiceAsync ();
if (! result . Ok ) throw new InvalidOperationException ( result . Error );
try
{
2026-09-19 00:39:04 +02:00
ApplyEngineSettings ();
activeInputDevice = settings . InputDeviceId ?? SelectedDevice ( inputDevice );
activeOutputDevice = settings . OutputDeviceId ?? SelectedDevice ( outputDevice );
activeStereo = settings . StereoMicrophone ;
playback = audioBackend . OpenPlayback ( activeOutputDevice );
2026-09-16 22:05:40 +02:00
client . Audio . MixedPcm += PlayMixedPcm ;
2026-09-19 00:39:04 +02:00
StreamInfo stream = await client . StartStreamAsync ( StreamKind . StreamMic , "Microphone" , settings . StereoMicrophone ? 2 : 1 );
2026-09-16 22:05:40 +02:00
microphoneStreamId = stream . StreamId ;
2026-09-19 00:39:04 +02:00
microphone = audioBackend . OpenCapture ( activeInputDevice , false , FeedMicrophone );
if ( settings . AuxiliaryEnabled ) await StartAuxiliaryAsync ();
2026-09-16 22:05:40 +02:00
joinedVoice = true ; voice . Title = "Leave voice" ; status . StringValue = "Voice connected" ;
2026-09-19 00:39:04 +02:00
feedback . Play ( SoundEvent . VoiceOn );
2026-09-16 22:05:40 +02:00
}
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" ;
2026-09-19 00:39:04 +02:00
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 );
2026-09-16 22:05:40 +02:00
}
private void FeedMicrophone ( ReadOnlySpan < short > pcm , int channels )
{
uint streamId = microphoneStreamId ;
if ( streamId != 0 ) client . Audio . FeedPcm ( streamId , pcm , channels );
}
2026-09-18 18:51:53 +02:00
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 );
}
2026-09-16 22:05:40 +02:00
private void StopAudioDevices ()
{
2026-09-19 00:39:04 +02:00
StopScreenAudio ();
StopAuxiliary ();
2026-09-16 22:05:40 +02:00
microphoneStreamId = 0 ;
2026-09-18 18:51:53 +02:00
Volatile . Write ( ref lastRemoteAudioTick , 0 );
2026-09-16 22:05:40 +02:00
Interlocked . Exchange ( ref microphone , null )?. Dispose ();
client . Audio . MixedPcm -= PlayMixedPcm ;
Interlocked . Exchange ( ref playback , null )?. Dispose ();
2026-09-19 00:39:04 +02:00
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 ) { }
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-19 00:39:04 +02:00
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 ));
}
2026-09-16 17:34:16 +02:00
protected override void Dispose ( bool disposing )
{
2026-09-19 00:39:04 +02:00
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 (); }
2026-09-16 17:34:16 +02:00
base . Dispose ( disposing );
}
}