2026-09-19 19:33:10 +02:00
using UIKit ;
using Voicecat.V1 ;
using Channel = Voicecat . V1 . Channel ;
namespace VoiceCat.iOS ;
internal sealed class ChannelEditorController : UIViewController
{
private readonly AppModel model ; private readonly Channel ? existing ;
private readonly UITextField name = UiHelpers . Field ( "Channel name" ), topic = UiHelpers . Field ( "Topic (optional)" ), maximum = UiHelpers . Field ( "Maximum users, 0 is unlimited" ), order = UiHelpers . Field ( "Sort order" ), password = UiHelpers . Field ( "Password; blank preserves existing" , true ), bitrate = UiHelpers . Field ( "Bitrate in bits per second" ), loss = UiHelpers . Field ( "Expected packet loss percent" ), complexity = UiHelpers . Field ( "Complexity 0 through 10" );
private readonly UISegmentedControl type = new ([ "Permanent" , "Temporary" ]), mode = new ([ "Mono" , "Stereo" ]), application = new ([ "VoIP" , "Audio" , "Low delay" ]);
private readonly UIButton parent = UIButton . FromType ( UIButtonType . System ), sampleRate = UIButton . FromType ( UIButtonType . System ), frame = UIButton . FromType ( UIButtonType . System );
2026-09-22 16:54:49 +02:00
private readonly UIButton lossSpeed = UIButton . FromType ( UIButtonType . System );
private readonly UISwitch automaticLoss = new (), fec = new (), dtx = new (), dred = new (); private uint parentId ;
2026-09-19 19:33:10 +02:00
internal ChannelEditorController ( AppModel model , Channel ? existing ) { this . model = model ; this . existing = existing ; Title = existing is null ? "New Channel" : "Edit Channel" ; }
public override void ViewDidLoad ()
{
base . ViewDidLoad (); View !. BackgroundColor = UIColor . SystemGroupedBackground ; UIScrollView scroll = new () { TranslatesAutoresizingMaskIntoConstraints = false }; UIStackView stack = new () { Axis = UILayoutConstraintAxis . Vertical , Spacing = 12 , TranslatesAutoresizingMaskIntoConstraints = false };
View . AddSubview ( scroll ); scroll . AddSubview ( stack ); NSLayoutConstraint . ActivateConstraints ([ scroll . TopAnchor . ConstraintEqualTo ( View . SafeAreaLayoutGuide . TopAnchor ), scroll . BottomAnchor . ConstraintEqualTo ( View . BottomAnchor ), scroll . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor ), scroll . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor ), stack . TopAnchor . ConstraintEqualTo ( scroll . ContentLayoutGuide . TopAnchor , 16 ), stack . BottomAnchor . ConstraintEqualTo ( scroll . ContentLayoutGuide . BottomAnchor , - 24 ), stack . LeadingAnchor . ConstraintEqualTo ( scroll . FrameLayoutGuide . LeadingAnchor , 20 ), stack . TrailingAnchor . ConstraintEqualTo ( scroll . FrameLayoutGuide . TrailingAnchor , - 20 )]);
2026-09-22 16:54:49 +02:00
foreach ( UIView row in new UIView [] { name , topic , PickerRow ( "Parent channel" , parent ), PickerRow ( "Channel type" , type ), maximum , order , password , PickerRow ( "Channel mode" , mode ), PickerRow ( "Sample rate" , sampleRate ), PickerRow ( "Frame duration" , frame ), PickerRow ( "Opus application" , application ), bitrate , SwitchRow ( "Automatic packet loss" , automaticLoss ), PickerRow ( "Adaptation speed" , lossSpeed ), loss , complexity , SwitchRow ( "Forward error correction" , fec ), SwitchRow ( "Discontinuous transmission" , dtx ), SwitchRow ( "Deep redundancy" , dred ) }) stack . AddArrangedSubview ( row );
2026-09-19 19:33:10 +02:00
maximum . KeyboardType = order . KeyboardType = bitrate . KeyboardType = loss . KeyboardType = complexity . KeyboardType = UIKeyboardType . NumberPad ;
parent . Menu = UIMenu . Create ( model . Channels . Where ( channel => channel . Id != existing ?. Id ). OrderBy ( channel => channel . Name ). Select ( channel => UIAction . Create ( channel . Name , null , null , _ => { parentId = channel . Id ; parent . SetTitle ( channel . Name , UIControlState . Normal ); })). Prepend ( UIAction . Create ( "Root" , null , null , _ => { parentId = 0 ; parent . SetTitle ( "Root" , UIControlState . Normal ); })). ToArray ()); parent . ShowsMenuAsPrimaryAction = true ;
sampleRate . Menu = Choice ( sampleRate , [ "48000" ]); sampleRate . ShowsMenuAsPrimaryAction = true ; frame . Menu = Choice ( frame , [ "5" , "10" , "20" , "40" , "60" ]); frame . ShowsMenuAsPrimaryAction = true ;
2026-09-22 16:54:49 +02:00
lossSpeed . Menu = Choice ( lossSpeed , [ "Fast" , "Balanced" , "Stable" ]); lossSpeed . ShowsMenuAsPrimaryAction = true ;
automaticLoss . Enabled = model . SupportsAdaptivePacketLoss ;
automaticLoss . ValueChanged += ( _ , _ ) => UpdateLossControls ();
2026-09-19 19:33:10 +02:00
Load (); NavigationItem . RightBarButtonItem = new ( "Save" , UIBarButtonItemStyle . Done , async ( _ , _ ) => await Save ());
}
private void Load ()
{
Channel channel = existing ?? new Channel { Audio = new AudioConfig { SampleRate = 48000 , BitrateBps = 64000 , FrameMs = 20 , Fec = true , Complexity = 10 , ExpectedPacketLoss = 5 } };
name . Text = channel . Name ; topic . Text = channel . Topic ; maximum . Text = channel . MaxUsers . ToString (); order . Text = channel . Order . ToString (); parentId = channel . ParentId ;
parent . SetTitle ( model . Channels . FirstOrDefault ( value => value . Id == parentId )?. Name ?? "Root" , UIControlState . Normal ); type . SelectedSegment = channel . Type == ChannelType . ChannelTemporary ? 1 : 0 ; mode . SelectedSegment = channel . Audio ?. Mode == ChannelMode . ModeStereo ? 1 : 0 ;
sampleRate . SetTitle (( channel . Audio ?. SampleRate ?? 48000 ). ToString (), UIControlState . Normal ); frame . SetTitle (( channel . Audio ?. FrameMs ?? 20 ). ToString (), UIControlState . Normal ); application . SelectedSegment = ( nint )( channel . Audio ?. Application ?? OpusApplication . OpusVoip );
bitrate . Text = ( channel . Audio ?. BitrateBps ?? 64000 ). ToString (); loss . Text = ( channel . Audio ?. ExpectedPacketLoss ?? 5 ). ToString (); complexity . Text = ( channel . Audio ?. Complexity ?? 10 ). ToString (); fec . On = channel . Audio ?. Fec ?? true ; dtx . On = channel . Audio ?. Dtx ?? false ; dred . On = channel . Audio ?. Dred ?? false ;
2026-09-22 16:54:49 +02:00
automaticLoss . On = channel . Audio ?. PacketLossMode != PacketLossMode . PacketLossManual ;
lossSpeed . SetTitle ( channel . Audio ?. PacketLossMode switch { PacketLossMode . PacketLossAutoFast => "Fast" , PacketLossMode . PacketLossAutoStable => "Stable" , _ => "Balanced" }, UIControlState . Normal );
UpdateLossControls ();
2026-09-19 19:33:10 +02:00
}
private async Task Save ()
{
try
{
if ( string . IsNullOrWhiteSpace ( name . Text ) || ! uint . TryParse ( maximum . Text , out uint max ) || ! int . TryParse ( order . Text , out int sort ) || ! uint . TryParse ( sampleRate . Title ( UIControlState . Normal ), out uint rate ) || ! uint . TryParse ( frame . Title ( UIControlState . Normal ), out uint frameMs ) || ! uint . TryParse ( bitrate . Text , out uint bits ) || ! uint . TryParse ( loss . Text , out uint packetLoss ) || ! uint . TryParse ( complexity . Text , out uint cpu ) || packetLoss > 100 || cpu > 10 ) throw new ArgumentException ( "Enter valid channel and Opus settings." );
var channel = new Channel { Id = existing ?. Id ?? 0 , ParentId = parentId , Name = name . Text . Trim (), Topic = topic . Text ?. Trim () ?? "" , MaxUsers = max , Order = sort , Type = type . SelectedSegment == 1 ? ChannelType . ChannelTemporary : ChannelType . ChannelPermanent ,
2026-09-22 16:54:49 +02:00
Audio = new AudioConfig { Codec = 0 , Mode = mode . SelectedSegment == 1 ? ChannelMode . ModeStereo : ChannelMode . ModeMono , SampleRate = rate , FrameMs = frameMs , Application = ( OpusApplication )( int ) application . SelectedSegment , BitrateBps = bits , ExpectedPacketLoss = packetLoss , Complexity = cpu , Fec = fec . On , Dtx = dtx . On , Dred = dred . On ,
PacketLossMode = automaticLoss . On ? lossSpeed . Title ( UIControlState . Normal ) switch { "Fast" => PacketLossMode . PacketLossAutoFast , "Stable" => PacketLossMode . PacketLossAutoStable , _ => PacketLossMode . PacketLossAutoBalanced } : PacketLossMode . PacketLossManual } };
2026-09-19 19:33:10 +02:00
GenericResult result = await model . RunAdminAsync ( client => existing is null ? client . CreateChannelAsync ( channel , password . Text ?? "" ) : client . EditChannelAsync ( channel , password . Text ?? "" )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); NavigationController ?. PopViewController ( true );
}
catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); }
}
2026-09-22 16:54:49 +02:00
private void UpdateLossControls () { loss . Enabled = ! automaticLoss . On ; lossSpeed . Enabled = model . SupportsAdaptivePacketLoss && automaticLoss . On ; }
2026-09-19 19:33:10 +02:00
private static UIView PickerRow ( string label , UIView control ) { var row = new UIStackView { Axis = UILayoutConstraintAxis . Horizontal , Distribution = UIStackViewDistribution . FillEqually , Spacing = 8 }; var text = new UILabel { Text = label }; control . AccessibilityLabel = label ; row . AddArrangedSubview ( text ); row . AddArrangedSubview ( control ); return row ; }
private static UIView SwitchRow ( string label , UISwitch toggle ) { var row = new UIStackView { Axis = UILayoutConstraintAxis . Horizontal , Distribution = UIStackViewDistribution . EqualSpacing }; row . AddArrangedSubview ( new UILabel { Text = label }); toggle . AccessibilityLabel = label ; row . AddArrangedSubview ( toggle ); return row ; }
private static UIMenu Choice ( UIButton button , string [] values ) => UIMenu . Create ( values . Select ( value => UIAction . Create ( value , null , null , _ => button . SetTitle ( value , UIControlState . Normal ))). ToArray ());
}