2026-09-19 19:33:10 +02:00
using AVFoundation ;
using AVKit ;
using ReplayKit ;
using UIKit ;
using VoiceCat.Audio ;
using Voicecat.V1 ;
namespace VoiceCat.iOS ;
internal sealed class SettingsController : UITableViewController
{
private readonly AppModel model ;
internal SettingsController ( AppModel model ) : base ( UITableViewStyle . InsetGrouped ) { this . model = model ; Title = "Settings" ; model . Changed += Reload ; IosAudioRouter . Shared . Changed += Reload ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "setting" ); }
private void Reload () => TableView . ReloadData ();
public override nint NumberOfSections ( UITableView tableView ) => 5 ;
2026-09-20 16:25:30 +02:00
public override nint RowsInSection ( UITableView tableView , nint section ) => section switch { 0 => 5 , 1 => 4 , 2 => 5 , 3 => model . Client ?. Permissions is { } p && ( p . IsAdmin || p . CanAdminAccounts ) ? 1 : 0 , _ => 2 };
2026-09-19 19:33:10 +02:00
public override string? TitleForHeader ( UITableView tableView , nint section ) => section switch { 0 => "Audio" , 1 => "Voice" , 2 => "Notifications" , 3 => "Administration" , _ => "Server" };
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath path )
{
UITableViewCell cell = tableView . DequeueReusableCell ( "setting" , path ); cell . AccessoryView = null ; cell . Accessory = UITableViewCellAccessory . None ;
string title = path . Section switch
{
2026-09-20 16:25:30 +02:00
0 => path . Row switch { 0 => "Audio preset" , 1 => "Speaker output" , 2 => "Advanced audio" , 3 => "Audio buffering" , _ => model . ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" },
2026-09-19 19:33:10 +02:00
1 => path . Row switch { 0 => "Input mode" , 1 => $"VAD threshold: {model.Settings.VadThreshold:0.000}" , 2 => $"Microphone volume: {model.Settings.InputGain:P0}" , _ => "Microphone noise reduction" },
2 => path . Row switch { 0 => "Event sounds" , 1 => $"Sound volume: {model.Settings.EventVolume:P0}" , 2 => "Speak events" , 3 => "Own voice activity sounds" , _ => "Push-to-talk cue" },
3 => "Manage accounts" , _ => path . Row == 0 ? "Disconnect" : "VoiceCat 0.0.1"
};
2026-09-20 16:25:30 +02:00
var content = cell . DefaultContentConfiguration ; content . Text = title ; content . SecondaryText = path . Section == 0 && path . Row == 0 ? IosAudioRouter . Shared . Preset . ToString () : path . Section == 1 && path . Row == 0 ? model . Settings . InputMode . ToString () : path . Section == 0 && path . Row == 3 ? $"{model.Settings.AudioBufferMilliseconds} ms" : path . Section == 0 && path . Row == 4 && model . ScreenSharing ? "Sharing" : null ; cell . ContentConfiguration = content ; cell . AccessibilityLabel = title + ( content . SecondaryText is null ? "" : ", " + content . SecondaryText );
2026-09-19 19:33:10 +02:00
if ( path . Section == 0 && path . Row == 1 ) cell . AccessoryView = Toggle ( IosAudioRouter . Shared . ForceSpeaker , "Speaker output" , ( _ , _ ) => IosAudioRouter . Shared . SetForceSpeaker ((( UISwitch ) cell . AccessoryView !). On ));
else if ( path . Section == 1 && path . Row == 3 ) cell . AccessoryView = Toggle ( model . Settings . InputNoiseReduction , title , ( _ , _ ) => { model . Settings . InputNoiseReduction = (( UISwitch ) cell . AccessoryView !). On ; model . ApplyVoiceSettings (); });
else if ( path . Section == 2 && path . Row is 0 or 2 or 3 or 4 ) { bool value = path . Row switch { 0 => model . Settings . EventSounds , 2 => model . Settings . SpokenEvents , 3 => model . Settings . SelfTalkSounds , _ => model . Settings . PushToTalkSound }; int row = path . Row ; cell . AccessoryView = Toggle ( value , title , ( _ , _ ) => { bool on = (( UISwitch ) cell . AccessoryView !). On ; if ( row == 0 ) model . Settings . EventSounds = on ; else if ( row == 2 ) model . Settings . SpokenEvents = on ; else if ( row == 3 ) model . Settings . SelfTalkSounds = on ; else model . Settings . PushToTalkSound = on ; model . Save (); }); }
2026-09-20 16:25:30 +02:00
else if ( path . Section == 0 && path . Row is 2 or 3 || path . Section == 3 ) cell . Accessory = UITableViewCellAccessory . DisclosureIndicator ;
2026-09-19 19:33:10 +02:00
return cell ;
}
public override async void RowSelected ( UITableView tableView , NSIndexPath path )
{
tableView . DeselectRow ( path , true ); try
{
if ( path . Section == 0 && path . Row == 0 ) Choice ( "Audio preset" , Enum . GetValues < IosAudioPreset >(). Select ( value => value . ToString ()). ToArray (), index => IosAudioRouter . Shared . SelectPreset ( Enum . GetValues < IosAudioPreset >()[ index ]));
else if ( path . Section == 0 && path . Row == 2 ) NavigationController ?. PushViewController ( new AdvancedAudioController (), true );
2026-09-20 16:25:30 +02:00
else if ( path . Section == 0 && path . Row == 3 ) Choice ( "Audio buffering" , [ "Low latency (20 ms)" , "Balanced (40 ms)" , "Stable (60 ms)" ], index => { model . Settings . AudioBufferMilliseconds = index switch { 0 => 20 , 2 => 60 , _ => 40 }; model . ApplyVoiceSettings (); });
else if ( path . Section == 0 && path . Row == 4 ) ShowScreenSharing ();
2026-09-19 19:33:10 +02:00
else if ( path . Section == 1 && path . Row == 0 ) Choice ( "Input mode" , [ "Voice activation" , "Push to talk" , "Always on" ], index => { model . Settings . InputMode = ( AudioInputMode ) index ; model . ApplyVoiceSettings (); });
else if ( path . Section == 1 && path . Row == 1 ) Slider ( "Voice activation threshold" , 0.001f , 0.1f , model . Settings . VadThreshold , value => { model . Settings . VadThreshold = value ; model . ApplyVoiceSettings (); });
else if ( path . Section == 1 && path . Row == 2 ) Slider ( "Microphone volume" , 0 , 4 , model . Settings . InputGain , value => { model . Settings . InputGain = value ; model . ApplyVoiceSettings (); });
else if ( path . Section == 2 && path . Row == 1 ) Slider ( "Sound volume" , 0 , 1 , model . Settings . EventVolume , value => { model . Settings . EventVolume = value ; model . Save (); });
else if ( path . Section == 3 ) NavigationController ?. PushViewController ( new AccountsController ( model ), true );
else if ( path . Section == 4 && path . Row == 0 ) await model . DisconnectAsync ();
}
catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); }
}
private static UISwitch Toggle ( bool value , string label , EventHandler changed ) { var toggle = new UISwitch { On = value , AccessibilityLabel = label }; toggle . ValueChanged += changed ; return toggle ; }
private void Choice ( string title , string [] values , Action < int > selected ) { UIAlertController alert = UIAlertController . Create ( title , null , UIAlertControllerStyle . ActionSheet ); for ( int i = 0 ; i < values . Length ; i ++) { int index = i ; alert . AddAction ( UIAlertAction . Create ( values [ i ], UIAlertActionStyle . Default , _ => selected ( index ))); } alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); alert . PopoverPresentationController !. SourceView = View !; PresentViewController ( alert , true , null ); }
private void Slider ( string title , float minimum , float maximum , float current , Action < float > changed ) { var slider = new UISlider ( new CoreGraphics . CGRect ( 16 , 48 , 238 , 28 )) { MinValue = minimum , MaxValue = maximum , Value = current , AccessibilityLabel = title }; UIAlertController alert = UIAlertController . Create ( title , null , UIAlertControllerStyle . Alert ); alert . View !. AddSubview ( slider ); alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); alert . AddAction ( UIAlertAction . Create ( "Apply" , UIAlertActionStyle . Default , _ => changed ( slider . Value ))); PresentViewController ( alert , true , null ); }
private void ShowScreenSharing ()
{
2026-09-21 14:14:15 +02:00
// ScreenCaptureKit is the preferred iOS 27 path, but `IsAvailable` also reflects
// policy restrictions and transient OS state. Keep the bundled ReplayKit provider as
// a compatibility path instead of reporting that capable hardware is unsupported.
if ( IosScreenCapture . IsAvailable ) { model . ToggleScreenAudio (); return ; }
2026-09-19 19:33:10 +02:00
#pragma warning disable CA1422
var picker = new RPSystemBroadcastPickerView ( new CoreGraphics . CGRect ( 0 , 0 , 60 , 60 )) { PreferredExtension = IosConstants . BroadcastExtension , ShowsMicrophoneButton = false };
#pragma warning restore CA1422
foreach ( UIView view in picker . Subviews ) view . AccessibilityLabel = model . ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" ;
UIAlertController alert = UIAlertController . Create ( "Screen Audio" , "Tap the broadcast button, then choose Start Broadcast." , UIAlertControllerStyle . Alert ); UIView alertView = alert . View !; alertView . AddSubview ( picker ); picker . Center = new ( alertView . Bounds . GetMidX (), 110 ); alert . AddAction ( UIAlertAction . Create ( "Done" , UIAlertActionStyle . Cancel , null )); PresentViewController ( alert , true , null );
}
}
internal sealed class AdvancedAudioController : UITableViewController
{
private readonly IosAudioRouter router = IosAudioRouter . Shared ;
internal AdvancedAudioController () : base ( UITableViewStyle . InsetGrouped ) { Title = "Advanced Audio" ; router . Changed += () => TableView . ReloadData (); }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "audio" ); router . RefreshRoutes (); }
public override nint RowsInSection ( UITableView tableView , nint section ) => 8 ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath path )
{
string title = path . Row switch { 0 => "Input port" , 1 => "Microphone orientation" , 2 => "Polar pattern" , 3 => "Microphone mode" , 4 => "Capture channels" , 5 => "Bluetooth mode" , 6 => "Voice processing and AGC" , _ => "Current outputs" };
UITableViewCell cell = tableView . DequeueReusableCell ( "audio" , path ); var content = cell . DefaultContentConfiguration ; content . Text = title ; content . SecondaryText = path . Row switch { 0 => router . Inputs . FirstOrDefault ( value => value . Id == router . SelectedInputId )?. Name ?? "Default" , 1 => router . DataSources (). FirstOrDefault ( value => value . Id == router . SelectedDataSourceId )?. Name ?? "Default" , 2 => router . SelectedPolarPattern . ToString (), 3 => router . MicMode . ToString (), 4 => router . CaptureChannels == 2 ? "Stereo" : "Mono" , 5 => router . BluetoothMode . ToString (), 6 => router . UsesVoiceProcessing ? "AEC/NS on" + ( router . AutomaticGainControl ? ", AGC on" : ", AGC off" ) : "Unavailable or off" , _ => string . Join ( ", " , router . Outputs . Select ( value => value . Name )) }; cell . ContentConfiguration = content ; cell . AccessibilityLabel = title + ", " + content . SecondaryText ; cell . Accessory = path . Row < 7 ? UITableViewCellAccessory . DisclosureIndicator : UITableViewCellAccessory . None ; return cell ;
}
public override void RowSelected ( UITableView tableView , NSIndexPath path ) { tableView . DeselectRow ( path , true ); if ( path . Row == 0 ) Menu ( "Input port" , router . Inputs . Select ( value => value . Name ). Prepend ( "Default" ). ToArray (), index => router . SelectInput ( index == 0 ? null : router . Inputs [ index - 1 ]. Id )); else if ( path . Row == 1 ) { var values = router . DataSources (); Menu ( "Microphone orientation" , values . Select ( value => value . Name ). Prepend ( "Default" ). ToArray (), index => router . SelectDataSource ( index == 0 ? null : values [ index - 1 ]. Id )); } else if ( path . Row == 2 ) Menu ( "Polar pattern" , Enum . GetValues < AVAudioDataSourcePolarPattern >(). Select ( value => value . ToString ()). ToArray (), index => router . SelectPolarPattern ( Enum . GetValues < AVAudioDataSourcePolarPattern >()[ index ])); else if ( path . Row == 3 ) Menu ( "Microphone mode" , Enum . GetValues < IosMicMode >(). Select ( value => value . ToString ()). ToArray (), index => router . SetMicMode ( Enum . GetValues < IosMicMode >()[ index ])); else if ( path . Row == 4 ) Menu ( "Capture channels" , [ "Mono" , "Stereo" ], index => router . SetCaptureChannels ( index + 1 )); else if ( path . Row == 5 ) Menu ( "Bluetooth mode" , Enum . GetValues < IosBluetoothMode >(). Select ( value => value . ToString ()). ToArray (), index => router . SetBluetoothMode ( Enum . GetValues < IosBluetoothMode >()[ index ])); else if ( path . Row == 6 ) Menu ( "Voice processing" , [ "Off" , "On without AGC" , "On with AGC" ], index => { router . SetVoiceProcessing ( index != 0 ); router . SetAutomaticGainControl ( index == 2 ); }); }
private void Menu ( string title , string [] values , Action < int > selected ) { UIAlertController alert = UIAlertController . Create ( title , null , UIAlertControllerStyle . ActionSheet ); for ( int i = 0 ; i < values . Length ; i ++) { int index = i ; alert . AddAction ( UIAlertAction . Create ( values [ i ], UIAlertActionStyle . Default , _ => selected ( index ))); } alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); alert . PopoverPresentationController !. SourceView = View !; PresentViewController ( alert , true , null ); }
}
internal sealed class AccountsController : UITableViewController
{
private readonly AppModel model ; private IReadOnlyList < AccountEntry > accounts = [];
internal AccountsController ( AppModel model ) { this . model = model ; Title = "Accounts" ; }
public override async void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "account" ); NavigationItem . RightBarButtonItem = new ( UIBarButtonSystemItem . Add , ( _ , _ ) => PromptCreate ()); await Refresh (); }
public override nint RowsInSection ( UITableView tableView , nint section ) => accounts . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath path ) { AccountEntry account = accounts [ path . Row ]; UITableViewCell cell = tableView . DequeueReusableCell ( "account" , path ); var content = cell . DefaultContentConfiguration ; content . Text = account . Username ; content . SecondaryText = $"{(account.IsAdmin ? " Administrator " : " Account ")} • created {DateTimeOffset.FromUnixTimeMilliseconds((long)account.CreatedAtUnixMs):d}" ; cell . ContentConfiguration = content ; cell . AccessibilityLabel = content . Text + ", " + content . SecondaryText ; return cell ; }
public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration ( UITableView tableView , NSIndexPath path ) { AccountEntry account = accounts [ path . Row ]; UIContextualAction reset = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Normal , "Reset password" , ( _ , _ , done ) => { PromptReset ( account ); done ( true ); }); UIContextualAction delete = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Destructive , "Delete" , ( _ , _ , done ) => { ConfirmDelete ( account ); done ( true ); }); return UISwipeActionsConfiguration . FromActions ([ delete , reset ]); }
private async Task Refresh () { try { accounts = await model . Client !. ListAccountsAsync (); TableView . ReloadData (); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
private void PromptCreate () => Credentials ( "Create Account" , "Create" , async ( name , password ) => await Execute ( client => client . CreateAccountAsync ( name , password )));
private void PromptReset ( AccountEntry account ) => Credentials ( $"Reset {account.Username}" , "Reset" , async ( _ , password ) => await Execute ( client => client . ResetPasswordAsync ( account . Username , password )), account . Username , false );
private void ConfirmDelete ( AccountEntry account ) { UIAlertController alert = UIAlertController . Create ( "Delete account?" , account . Username , UIAlertControllerStyle . Alert ); alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); alert . AddAction ( UIAlertAction . Create ( "Delete" , UIAlertActionStyle . Destructive , async _ => await Execute ( client => client . DeleteAccountAsync ( account . Username )))); PresentViewController ( alert , true , null ); }
private void Credentials ( string title , string action , Func < string , string , Task > run , string username = "" , bool editName = true ) { UIAlertController prompt = UIAlertController . Create ( title , null , UIAlertControllerStyle . Alert ); prompt . AddTextField ( field => { field . Placeholder = "Username" ; field . Text = username ; field . Enabled = editName ; field . AccessibilityLabel = "Username" ; }); prompt . AddTextField ( field => { field . Placeholder = "Password" ; field . SecureTextEntry = true ; field . AccessibilityLabel = "New password" ; }); prompt . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); prompt . AddAction ( UIAlertAction . Create ( action , UIAlertActionStyle . Default , async _ => await run ( prompt . TextFields ?[ 0 ]. Text ?? "" , prompt . TextFields ?[ 1 ]. Text ?? "" ))); PresentViewController ( prompt , true , null ); }
private async Task Execute ( Func < VoiceCat . Core . VoiceCatClient , Task < GenericResult >> operation ) { try { GenericResult result = await model . RunAdminAsync ( operation ); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); await Refresh (); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
}