.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
129 lines
14 KiB
C#
129 lines
14 KiB
C#
using ReplayKit;
|
|
using UIKit;
|
|
using Voicecat.V1;
|
|
using Channel = Voicecat.V1.Channel;
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
internal sealed class MainTabController : UITabBarController
|
|
{
|
|
internal MainTabController(AppModel model)
|
|
{
|
|
UIViewController channels = new UINavigationController(new ChannelsController(model));
|
|
channels.TabBarItem = new("Channels", UIImage.GetSystemImage("list.bullet.indent"), 0);
|
|
UIViewController chat = new UINavigationController(new ChatController(model));
|
|
chat.TabBarItem = new("Chat", UIImage.GetSystemImage("message"), 1);
|
|
UIViewController settings = new UINavigationController(new SettingsController(model));
|
|
settings.TabBarItem = new("Settings", UIImage.GetSystemImage("gear"), 2);
|
|
ViewControllers = [channels, chat, settings];
|
|
}
|
|
}
|
|
|
|
internal sealed class ChannelsController : UITableViewController
|
|
{
|
|
private readonly AppModel model;
|
|
internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += () => TableView.ReloadData(); }
|
|
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)); }
|
|
public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count;
|
|
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row);
|
|
UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath); int count = model.Users.Count(u => u.ChannelId == channel.Id);
|
|
var content = cell.DefaultContentConfiguration; content.Text = channel.Name; content.SecondaryText = $"{count} users" + (channel.PasswordProtected ? " • protected" : ""); content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : "bubble.left"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{channel.Name}, {count} users"; return cell;
|
|
}
|
|
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row); tableView.DeselectRow(indexPath, true);
|
|
if (channel.PasswordProtected)
|
|
{
|
|
UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(f => { f.SecureTextEntry = true; f.Placeholder = "Password"; });
|
|
prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Join", UIAlertActionStyle.Default, async _ => { try { await model.JoinChannelAsync(channel.Id, prompt.TextFields![0].Text ?? ""); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(prompt, true, null); return;
|
|
}
|
|
try { await model.JoinChannelAsync(channel.Id); } catch (Exception e) { UiHelpers.ShowError(this, e); }
|
|
}
|
|
}
|
|
|
|
internal sealed class UsersController : UITableViewController
|
|
{
|
|
private readonly AppModel model;
|
|
private IReadOnlyList<User> Visible => model.Users.Where(u => u.ChannelId == model.CurrentChannelId).ToArray();
|
|
internal UsersController(AppModel model) { this.model = model; Title = "Users"; model.Changed += () => TableView.ReloadData(); }
|
|
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); }
|
|
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
|
|
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
User user = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("user", indexPath);
|
|
var content = cell.DefaultContentConfiguration; content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : ""); content.SecondaryText = user.ServerMuted ? "server muted" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account"; content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}"; return cell;
|
|
}
|
|
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return;
|
|
UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet);
|
|
menu.AddAction(UIAlertAction.Create("Private message", UIAlertActionStyle.Default, _ => PromptPrivate(user)));
|
|
if (model.Client?.Permissions.CanKick == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Kick", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.KickUserAsync(user.Id))));
|
|
if (model.Client?.Permissions.CanBan == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Ban", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.BanUserAsync(user.Id))));
|
|
if (model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create(user.ServerMuted ? "Server unmute" : "Server mute", UIAlertActionStyle.Default, async _ => await Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened))));
|
|
menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = tableView.CellAt(indexPath); PresentViewController(menu, true, null);
|
|
}
|
|
private void PromptPrivate(User user) { UIAlertController p = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Message"); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(p.TextFields![0].Text ?? "", user.Id))); PresentViewController(p, true, null); }
|
|
private async Task Run(Func<Task<GenericResult>> command) { try { GenericResult result = await command(); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception e) { UiHelpers.ShowError(this, e); } }
|
|
}
|
|
|
|
internal sealed class ChatController : UIViewController
|
|
{
|
|
private readonly AppModel model; private readonly UITextView log = new(); private readonly UITextField compose = UiHelpers.Field("Message");
|
|
internal ChatController(AppModel model) { this.model = model; Title = "Chat"; model.Changed += Refresh; }
|
|
public override void ViewDidLoad()
|
|
{
|
|
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.TranslatesAutoresizingMaskIntoConstraints = false;
|
|
UIButton send = UIButton.FromType(UIButtonType.System); send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = "Send message"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => { model.SendText(compose.Text ?? ""); compose.Text = ""; };
|
|
compose.TranslatesAutoresizingMaskIntoConstraints = false; View.AddSubviews(log, compose, send);
|
|
NSLayoutConstraint.ActivateConstraints([log.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), log.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), log.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(log.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh();
|
|
}
|
|
private void Refresh() { log.Text = string.Join("\n", model.Messages.Select(m => $"[{m.Timestamp:t}] {(m.Private ? "[private] " : "")}{m.Sender}: {m.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1)); }
|
|
}
|
|
|
|
internal sealed class SettingsController : UITableViewController
|
|
{
|
|
private readonly AppModel model; private readonly string[] rows = ["Join Voice", "Audio Preset", "Speaker Output", "Mute Microphone", "Deafen", "Share Screen Audio", "Accounts", "Disconnect"];
|
|
internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) { this.model = model; Title = "Settings"; model.Changed += () => TableView.ReloadData(); }
|
|
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
|
|
public override nint RowsInSection(UITableView tableView, nint section) => rows.Length;
|
|
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
UITableViewCell cell = tableView.DequeueReusableCell("setting", indexPath); string title = rows[indexPath.Row];
|
|
if (indexPath.Row == 0) title = model.VoiceJoined ? "Leave Voice" : "Join Voice";
|
|
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = indexPath.Row == 1 ? IosAudioRouter.Shared.Preset.ToString() : null; cell.ContentConfiguration = content; cell.Accessory = indexPath.Row is 1 or 6 ? UITableViewCellAccessory.DisclosureIndicator : UITableViewCellAccessory.None; return cell;
|
|
}
|
|
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
|
{
|
|
tableView.DeselectRow(indexPath, true); try
|
|
{
|
|
switch (indexPath.Row)
|
|
{
|
|
case 0: await model.ToggleVoiceAsync(); break;
|
|
case 1: ShowPresets(); break;
|
|
case 2: IosAudioRouter.Shared.ForceSpeaker = !IosAudioRouter.Shared.ForceSpeaker; IosAudioEngine.Shared.Reconfigure(); break;
|
|
case 3: if (model.Client is { } c) c.SetSelfAudioState(!c.Audio.MicMuted, c.Audio.Deafened); break;
|
|
case 4: if (model.Client is { } d) d.SetSelfAudioState(d.Audio.MicMuted, !d.Audio.Deafened); break;
|
|
case 5: ShowBroadcastPicker(); break;
|
|
case 6: NavigationController?.PushViewController(new AccountsController(model), true); break;
|
|
case 7: await model.DisconnectAsync(); break;
|
|
}
|
|
}
|
|
catch (Exception e) { UiHelpers.ShowError(this, e); }
|
|
}
|
|
private void ShowPresets() { UIAlertController a = UIAlertController.Create("Audio Preset", null, UIAlertControllerStyle.ActionSheet); foreach (IosAudioPreset p in Enum.GetValues<IosAudioPreset>()) a.AddAction(UIAlertAction.Create(p.ToString(), UIAlertActionStyle.Default, _ => { IosAudioRouter.Shared.SelectPreset(p); TableView.ReloadData(); })); a.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); a.PopoverPresentationController!.SourceView = View; PresentViewController(a, true, null); }
|
|
private void ShowBroadcastPicker() { var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false }; UIAlertController a = UIAlertController.Create("Screen Audio", "Tap the broadcast button, then choose Start Broadcast.", UIAlertControllerStyle.Alert); a.View.AddSubview(picker); picker.Center = new(a.View.Bounds.GetMidX(), 110); a.AddAction(UIAlertAction.Create("Done", UIAlertActionStyle.Cancel, null)); PresentViewController(a, 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()); try { accounts = await model.Client!.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } }
|
|
public override nint RowsInSection(UITableView tableView, nint section) => accounts.Count;
|
|
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell c = tableView.DequeueReusableCell("account", indexPath); var x = c.DefaultContentConfiguration; x.Text = accounts[indexPath.Row].Username; x.SecondaryText = accounts[indexPath.Row].IsAdmin ? "Administrator" : "Account"; c.ContentConfiguration = x; return c; }
|
|
private void PromptCreate() { UIAlertController p = UIAlertController.Create("Create Account", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Username"); p.AddTextField(f => { f.Placeholder = "Password"; f.SecureTextEntry = true; }); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Create", UIAlertActionStyle.Default, async _ => { try { GenericResult result = await model.Client!.CreateAccountAsync(p.TextFields![0].Text ?? "", p.TextFields[1].Text ?? ""); if (!result.Ok) throw new InvalidOperationException(result.Message); accounts = await model.Client.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(p, true, null); }
|
|
}
|