Retire legacy implementations and flatten managed layout
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using UIKit;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class UsersController : UITableViewController
|
||||
{
|
||||
private readonly AppModel model; private IReadOnlyList<User> Visible => model.CurrentChannelId == 0 ? model.Users : model.Users.Where(user => user.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.ServerDeafened ? "server deafened" : user.ServerMuted ? "server muted" : user.SelfDeafened ? "deafened" : 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}";
|
||||
if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions = Actions(user).Select(value => new UIAccessibilityCustomAction(value.Title, (Func<UIAccessibilityCustomAction, bool>)(_ => { value.Run(); return true; }))).ToArray(); 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);
|
||||
foreach ((string title, Action run, bool destructive) in Actions(user)) menu.AddAction(UIAlertAction.Create(title, destructive ? UIAlertActionStyle.Destructive : UIAlertActionStyle.Default, _ => run()));
|
||||
menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = (UIView?)tableView.CellAt(indexPath) ?? tableView; PresentViewController(menu, true, null);
|
||||
}
|
||||
private IReadOnlyList<(string Title, Action Run, bool Destructive)> Actions(User user)
|
||||
{
|
||||
Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)>
|
||||
{ ("Private message", () => PromptPrivate(user), false), ("Volume and noise reduction", () => NavigationController?.PushViewController(new PerUserTuningController(model, user), true), false) };
|
||||
if (permissions.CanKick || permissions.IsAdmin) result.Add(("Kick", () => PromptReason(user, false), true));
|
||||
if (permissions.CanBan || permissions.IsAdmin) result.Add(("Ban", () => NavigationController?.PushViewController(new BanUserController(model, user), true), true));
|
||||
if (permissions.CanMoveUsers || permissions.IsAdmin) result.Add(("Move to channel", () => NavigationController?.PushViewController(new MoveUserController(model, user), true), false));
|
||||
if (permissions.IsAdmin)
|
||||
{
|
||||
result.Add((user.ServerMuted ? "Server unmute" : "Server mute", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)), false));
|
||||
result.Add((user.ServerDeafened ? "Server undeafen" : "Server deafen", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)), false));
|
||||
result.Add(("Permissions", () => NavigationController?.PushViewController(new PermissionsController(model, user), true), false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private void PromptPrivate(User user) { UIAlertController prompt = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Private message"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(prompt.TextFields?[0].Text ?? "", user.Id))); PresentViewController(prompt, true, null); }
|
||||
private void PromptReason(User user, bool ban) { UIAlertController prompt = UIAlertController.Create(ban ? "Ban user" : "Kick user", "Reason (optional)", UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Reason"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(ban ? "Ban" : "Kick", UIAlertActionStyle.Destructive, _ => Run(() => model.Client!.KickUserAsync(user.Id, prompt.TextFields?[0].Text ?? "")))); PresentViewController(prompt, true, null); }
|
||||
private async void Run(Func<Task<GenericResult>> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
|
||||
}
|
||||
|
||||
internal abstract class FormController : UITableViewController
|
||||
{
|
||||
protected FormController(string title) : base(UITableViewStyle.InsetGrouped) { Title = title; }
|
||||
protected static UITableViewCell TextCell(UITableView table, NSIndexPath path, string id, string text, string? detail = null)
|
||||
{ UITableViewCell cell = table.DequeueReusableCell(id, path); var content = cell.DefaultContentConfiguration; content.Text = text; content.SecondaryText = detail; cell.ContentConfiguration = content; return cell; }
|
||||
protected static UISwitch Switch(bool value, string label, EventHandler handler) { var toggle = new UISwitch { On = value, AccessibilityLabel = label }; toggle.ValueChanged += handler; return toggle; }
|
||||
}
|
||||
|
||||
internal sealed class PermissionsController : FormController
|
||||
{
|
||||
private readonly AppModel model; private readonly User user; private readonly string[] names = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Administrator"];
|
||||
private readonly bool[] values = new bool[6];
|
||||
internal PermissionsController(AppModel model, User user) : base($"Permissions — {user.Nickname}") { this.model = model; this.user = user; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "permission"); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save()); }
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => names.Length;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell cell = TextCell(tableView, indexPath, "permission", names[indexPath.Row]); int row = indexPath.Row; cell.AccessoryView = Switch(values[row], names[row], (_, _) => values[row] = ((UISwitch)cell.AccessoryView!).On); return cell; }
|
||||
private async Task Save() { try { var permissions = new Permissions { CanCreateTempChannel = values[0], CanKick = values[1], CanBan = values[2], CanMoveUsers = values[3], CanAdminAccounts = values[4], IsAdmin = values[5] }; GenericResult result = await model.RunAdminAsync(client => client.SetPermissionsAsync(user.Id, permissions)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
|
||||
}
|
||||
|
||||
internal sealed class MoveUserController : FormController
|
||||
{
|
||||
private readonly AppModel model; private readonly User user; private uint selected;
|
||||
internal MoveUserController(AppModel model, User user) : base($"Move {user.Nickname}") { this.model = model; this.user = user; selected = user.ChannelId; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Move", UIBarButtonItemStyle.Done, async (_, _) => await Move()); }
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { Channel channel = model.Channels[indexPath.Row]; UITableViewCell cell = TextCell(tableView, indexPath, "channel", channel.Name); cell.Accessory = channel.Id == selected ? UITableViewCellAccessory.Checkmark : UITableViewCellAccessory.None; cell.AccessibilityLabel = channel.Name + (channel.Id == selected ? ", selected" : ""); return cell; }
|
||||
public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { selected = model.Channels[indexPath.Row].Id; tableView.ReloadData(); }
|
||||
private async Task Move() { try { GenericResult result = await model.RunAdminAsync(client => client.MoveUserAsync(user.Id, selected)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
|
||||
}
|
||||
|
||||
internal sealed class BanUserController : UIViewController
|
||||
{
|
||||
private readonly AppModel model; private readonly User user; private readonly UITextField reason = UiHelpers.Field("Reason (optional)"); private readonly UISegmentedControl duration = new(["Permanent", "1 hour", "1 day", "1 week"]);
|
||||
internal BanUserController(AppModel model, User user) { this.model = model; this.user = user; Title = $"Ban {user.Nickname}"; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; duration.SelectedSegment = 0; duration.AccessibilityLabel = "Ban duration"; UIStackView stack = new([reason, duration]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 16, TranslatesAutoresizingMaskIntoConstraints = false }; View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]); NavigationItem.RightBarButtonItem = new("Ban", UIBarButtonItemStyle.Done, async (_, _) => await Ban()); }
|
||||
private async Task Ban() { try { ulong expires = duration.SelectedSegment switch { 1 => (ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds(), 2 => (ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds(), 3 => (ulong)DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeMilliseconds(), _ => 0 }; GenericResult result = await model.RunAdminAsync(client => client.BanUserAsync(user.Id, reason.Text ?? "", expires)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
|
||||
}
|
||||
|
||||
internal sealed class PerUserTuningController : FormController
|
||||
{
|
||||
private readonly AppModel model; private readonly User user; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 };
|
||||
private bool muted, noise;
|
||||
internal PerUserTuningController(AppModel model, User user) : base(user.Nickname) { this.model = model; this.user = user; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "tuning"); gain.AccessibilityLabel = $"Volume gain for {user.Nickname}"; gain.ValueChanged += (_, _) => Apply(); if (user.Streams.FirstOrDefault() is { } stream && model.Client?.Audio.GetRemotePlayback(user.Id, stream.StreamId) is { } state) { gain.Value = state.Gain; muted = state.Muted; noise = state.NoiseReduction; } }
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => 3;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { string title = indexPath.Row switch { 0 => "Gain", 1 => "Mute", _ => "Noise reduction" }; UITableViewCell cell = TextCell(tableView, indexPath, "tuning", title); if (indexPath.Row == 0) cell.AccessoryView = gain; else { int row = indexPath.Row; cell.AccessoryView = Switch(row == 1 ? muted : noise, title, (_, _) => { bool value = ((UISwitch)cell.AccessoryView!).On; if (row == 1) muted = value; else noise = value; Apply(); }); } return cell; }
|
||||
private void Apply() { if (model.Client is not { } client) return; foreach (StreamInfo stream in user.Streams) client.Audio.SetRemotePlayback(user.Id, stream.StreamId, gain.Value, muted, noise); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
internal sealed class AppDelegate : UIApplicationDelegate
|
||||
{
|
||||
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
|
||||
{
|
||||
AppModel.Shared.Load();
|
||||
return true;
|
||||
}
|
||||
|
||||
public override UISceneConfiguration GetConfiguration(UIApplication application, UISceneSession connectingSceneSession,
|
||||
UISceneConnectionOptions options) => new("Default Configuration", connectingSceneSession.Role)
|
||||
{ DelegateType = typeof(SceneDelegate) };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using UIKit;
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Core;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed record ChatEntry(DateTime Timestamp, string Sender, string Text, bool Private);
|
||||
internal sealed record ActivityEntry(DateTime Timestamp, string Text);
|
||||
|
||||
internal sealed class AppModel
|
||||
{
|
||||
internal static AppModel Shared { get; } = new();
|
||||
private readonly IosStorage storage = new();
|
||||
private readonly List<ServerProfile> profiles = [];
|
||||
private readonly List<ChatEntry> messages = [];
|
||||
private readonly List<ActivityEntry> activity = [];
|
||||
private readonly IosSettings settings = new();
|
||||
private readonly EventFeedback feedback;
|
||||
private CancellationTokenSource? lifetime;
|
||||
private VoiceCatClient? client;
|
||||
private TaskCompletionSource<bool>? identityDecision;
|
||||
private ServerProfile? connectedProfile;
|
||||
private int reconnectAttempt;
|
||||
private bool explicitDisconnect;
|
||||
private uint microphoneStream;
|
||||
private BroadcastAudioPump? broadcast;
|
||||
private Timer? levelTimer;
|
||||
private bool lastTalking;
|
||||
private bool restoringVoice;
|
||||
private uint restoreChannel;
|
||||
private bool restoreMuted;
|
||||
private bool restoreDeafened;
|
||||
private bool backgrounded;
|
||||
|
||||
internal event Action? Changed;
|
||||
internal event Action<ServerIdentityChallenge>? IdentityRequested;
|
||||
internal IReadOnlyList<ServerProfile> Profiles => profiles;
|
||||
internal IReadOnlyList<ChatEntry> Messages => messages;
|
||||
internal IReadOnlyList<ActivityEntry> Activity => activity;
|
||||
internal IosSettings Settings => settings;
|
||||
internal VoiceCatClient? Client => client;
|
||||
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
|
||||
internal bool IsConnecting { get; private set; }
|
||||
internal bool VoiceJoined => microphoneStream != 0;
|
||||
internal bool ScreenSharing => broadcast?.IsActive == true;
|
||||
internal bool IsBackgrounded => backgrounded;
|
||||
internal float MicrophoneLevel { get; private set; }
|
||||
internal string Status { get; private set; } = "Not connected";
|
||||
internal uint CurrentChannelId { get; private set; }
|
||||
internal uint SelfUserId => client?.Authentication?.Self.Id ?? 0;
|
||||
internal IReadOnlyList<Channel> Channels => client?.Channels ?? [];
|
||||
internal IReadOnlyList<User> Users => client?.Users ?? [];
|
||||
|
||||
private AppModel() { feedback = new(settings); }
|
||||
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); Notify(); }
|
||||
internal void Save() { storage.SaveProfiles(profiles); settings.Save(); }
|
||||
|
||||
internal void DidEnterBackground()
|
||||
{
|
||||
backgrounded = true; Save();
|
||||
// Do not stop or rebuild AVAudioEngine here. With the `audio` background mode and an
|
||||
// active PlayAndRecord session, capture, playback, media UDP, and screen-ring draining
|
||||
// remain live while the scene is backgrounded or the device is locked.
|
||||
}
|
||||
|
||||
internal void WillEnterForeground()
|
||||
{
|
||||
backgrounded = false;
|
||||
IosAudioRouter.Shared.Recover("foreground");
|
||||
}
|
||||
|
||||
internal void DidBecomeActive()
|
||||
{
|
||||
backgrounded = false;
|
||||
IosAudioRouter.Shared.EnsureAudio("active scene");
|
||||
Notify();
|
||||
}
|
||||
|
||||
internal void UpsertProfile(ServerProfile profile, string? password)
|
||||
{
|
||||
int index = profiles.FindIndex(item => item.Id == profile.Id);
|
||||
if (index < 0) profiles.Add(profile); else profiles[index] = profile;
|
||||
if (!string.IsNullOrEmpty(password)) storage.SavePassword(profile.Id, password);
|
||||
Save(); Notify();
|
||||
}
|
||||
|
||||
internal void RemoveProfile(ServerProfile profile)
|
||||
{
|
||||
storage.RemovePassword(profile.Id); profiles.RemoveAll(item => item.Id == profile.Id); Save(); Notify();
|
||||
}
|
||||
|
||||
internal async Task ConnectAsync(ServerProfile profile, string? suppliedPassword = null, bool restoring = false)
|
||||
{
|
||||
if (IsConnecting || IsConnected) return;
|
||||
explicitDisconnect = false; IsConnecting = true; connectedProfile = profile;
|
||||
Status = restoring ? "Reconnecting…" : "Connecting…"; Notify();
|
||||
lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new();
|
||||
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath);
|
||||
next.ConnectionStateChanged += state =>
|
||||
{
|
||||
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
|
||||
Console.Error.WriteLine($"VoiceCat control connection failed: {failure}");
|
||||
};
|
||||
client = next;
|
||||
try
|
||||
{
|
||||
await next.ConnectAsync(profile.Host, profile.Port, ConfirmIdentityAsync, lifetime.Token);
|
||||
AuthResult auth = profile.Authentication == ServerAuthentication.Guest
|
||||
? await next.AuthenticateGuestAsync(profile.Nickname ?? "iOS User", lifetime.Token)
|
||||
: await next.AuthenticateUserAsync(profile.Username!, suppliedPassword ?? storage.LoadPassword(profile) ?? "", lifetime.Token);
|
||||
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
|
||||
CurrentChannelId = auth.Self.ChannelId; reconnectAttempt = 0; IsConnecting = false; Status = "Connected";
|
||||
ApplyAudioSettings(next);
|
||||
IosAudioEngine.Shared.StartListening(next);
|
||||
broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next);
|
||||
_ = PumpEventsAsync(next, lifetime.Token);
|
||||
levelTimer?.Dispose(); levelTimer = new(_ => PollAudio(), null, 50, 50);
|
||||
feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected");
|
||||
if (restoring && restoreChannel != 0) await RestoreSessionAsync(next);
|
||||
Notify();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
|
||||
IsConnecting = false; Status = exception.Message;
|
||||
await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null;
|
||||
Notify();
|
||||
if (restoring && !explicitDisconnect) ScheduleReconnect();
|
||||
else throw;
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask<bool> ConfirmIdentityAsync(ServerIdentityChallenge challenge, CancellationToken token)
|
||||
{
|
||||
identityDecision = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
token.Register(() => identityDecision.TrySetCanceled(token));
|
||||
UIApplication.SharedApplication.BeginInvokeOnMainThread(() => IdentityRequested?.Invoke(challenge));
|
||||
return new(identityDecision.Task);
|
||||
}
|
||||
|
||||
internal void ResolveIdentity(bool accepted) { identityDecision?.TrySetResult(accepted); identityDecision = null; }
|
||||
|
||||
private async Task PumpEventsAsync(VoiceCatClient owner, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (Envelope envelope in owner.ReadEventsAsync(token))
|
||||
{
|
||||
if (envelope.JoinChannelResult?.Ok == true) CurrentChannelId = envelope.JoinChannelResult.ChannelId;
|
||||
if (envelope.JoinChannelResult is { Ok: false } joinFailure) AddActivity("Join failed: " + joinFailure.Error);
|
||||
if (envelope.TextMessage is { } text)
|
||||
{
|
||||
User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId);
|
||||
messages.Add(new(DateTime.Now, sender?.Nickname ?? $"User {text.SenderId}", text.Body, text.Scope == TextScope.TextPrivate));
|
||||
if (messages.Count > 500) messages.RemoveAt(0);
|
||||
bool self = text.SenderId == SelfUserId;
|
||||
feedback.Play(text.Scope == TextScope.TextPrivate
|
||||
? self ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived
|
||||
: self ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived);
|
||||
if (!self) feedback.Speak(text.Scope == TextScope.TextPrivate ? $"Private message from {sender?.Nickname}: {text.Body}" : $"{sender?.Nickname}: {text.Body}");
|
||||
}
|
||||
if (envelope.UserEvent is { } userEvent) HandleUserEvent(owner, userEvent);
|
||||
if (envelope.StreamState is { } streamState) AddActivity($"{Name(owner, streamState.UserId)} {(streamState.Talking ? "started" : "stopped")} talking");
|
||||
if (envelope.StreamAnnounceResult?.Ok == false) AddActivity("Stream failed: " + envelope.StreamAnnounceResult.Error);
|
||||
if (envelope.GenericResult is { Ok: false } result) AddActivity("Operation failed: " + result.Message);
|
||||
if (envelope.Disconnect is { } disconnected) AddActivity("Disconnected: " + disconnected.Reason);
|
||||
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(client, owner) && !explicitDisconnect)
|
||||
{
|
||||
CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost";
|
||||
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
ScheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task JoinChannelAsync(uint channelId, string password = "")
|
||||
{
|
||||
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
|
||||
Envelope response = await active.RequestAsync(new() { JoinChannel = new() { ChannelId = channelId, Password = password } });
|
||||
if (response.JoinChannelResult?.Ok != true) throw new InvalidOperationException(response.JoinChannelResult?.Error ?? "Join failed.");
|
||||
CurrentChannelId = channelId; Notify();
|
||||
}
|
||||
|
||||
internal void SendText(string body, uint targetUser = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return;
|
||||
(client ?? throw new InvalidOperationException("Not connected.")).Send(new()
|
||||
{
|
||||
TextMessage = new() { Scope = targetUser == 0 ? TextScope.TextChannel : TextScope.TextPrivate,
|
||||
TargetId = targetUser == 0 ? CurrentChannelId : targetUser, Body = body.Trim(), ClientMsgId = Guid.NewGuid().ToString("N") }
|
||||
});
|
||||
}
|
||||
|
||||
internal async Task ToggleVoiceAsync()
|
||||
{
|
||||
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
|
||||
if (microphoneStream != 0)
|
||||
{
|
||||
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0;
|
||||
await active.SubscribeVoiceAsync(false); MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); Notify(); return;
|
||||
}
|
||||
if (AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio) == AVFoundation.AVAuthorizationStatus.NotDetermined)
|
||||
await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio);
|
||||
VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync();
|
||||
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
|
||||
int channels = IosAudioRouter.Shared.CaptureChannels;
|
||||
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
|
||||
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
|
||||
}
|
||||
|
||||
internal void ToggleScreenAudio()
|
||||
{
|
||||
if (!OperatingSystem.IsIOSVersionAtLeast(27)) throw new PlatformNotSupportedException("Use the ReplayKit broadcast picker on this iOS version.");
|
||||
if (ScreenSharing) { IosScreenCapture.Stop(); broadcast?.RequestStop(); }
|
||||
else
|
||||
{
|
||||
if (!IsConnected || CurrentChannelId == 0) throw new InvalidOperationException("Connect and join a channel before sharing screen audio.");
|
||||
IosScreenCapture.Present();
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetSelfAudio(bool muted, bool deafened) { client?.SetSelfAudioState(muted, deafened); Notify(); }
|
||||
internal void SetPushToTalk(bool active)
|
||||
{
|
||||
if (client is null) return; client.Audio.PushToTalk = active;
|
||||
if (active) feedback.Play(SoundEvent.PushToTalk); Notify();
|
||||
}
|
||||
internal void ApplyVoiceSettings()
|
||||
{
|
||||
if (client is { } active) ApplyAudioSettings(active); settings.Save(); Notify();
|
||||
}
|
||||
|
||||
internal async Task<GenericResult> RunAdminAsync(Func<VoiceCatClient, Task<GenericResult>> action)
|
||||
{
|
||||
GenericResult result = await action(client ?? throw new InvalidOperationException("Not connected."));
|
||||
AddActivity(result.Ok ? (string.IsNullOrEmpty(result.Message) ? "Operation completed." : result.Message) : "Operation failed: " + result.Message);
|
||||
Notify(); return result;
|
||||
}
|
||||
|
||||
internal async Task DisconnectAsync()
|
||||
{
|
||||
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync();
|
||||
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
|
||||
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
|
||||
}
|
||||
|
||||
private void ScheduleReconnect()
|
||||
{
|
||||
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
|
||||
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30);
|
||||
_ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(delay), lifetime?.Token ?? default); await ConnectAsync(profile, restoring: true); } catch { } });
|
||||
}
|
||||
|
||||
private void Notify() => Changed?.Invoke();
|
||||
|
||||
private void ApplyAudioSettings(VoiceCatClient owner)
|
||||
{
|
||||
owner.Audio.InputMode = settings.InputMode; owner.Audio.VadThreshold = settings.VadThreshold;
|
||||
owner.Audio.InputGain = settings.InputGain; owner.Audio.OutputGain = settings.OutputGain;
|
||||
owner.Audio.InputNoiseReduction = settings.InputNoiseReduction;
|
||||
owner.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
IosAudioEngine.Shared.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
}
|
||||
|
||||
private void PollAudio()
|
||||
{
|
||||
VoiceCatClient? owner = client; uint stream = microphoneStream; if (owner is null || stream == 0) return;
|
||||
(float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level;
|
||||
if (talking != lastTalking) { lastTalking = talking; owner.PublishStreamState(stream, talking); feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop); }
|
||||
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
}
|
||||
|
||||
private void HandleUserEvent(VoiceCatClient owner, UserEvent value)
|
||||
{
|
||||
string name = value.User?.Nickname ?? Name(owner, value.LeftId);
|
||||
if (value.Kind == UserEvent.Types.Kind.Joined && value.User?.ChannelId == CurrentChannelId && value.User.Id != SelfUserId)
|
||||
{ AddActivity($"{name} joined"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); }
|
||||
else if (value.Kind == UserEvent.Types.Kind.Left)
|
||||
{ AddActivity($"{name} left"); feedback.Play(SoundEvent.ChannelLeave); feedback.Speak($"{name} left"); }
|
||||
User? self = owner.Users.FirstOrDefault(user => user.Id == SelfUserId); if (self is not null) CurrentChannelId = self.ChannelId;
|
||||
}
|
||||
|
||||
private void AddActivity(string text) { activity.Add(new(DateTime.Now, text)); if (activity.Count > 500) activity.RemoveAt(0); }
|
||||
private static string Name(VoiceCatClient owner, uint id) => owner.Users.FirstOrDefault(user => user.Id == id)?.Nickname ?? $"User {id}";
|
||||
private void BroadcastChanged() => UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
|
||||
private void CaptureRestoreState(VoiceCatClient owner)
|
||||
{
|
||||
restoreChannel = CurrentChannelId; restoringVoice = microphoneStream != 0;
|
||||
restoreMuted = owner.Audio.MicMuted; restoreDeafened = owner.Audio.Deafened;
|
||||
}
|
||||
|
||||
private async Task RestoreSessionAsync(VoiceCatClient owner)
|
||||
{
|
||||
Envelope joined = await owner.RequestAsync(new() { JoinChannel = new() { ChannelId = restoreChannel } });
|
||||
if (joined.JoinChannelResult?.Ok != true) { AddActivity("Could not restore the previous channel."); return; }
|
||||
CurrentChannelId = restoreChannel;
|
||||
if (restoringVoice) await ToggleVoiceAsync();
|
||||
owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}");
|
||||
}
|
||||
|
||||
private async Task StopSessionResourcesAsync()
|
||||
{
|
||||
levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0;
|
||||
if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using VoiceCat.Core;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class BroadcastAudioPump : IAsyncDisposable
|
||||
{
|
||||
private const uint Magic = 0x56434252, Version = 1;
|
||||
private const int Header = 64, Capacity = 96_000, Frame = 960;
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private Task worker = Task.CompletedTask;
|
||||
private VoiceCatClient? client;
|
||||
private uint streamId;
|
||||
private bool active;
|
||||
private int generation;
|
||||
private readonly short[] scratch = new short[Frame * 2];
|
||||
|
||||
internal event Action? Changed;
|
||||
internal bool IsActive => active;
|
||||
|
||||
internal void Start(VoiceCatClient owner) { client = owner; worker = RunAsync(stop.Token); }
|
||||
|
||||
private async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try { await DrainAsync(token); }
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { }
|
||||
await Task.Delay(10, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync(CancellationToken token)
|
||||
{
|
||||
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
||||
if (root?.Path is null) return;
|
||||
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); if (!File.Exists(path)) return;
|
||||
using MemoryMappedFile map = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
||||
using MemoryMappedViewAccessor view = map.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
||||
if (view.ReadUInt32(0) != Magic || view.ReadUInt32(4) != Version) throw new InvalidDataException("Unsupported broadcast ring.");
|
||||
bool active = view.ReadUInt32(16) != 0;
|
||||
if (!active) { StopStream(); SetActive(false); return; }
|
||||
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
|
||||
if (streamId == 0)
|
||||
{
|
||||
int startGeneration = Volatile.Read(ref generation);
|
||||
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
|
||||
if (startGeneration != Volatile.Read(ref generation) || view.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
|
||||
{
|
||||
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
||||
return;
|
||||
}
|
||||
streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); SetActive(true);
|
||||
}
|
||||
ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32);
|
||||
if (write - read > Capacity) read = write - Capacity;
|
||||
while (write - read >= Frame * 2)
|
||||
{
|
||||
for (int sample = 0; sample < scratch.Length; sample++)
|
||||
{
|
||||
ulong index = (read + (ulong)sample) % Capacity;
|
||||
scratch[sample] = view.ReadInt16(Header + checked((long)index * sizeof(short)));
|
||||
}
|
||||
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
|
||||
read += (ulong)scratch.Length;
|
||||
view.Write(32, read);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopStream()
|
||||
{
|
||||
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
|
||||
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
||||
}
|
||||
|
||||
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
|
||||
|
||||
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); Interlocked.Increment(ref generation); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); SetActive(false); stop.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
private readonly UISwitch fec = new(), dtx = new(), dred = new(); private uint parentId;
|
||||
|
||||
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)]);
|
||||
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, loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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,
|
||||
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 } };
|
||||
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); }
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.security.application-groups</key><array><string>group.me.iamtalon.voicecat</string></array>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,49 @@
|
||||
using AVFoundation;
|
||||
using Foundation;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
|
||||
|
||||
internal sealed class EventFeedback : IDisposable
|
||||
{
|
||||
private readonly IosSettings settings;
|
||||
private readonly AVSpeechSynthesizer speech = new();
|
||||
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
|
||||
|
||||
internal EventFeedback(IosSettings settings) => this.settings = settings;
|
||||
|
||||
internal void Play(SoundEvent sound)
|
||||
{
|
||||
if (!settings.EventSounds || settings.EventVolume <= 0 ||
|
||||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
|
||||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
|
||||
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
|
||||
{
|
||||
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
|
||||
if (!File.Exists(path)) return;
|
||||
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
|
||||
if (player is null) return;
|
||||
player.PrepareToPlay(); players[sound] = player;
|
||||
}
|
||||
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
|
||||
}
|
||||
|
||||
internal void Speak(string text)
|
||||
{
|
||||
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
|
||||
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
|
||||
}
|
||||
|
||||
private static string FileName(SoundEvent value) => value switch
|
||||
{
|
||||
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
|
||||
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
|
||||
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
|
||||
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
|
||||
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
|
||||
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
|
||||
};
|
||||
|
||||
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleDisplayName</key><string>VoiceCat</string>
|
||||
<key>CFBundleIdentifier</key><string>me.iamtalon.voicecat</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.0.1</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key><true/>
|
||||
<key>NSMicrophoneUsageDescription</key><string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key><array><string>audio</string></array>
|
||||
<key>UIRequiresFullScreen</key><false/>
|
||||
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
|
||||
<key>UILaunchScreen</key><dict/>
|
||||
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationPortraitUpsideDown</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
|
||||
<key>UIApplicationSceneManifest</key><dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key><false/>
|
||||
<key>UISceneConfigurations</key><dict><key>UIWindowSceneSessionRoleApplication</key><array><dict>
|
||||
<key>UISceneConfigurationName</key><string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key><string>VoiceCat.iOS.SceneDelegate</string>
|
||||
</dict></array></dict>
|
||||
</dict>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using AVFoundation;
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Core;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class IosAudioEngine
|
||||
{
|
||||
internal static IosAudioEngine Shared { get; } = new();
|
||||
private readonly AdaptivePcmBuffer playbackRing = new(2, capacityFrames: 65_536);
|
||||
private readonly short[] renderScratch = new short[16_384];
|
||||
private AVAudioEngine? engine;
|
||||
private AVAudioSourceNode? source;
|
||||
private AVAudioFormat? outputFormat;
|
||||
private VoiceCatClient? client;
|
||||
private uint microphoneStream;
|
||||
private int microphoneChannels = 1;
|
||||
private readonly PcmRing microphoneRing = new(131_072);
|
||||
private readonly short[] microphoneFrame = new short[960 * 2];
|
||||
private readonly CancellationTokenSource microphoneStop = new();
|
||||
private readonly Task microphoneWorker;
|
||||
private AVAudioFormat? microphoneFormat;
|
||||
private AVAudioConverter? microphoneConverter;
|
||||
private AVAudioPcmBuffer? convertedMicrophone;
|
||||
private AVAudioPcmBuffer? pendingInput;
|
||||
private AVAudioConverterInputHandler? inputProvider;
|
||||
private bool inputProvided;
|
||||
private bool tapInstalled;
|
||||
internal bool IsConnected { get; private set; }
|
||||
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
|
||||
|
||||
private IosAudioEngine() { microphoneWorker = PumpMicrophoneAsync(microphoneStop.Token); }
|
||||
|
||||
internal void StartListening(VoiceCatClient owner)
|
||||
{
|
||||
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
|
||||
}
|
||||
|
||||
internal void StartMicrophone(uint streamId, int channels)
|
||||
{
|
||||
microphoneStream = streamId; microphoneChannels = Math.Clamp(channels, 1, 2); Rebuild();
|
||||
}
|
||||
|
||||
internal void StopMicrophone() { microphoneStream = 0; Rebuild(); }
|
||||
internal void Reconfigure() { if (IsConnected) Rebuild(); }
|
||||
internal bool EnsureRunning()
|
||||
{
|
||||
if (!IsConnected || engine?.Running == true) return true;
|
||||
Rebuild(); return engine?.Running == true;
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
DestroyGraph(); IosAudioRouter.Shared.Apply();
|
||||
var next = new AVAudioEngine();
|
||||
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
|
||||
source = new(outputFormat, Render);
|
||||
next.AttachNode(source);
|
||||
NSError? connectionError = null;
|
||||
if (OperatingSystem.IsIOSVersionAtLeast(27)) next.Connect(source, next.MainMixerNode, outputFormat, out connectionError);
|
||||
else next.Connect(source, next.MainMixerNode, outputFormat);
|
||||
if (connectionError is not null) throw new InvalidOperationException(connectionError.LocalizedDescription);
|
||||
if (microphoneStream != 0)
|
||||
{
|
||||
AVAudioInputNode input = next.InputNode;
|
||||
input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out _);
|
||||
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
|
||||
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
|
||||
microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)microphoneChannels, true);
|
||||
microphoneConverter = new(inputFormat, microphoneFormat);
|
||||
uint capacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
|
||||
convertedMicrophone = new(microphoneFormat, capacity);
|
||||
inputProvider = ProvideInput;
|
||||
NSError? tapError = null;
|
||||
if (OperatingSystem.IsIOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Capture);
|
||||
else input.InstallTapOnBus(0, 960, inputFormat, Capture);
|
||||
if (tapError is not null) throw new InvalidOperationException(tapError.LocalizedDescription);
|
||||
tapInstalled = true;
|
||||
}
|
||||
next.Prepare();
|
||||
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); }
|
||||
engine = next;
|
||||
}
|
||||
|
||||
private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time)
|
||||
{
|
||||
VoiceCatClient? owner = client; uint stream = microphoneStream;
|
||||
if (owner is null || stream == 0 || buffer.FrameLength == 0) return;
|
||||
AVAudioConverter? converter = microphoneConverter;
|
||||
AVAudioPcmBuffer? converted = convertedMicrophone;
|
||||
AVAudioConverterInputHandler? provider = inputProvider;
|
||||
if (converter is null || converted is null || provider is null) return;
|
||||
pendingInput = buffer; inputProvided = false; converted.FrameLength = 0;
|
||||
converter.ConvertToBuffer(converted, out _, provider);
|
||||
if (converted.FrameLength == 0) return;
|
||||
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
|
||||
if (samples != 0) microphoneRing.TryWrite(new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * microphoneChannels)));
|
||||
pendingInput = null;
|
||||
}
|
||||
|
||||
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
|
||||
{
|
||||
if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; }
|
||||
status = AVAudioConverterInputStatus.NoDataNow; return null!;
|
||||
}
|
||||
|
||||
private async Task PumpMicrophoneAsync(CancellationToken token)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10));
|
||||
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
|
||||
{
|
||||
int channels = microphoneChannels, required = 960 * channels;
|
||||
while (microphoneRing.Count >= required)
|
||||
{
|
||||
int read = microphoneRing.Read(microphoneFrame.AsSpan(0, required));
|
||||
VoiceCatClient? owner = client; uint stream = microphoneStream;
|
||||
if (read == required && owner is not null && stream != 0) owner.Audio.FeedPcm(stream, microphoneFrame.AsSpan(0, required), channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReceiveMixedPcm(ReadOnlySpan<short> pcm) => playbackRing.TryWrite(pcm);
|
||||
|
||||
private unsafe int Render(IntPtr isSilence, IntPtr timestamp, uint frameCount, IntPtr outputData)
|
||||
{
|
||||
int frames = checked((int)frameCount), requested = checked(frames * 2);
|
||||
if (requested > renderScratch.Length) return -1;
|
||||
Span<short> input = renderScratch.AsSpan(0, requested);
|
||||
int read = playbackRing.Read(input); input[read..].Clear();
|
||||
int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12;
|
||||
if (count != 2) return -1;
|
||||
for (int channel = 0; channel < 2; channel++)
|
||||
{
|
||||
nint data = Marshal.ReadIntPtr(outputData, first + channel * stride + 8);
|
||||
var output = new Span<float>((void*)data, frames);
|
||||
for (int frame = 0; frame < frames; frame++) output[frame] = input[frame * 2 + channel] / 32768f;
|
||||
}
|
||||
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
IsConnected = false; microphoneStream = 0;
|
||||
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
|
||||
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
|
||||
}
|
||||
|
||||
private void DestroyGraph()
|
||||
{
|
||||
if (engine is { } old)
|
||||
{
|
||||
if (tapInstalled) old.InputNode.RemoveTapOnBus(0);
|
||||
old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose();
|
||||
}
|
||||
tapInstalled = false; pendingInput = null; inputProvider = null;
|
||||
convertedMicrophone?.Dispose(); convertedMicrophone = null;
|
||||
microphoneConverter?.Dispose(); microphoneConverter = null;
|
||||
microphoneFormat?.Dispose(); microphoneFormat = null;
|
||||
source?.Dispose(); source = null; outputFormat?.Dispose(); outputFormat = null; engine = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using AVFoundation;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
|
||||
internal enum IosBluetoothMode { HfpVoice, BuiltInMicA2dp, BuiltInMicSpeaker }
|
||||
internal enum IosMicMode { Standard, Raw }
|
||||
internal sealed record IosAudioPort(string Id, string Name, string Type);
|
||||
internal sealed record IosAudioDataSource(string Id, string Name, IReadOnlyList<AVAudioDataSourcePolarPattern> Patterns);
|
||||
|
||||
internal sealed class IosAudioRouter
|
||||
{
|
||||
internal static IosAudioRouter Shared { get; } = new();
|
||||
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
|
||||
private bool applying;
|
||||
internal event Action? Changed;
|
||||
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
|
||||
internal IosBluetoothMode BluetoothMode { get; private set; } = IosBluetoothMode.HfpVoice;
|
||||
internal IosMicMode MicMode { get; private set; } = IosMicMode.Standard;
|
||||
internal bool ForceSpeaker { get; private set; }
|
||||
internal bool VoiceProcessing { get; private set; } = true;
|
||||
internal bool AutomaticGainControl { get; private set; } = true;
|
||||
internal int CaptureChannels { get; private set; } = 1;
|
||||
internal string? SelectedInputId { get; private set; }
|
||||
internal string? SelectedDataSourceId { get; private set; }
|
||||
internal AVAudioDataSourcePolarPattern SelectedPolarPattern { get; private set; } = AVAudioDataSourcePolarPattern.Unknown;
|
||||
internal IReadOnlyList<IosAudioPort> Inputs { get; private set; } = [];
|
||||
internal IReadOnlyList<IosAudioPort> Outputs { get; private set; } = [];
|
||||
internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode.Standard && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp;
|
||||
internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable;
|
||||
|
||||
private IosAudioRouter()
|
||||
{
|
||||
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange);
|
||||
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
|
||||
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset"));
|
||||
}
|
||||
|
||||
internal void Load()
|
||||
{
|
||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.preset"), true, out IosAudioPreset preset)) Preset = preset;
|
||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
|
||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
|
||||
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
|
||||
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
|
||||
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
|
||||
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
|
||||
SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId");
|
||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
|
||||
RefreshRoutes();
|
||||
}
|
||||
|
||||
internal void SelectPreset(IosAudioPreset preset)
|
||||
{
|
||||
Preset = preset;
|
||||
if (preset != IosAudioPreset.Advanced)
|
||||
{
|
||||
CaptureChannels = preset == IosAudioPreset.StereoMicrophone ? 2 : 1; MicMode = IosMicMode.Standard;
|
||||
BluetoothMode = preset == IosAudioPreset.VoiceChat ? IosBluetoothMode.HfpVoice : IosBluetoothMode.BuiltInMicA2dp;
|
||||
if (preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone)
|
||||
SelectedInputId = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic)?.UID;
|
||||
}
|
||||
SaveAndReconfigure();
|
||||
}
|
||||
|
||||
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; SaveAndReconfigure(); }
|
||||
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
|
||||
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
|
||||
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
internal void SetBluetoothMode(IosBluetoothMode value) { BluetoothMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
internal void SetMicMode(IosMicMode value) { MicMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
internal void SelectInput(string? id) { SelectedInputId = string.IsNullOrEmpty(id) ? null : id; SelectedDataSourceId = null; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
internal void SelectDataSource(string? id) { SelectedDataSourceId = string.IsNullOrEmpty(id) ? null : id; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
internal void SelectPolarPattern(AVAudioDataSourcePolarPattern pattern) { SelectedPolarPattern = pattern; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||
|
||||
internal IReadOnlyList<IosAudioDataSource> DataSources()
|
||||
{
|
||||
AVAudioSessionPortDescription? port = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
|
||||
return port?.DataSources?.Select(value => new IosAudioDataSource(value.DataSourceID.ToString(), value.DataSourceName,
|
||||
value.SupportedPolarPatterns?.ToArray() ?? [])).ToArray() ?? [];
|
||||
}
|
||||
|
||||
internal void RefreshRoutes()
|
||||
{
|
||||
AVAudioSession session = AVAudioSession.SharedInstance();
|
||||
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
|
||||
Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray();
|
||||
SelectedInputId ??= session.PreferredInput?.UID; Changed?.Invoke();
|
||||
}
|
||||
|
||||
internal void Apply()
|
||||
{
|
||||
if (applying) return; applying = true;
|
||||
try
|
||||
{
|
||||
AVAudioSession session = AVAudioSession.SharedInstance(); AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.MixWithOthers;
|
||||
if (BluetoothMode == IosBluetoothMode.HfpVoice) options |= AVAudioSessionCategoryOptions.AllowBluetooth | AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
|
||||
if (BluetoothMode == IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
|
||||
if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker;
|
||||
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement
|
||||
: BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat;
|
||||
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription);
|
||||
session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _); ApplyInput(session);
|
||||
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
|
||||
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
|
||||
}
|
||||
finally { applying = false; }
|
||||
}
|
||||
|
||||
private void ApplyInput(AVAudioSession session)
|
||||
{
|
||||
AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
|
||||
if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
|
||||
if (port is null) return; session.SetPreferredInput(port, out _);
|
||||
AVAudioSessionDataSourceDescription? source = port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
|
||||
if (CaptureChannels == 2) source ??= port.DataSources?.FirstOrDefault(value => value.SupportedPolarPatterns?.Any(pattern => pattern.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) == true);
|
||||
if (source is null) return; port.SetPreferredDataSource(source, out _); session.SetInputDataSource(source, out _);
|
||||
if (CaptureChannels != 2 && SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown) source.SetPreferredPolarPattern(SelectedPolarPattern, out _);
|
||||
}
|
||||
|
||||
private void SaveAndReconfigure()
|
||||
{
|
||||
defaults.SetString(Preset.ToString(), "cat.voice.audio.preset"); defaults.SetString(BluetoothMode.ToString(), "cat.voice.audio.bluetoothMode");
|
||||
defaults.SetString(MicMode.ToString(), "cat.voice.audio.micMode"); defaults.SetBool(ForceSpeaker, "cat.voice.audio.forceSpeaker");
|
||||
defaults.SetBool(VoiceProcessing, "cat.voice.audio.voiceProcessing"); defaults.SetBool(AutomaticGainControl, "cat.voice.audio.agc"); defaults.SetInt(CaptureChannels, "cat.voice.audio.captureChannels");
|
||||
Set("cat.voice.audio.inputPortId", SelectedInputId); Set("cat.voice.audio.dataSourceId", SelectedDataSourceId); defaults.SetString(SelectedPolarPattern.ToString(), "cat.voice.audio.polarPattern"); defaults.Synchronize();
|
||||
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke();
|
||||
}
|
||||
|
||||
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
|
||||
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
|
||||
internal void EnsureAudio(string reason)
|
||||
{
|
||||
if (!IosAudioEngine.Shared.IsConnected) return;
|
||||
try { IosAudioEngine.Shared.EnsureRunning(); }
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
||||
}
|
||||
|
||||
internal void Recover(string reason)
|
||||
{
|
||||
RefreshRoutes();
|
||||
if (!IosAudioEngine.Shared.IsConnected) return;
|
||||
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
try { IosAudioEngine.Shared.Reconfigure(); }
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
||||
});
|
||||
}
|
||||
private void HandleRouteChange(NSNotification note)
|
||||
{
|
||||
NSNumber? value = note.UserInfo?[new NSString("AVAudioSessionRouteChangeReasonKey")] as NSNumber;
|
||||
AVAudioSessionRouteChangeReason reason = (AVAudioSessionRouteChangeReason)(value?.UInt32Value ?? 0);
|
||||
RefreshRoutes();
|
||||
if (reason is AVAudioSessionRouteChangeReason.CategoryChange
|
||||
or AVAudioSessionRouteChangeReason.Override
|
||||
or AVAudioSessionRouteChangeReason.RouteConfigurationChange)
|
||||
return;
|
||||
Recover($"route change ({reason})");
|
||||
}
|
||||
private void HandleInterruption(NSNotification note)
|
||||
{
|
||||
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
|
||||
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
|
||||
if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal static class IosConstants
|
||||
{
|
||||
internal const string AppGroup = "group.me.iamtalon.voicecat";
|
||||
internal const string BroadcastExtension = "me.iamtalon.voicecat.broadcast";
|
||||
internal const string PasswordService = "me.iamtalon.voicecat.ios";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Foundation;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal static partial class IosScreenCapture
|
||||
{
|
||||
internal static void Present()
|
||||
{
|
||||
if (!OperatingSystem.IsIOSVersionAtLeast(27)) return;
|
||||
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
||||
if (root?.Path is null) throw new InvalidOperationException("The VoiceCat App Group is unavailable.");
|
||||
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
if (!IsAvailable) throw new InvalidOperationException("Screen audio sharing is unavailable on this device."); PresentNative(path);
|
||||
}
|
||||
|
||||
internal static bool IsAvailable => OperatingSystem.IsIOSVersionAtLeast(27) && Available() != 0;
|
||||
|
||||
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_available")]
|
||||
private static partial int Available();
|
||||
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_present", StringMarshalling = StringMarshalling.Utf8)]
|
||||
private static partial void PresentNative(string ringPath);
|
||||
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_stop")]
|
||||
internal static partial void Stop();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Foundation;
|
||||
using VoiceCat.Audio;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class IosSettings
|
||||
{
|
||||
private readonly NSUserDefaults values = NSUserDefaults.StandardUserDefaults;
|
||||
|
||||
internal AudioInputMode InputMode { get; set; } = AudioInputMode.VoiceActivation;
|
||||
internal float VadThreshold { get; set; } = 0.025f;
|
||||
internal float InputGain { get; set; } = 1f;
|
||||
internal float OutputGain { get; set; } = 1f;
|
||||
internal bool InputNoiseReduction { get; set; }
|
||||
internal bool EventSounds { get; set; } = true;
|
||||
internal bool SpokenEvents { get; set; }
|
||||
internal float EventVolume { get; set; } = 1f;
|
||||
internal bool SelfTalkSounds { get; set; }
|
||||
internal bool PushToTalkSound { get; set; }
|
||||
internal int AudioBufferMilliseconds { get; set; } = 40;
|
||||
|
||||
internal void Load()
|
||||
{
|
||||
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain",
|
||||
(NSString)"voice.outputGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
|
||||
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.025f),
|
||||
NSNumber.FromFloat(1f), NSNumber.FromFloat(1f), NSNumber.FromInt32(40), NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
|
||||
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
|
||||
int inputMode = checked((int)values.IntForKey("voice.inputMode"));
|
||||
InputMode = Enum.IsDefined(typeof(AudioInputMode), inputMode) ? (AudioInputMode)inputMode : AudioInputMode.VoiceActivation;
|
||||
VadThreshold = Math.Clamp(values.FloatForKey("voice.vadThreshold"), 0.001f, 0.1f);
|
||||
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
|
||||
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
|
||||
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
|
||||
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
|
||||
EventSounds = values.BoolForKey("feedback.sounds");
|
||||
SpokenEvents = values.BoolForKey("feedback.speech");
|
||||
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
|
||||
SelfTalkSounds = values.BoolForKey("feedback.selfTalk");
|
||||
PushToTalkSound = values.BoolForKey("feedback.ptt");
|
||||
}
|
||||
|
||||
internal void Save()
|
||||
{
|
||||
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
|
||||
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
|
||||
values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
|
||||
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(EventSounds, "feedback.sounds");
|
||||
values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
|
||||
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Foundation;
|
||||
using Security;
|
||||
using VoiceCat.Core;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class IosStorage
|
||||
{
|
||||
private readonly string directory;
|
||||
private readonly ServerProfileStore profiles;
|
||||
|
||||
internal IosStorage()
|
||||
{
|
||||
NSUrl? group = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
||||
string root = group?.Path ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
directory = Path.Combine(root, "voicecat");
|
||||
profiles = new(Path.Combine(directory, "servers.json"));
|
||||
}
|
||||
|
||||
internal string TofuPath => Path.Combine(directory, "tofu_pins.txt");
|
||||
internal IReadOnlyList<ServerProfile> LoadProfiles()
|
||||
{
|
||||
MigrateLegacyFiles();
|
||||
return profiles.Load();
|
||||
}
|
||||
internal void SaveProfiles(IEnumerable<ServerProfile> values) => profiles.Save(values);
|
||||
|
||||
private void MigrateLegacyFiles()
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
string legacy = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "voicecat");
|
||||
if (Path.GetFullPath(legacy) == Path.GetFullPath(directory) || !Directory.Exists(legacy)) return;
|
||||
foreach (string name in new[] { "servers.json", "tofu_pins.txt" })
|
||||
{
|
||||
string source = Path.Combine(legacy, name), destination = Path.Combine(directory, name);
|
||||
if (File.Exists(source) && !File.Exists(destination)) File.Copy(source, destination);
|
||||
}
|
||||
}
|
||||
|
||||
internal string? LoadPassword(ServerProfile profile)
|
||||
{
|
||||
string? value = ReadPassword(IosConstants.PasswordService, profile.Id.ToString("D"), IosConstants.AppGroup);
|
||||
return value ?? (profile.LegacyKeychainTag is { Length: > 0 } tag
|
||||
? ReadPassword("cat.voice.VoiceCatiOS", tag, IosConstants.AppGroup) ?? ReadPassword("cat.voice.VoiceCatiOS", tag, null)
|
||||
: null);
|
||||
}
|
||||
|
||||
internal void SavePassword(Guid id, string password)
|
||||
{
|
||||
byte[] encoded = Encoding.UTF8.GetBytes(password);
|
||||
try
|
||||
{
|
||||
using var data = NSData.FromArray(encoded);
|
||||
using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
|
||||
using var attributes = new SecRecord { ValueData = data, Label = "VoiceCat server password", Accessible = SecAccessible.AfterFirstUnlock };
|
||||
SecStatusCode status = SecKeyChain.Update(query, attributes);
|
||||
if (status == SecStatusCode.ItemNotFound)
|
||||
{
|
||||
using var record = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
|
||||
record.ValueData = data; record.Label = attributes.Label; record.Accessible = attributes.Accessible;
|
||||
status = SecKeyChain.Add(record);
|
||||
}
|
||||
if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status}).");
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(encoded); }
|
||||
}
|
||||
|
||||
internal void RemovePassword(Guid id)
|
||||
{
|
||||
using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
|
||||
SecStatusCode status = SecKeyChain.Remove(query);
|
||||
if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound))
|
||||
throw new InvalidOperationException($"Keychain removal failed ({status}).");
|
||||
}
|
||||
|
||||
private static string? ReadPassword(string service, string account, string? group)
|
||||
{
|
||||
using var query = PasswordQuery(service, account, group);
|
||||
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
|
||||
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
|
||||
byte[] bytes = data.ToArray();
|
||||
try { return Encoding.UTF8.GetString(bytes); }
|
||||
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||
}
|
||||
|
||||
private static SecRecord PasswordQuery(string service, string account, string? group) => new(SecKind.GenericPassword)
|
||||
{ Service = service, Account = account, AccessGroup = group };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using UIKit;
|
||||
|
||||
UIApplication.Main(args, null, typeof(VoiceCat.iOS.AppDelegate));
|
||||
@@ -0,0 +1,129 @@
|
||||
using UIKit;
|
||||
using VoiceCat.Audio;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class MainTabController : UITabBarController
|
||||
{
|
||||
internal MainTabController(AppModel model)
|
||||
{
|
||||
UIViewController channels = Wrap(new ChannelsController(model), "Channels", "list.bullet.indent", 0);
|
||||
UIViewController chat = Wrap(new ChatController(model), "Chat", "message", 1);
|
||||
UIViewController settings = Wrap(new SettingsController(model), "Settings", "gear", 2);
|
||||
ViewControllers = [channels, chat, settings];
|
||||
}
|
||||
|
||||
private static UIViewController Wrap(UIViewController content, string title, string image, nint tag)
|
||||
{
|
||||
var root = new UIViewController(); UIView rootView = root.View!; rootView.BackgroundColor = UIColor.SystemBackground;
|
||||
var navigation = new UINavigationController(content); UIView navigationView = navigation.View!; navigationView.TranslatesAutoresizingMaskIntoConstraints = false;
|
||||
root.AddChildViewController(navigation); rootView.AddSubview(navigationView); navigation.DidMoveToParentViewController(root);
|
||||
var voice = new VoiceControlsView(AppModel.Shared) { TranslatesAutoresizingMaskIntoConstraints = false }; rootView.AddSubview(voice);
|
||||
NSLayoutConstraint.ActivateConstraints([navigationView.TopAnchor.ConstraintEqualTo(rootView.TopAnchor), navigationView.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor),
|
||||
navigationView.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor), navigationView.BottomAnchor.ConstraintEqualTo(voice.TopAnchor),
|
||||
voice.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor), voice.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor),
|
||||
voice.BottomAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.BottomAnchor), voice.HeightAnchor.ConstraintEqualTo(58)]);
|
||||
root.TabBarItem = new(title, UIImage.GetSystemImage(image), tag); return root;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class VoiceControlsView : UIView
|
||||
{
|
||||
private readonly AppModel model; private readonly UIButton join = UIButton.FromType(UIButtonType.System);
|
||||
private readonly UIButton ptt = UIButton.FromType(UIButtonType.System); private readonly UIButton mute = UIButton.FromType(UIButtonType.System);
|
||||
private readonly UIButton deafen = UIButton.FromType(UIButtonType.System); private readonly UIProgressView level = new(UIProgressViewStyle.Default);
|
||||
internal VoiceControlsView(AppModel model) { this.model = model; model.Changed += Refresh; Build(); }
|
||||
private void Build()
|
||||
{
|
||||
BackgroundColor = UIColor.SecondarySystemBackground; join.TouchUpInside += async (_, _) => await Run(model.ToggleVoiceAsync);
|
||||
ptt.TouchDown += (_, _) => model.SetPushToTalk(true); ptt.TouchUpInside += (_, _) => model.SetPushToTalk(false);
|
||||
ptt.TouchUpOutside += (_, _) => model.SetPushToTalk(false); ptt.TouchCancel += (_, _) => model.SetPushToTalk(false);
|
||||
mute.TouchUpInside += (_, _) => model.SetSelfAudio(!(model.Client?.Audio.MicMuted ?? false), model.Client?.Audio.Deafened ?? false);
|
||||
deafen.TouchUpInside += (_, _) => model.SetSelfAudio(model.Client?.Audio.MicMuted ?? false, !(model.Client?.Audio.Deafened ?? false));
|
||||
UIStackView stack = new([join, ptt, level, mute, deafen]) { Axis = UILayoutConstraintAxis.Horizontal, Alignment = UIStackViewAlignment.Center,
|
||||
Distribution = UIStackViewDistribution.Fill, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
|
||||
AddSubview(stack); level.WidthAnchor.ConstraintEqualTo(65).Active = true;
|
||||
NSLayoutConstraint.ActivateConstraints([stack.LeadingAnchor.ConstraintEqualTo(LeadingAnchor, 12), stack.TrailingAnchor.ConstraintEqualTo(TrailingAnchor, -12), stack.CenterYAnchor.ConstraintEqualTo(CenterYAnchor)]); Refresh();
|
||||
}
|
||||
private void Refresh()
|
||||
{
|
||||
join.SetTitle(model.VoiceJoined ? "Leave Voice" : "Join Voice", UIControlState.Normal); join.AccessibilityLabel = join.Title(UIControlState.Normal); join.Enabled = model.CurrentChannelId != 0;
|
||||
ptt.SetTitle("Hold to Talk", UIControlState.Normal); ptt.AccessibilityLabel = "Push to talk, hold to transmit"; ptt.Hidden = model.Settings.InputMode != AudioInputMode.PushToTalk; ptt.Enabled = model.VoiceJoined;
|
||||
bool muted = model.Client?.Audio.MicMuted == true, deafened = model.Client?.Audio.Deafened == true;
|
||||
mute.SetImage(UIImage.GetSystemImage(muted ? "mic.slash.fill" : "mic.fill"), UIControlState.Normal); mute.AccessibilityLabel = muted ? "Unmute microphone" : "Mute microphone"; mute.Enabled = model.VoiceJoined;
|
||||
deafen.SetImage(UIImage.GetSystemImage(deafened ? "headphones.slash" : "headphones"), UIControlState.Normal); deafen.AccessibilityLabel = deafened ? "Undeafen" : "Deafen"; deafen.Enabled = model.VoiceJoined;
|
||||
level.Progress = Math.Clamp(model.MicrophoneLevel * 10, 0, 1); level.AccessibilityLabel = "Microphone level"; level.AccessibilityValue = $"{level.Progress:P0}";
|
||||
}
|
||||
private async Task Run(Func<Task> operation) { try { await operation(); } catch (Exception exception) { if (Window?.RootViewController is { } owner) UiHelpers.ShowError(owner, exception); } }
|
||||
}
|
||||
|
||||
internal sealed class ChannelsController : UITableViewController
|
||||
{
|
||||
private readonly AppModel model; private IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten();
|
||||
internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += Reload; }
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel");
|
||||
NavigationItem.RightBarButtonItems = [new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)),
|
||||
new(UIBarButtonSystemItem.Add, (_, _) => NavigationController?.PushViewController(new ChannelEditorController(model, null), true))]; Reload();
|
||||
}
|
||||
private void Reload() { TableView.ReloadData(); NavigationItem.RightBarButtonItems![1].Enabled = model.Client?.Permissions is { } p && (p.IsAdmin || p.CanCreateTempChannel); }
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
(Channel channel, int depth) = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath);
|
||||
int count = model.Users.Count(user => user.ChannelId == channel.Id); var content = cell.DefaultContentConfiguration;
|
||||
content.Text = new string(' ', depth * 3) + channel.Name; content.SecondaryText = string.IsNullOrEmpty(channel.Topic) ? $"{count} users" : $"{channel.Topic} • {count} users";
|
||||
content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : channel.PasswordProtected ? "lock.fill" : "bubble.left"); cell.ContentConfiguration = content;
|
||||
cell.AccessibilityLabel = $"{channel.Name}{(channel.Id == model.CurrentChannelId ? ", current" : "")}{(channel.PasswordProtected ? ", password protected" : "")}, {count} users"; return cell;
|
||||
}
|
||||
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
Channel channel = Visible[indexPath.Row].Channel; tableView.DeselectRow(indexPath, true);
|
||||
if (channel.PasswordProtected) { PromptPassword(channel); return; }
|
||||
try { await model.JoinChannelAsync(channel.Id); } catch (Exception exception) { UiHelpers.ShowError(this, exception); }
|
||||
}
|
||||
public override UISwipeActionsConfiguration? GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
if (model.Client?.Permissions.IsAdmin != true) return null; Channel channel = Visible[indexPath.Row].Channel;
|
||||
UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { NavigationController?.PushViewController(new ChannelEditorController(model, channel), true); done(true); });
|
||||
UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { ConfirmDelete(channel); done(true); });
|
||||
delete.BackgroundColor = UIColor.SystemRed; return UISwipeActionsConfiguration.FromActions(channel.Id == 1 ? [edit] : [delete, edit]);
|
||||
}
|
||||
private void PromptPassword(Channel channel)
|
||||
{
|
||||
UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(field => { field.SecureTextEntry = true; field.AccessibilityLabel = "Channel 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 exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(prompt, true, null);
|
||||
}
|
||||
private void ConfirmDelete(Channel channel)
|
||||
{
|
||||
UIAlertController alert = UIAlertController.Create("Delete channel?", channel.Name, UIAlertControllerStyle.Alert); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null));
|
||||
alert.AddAction(UIAlertAction.Create("Delete", UIAlertActionStyle.Destructive, async _ => { try { GenericResult result = await model.RunAdminAsync(client => client.DeleteChannelAsync(channel.Id)); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(alert, true, null);
|
||||
}
|
||||
private IReadOnlyList<(Channel, int)> Flatten()
|
||||
{
|
||||
var result = new List<(Channel, int)>(); void Add(uint parent, int depth) { foreach (Channel value in model.Channels.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) { result.Add((value, depth)); Add(value.Id, depth + 1); } }
|
||||
Add(0, 0); return result;
|
||||
}
|
||||
}
|
||||
|
||||
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.AccessibilityLabel = "Chat and activity timeline"; 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()
|
||||
{
|
||||
IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Select(message => (message.Timestamp, $"{(message.Private ? "[private] " : "")}{message.Sender}: {message.Text}"));
|
||||
IEnumerable<(DateTime Time, string Text)> activity = model.Activity.Select(value => (value.Timestamp, $"• {value.Text}"));
|
||||
log.Text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using VoiceCat.Core;
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class RootViewController : UIViewController
|
||||
{
|
||||
private readonly AppModel model;
|
||||
private UIViewController? current;
|
||||
internal RootViewController(AppModel model) { this.model = model; model.Changed += Refresh; model.IdentityRequested += ShowIdentity; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; Refresh(); }
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
bool main = model.IsConnected;
|
||||
if (current is MainTabController && main || current is UINavigationController && !main) return;
|
||||
UIViewController next = main ? new MainTabController(model) : new UINavigationController(new ServerListController(model));
|
||||
if (current is not null) { current.WillMoveToParentViewController(null); current.View!.RemoveFromSuperview(); current.RemoveFromParentViewController(); }
|
||||
UIView rootView = View!, nextView = next.View!;
|
||||
AddChildViewController(next); nextView.Frame = rootView.Bounds; nextView.AutoresizingMask = UIViewAutoresizing.All; rootView.AddSubview(nextView); next.DidMoveToParentViewController(this); current = next;
|
||||
}
|
||||
|
||||
private void ShowIdentity(ServerIdentityChallenge challenge)
|
||||
{
|
||||
UIAlertController alert = UIAlertController.Create(challenge.Status == VoiceCat.Crypto.TofuStatus.Mismatch ? "Server Identity Changed" : "New Server Identity",
|
||||
$"{challenge.Host}:{challenge.Port}\n\nSHA-256\n{challenge.CertificateFingerprint}", UIAlertControllerStyle.Alert);
|
||||
alert.AddAction(UIAlertAction.Create("Reject", UIAlertActionStyle.Destructive, _ => model.ResolveIdentity(false)));
|
||||
alert.AddAction(UIAlertAction.Create("Trust", UIAlertActionStyle.Default, _ => model.ResolveIdentity(true)));
|
||||
PresentViewController(alert, true, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
[Register("SceneDelegate")]
|
||||
internal sealed class SceneDelegate : UIResponder, IUIWindowSceneDelegate
|
||||
{
|
||||
[Export("window")]
|
||||
public UIWindow? Window { get; set; }
|
||||
|
||||
[Export("scene:willConnectToSession:options:")]
|
||||
public void WillConnect(UIScene scene, UISceneSession session, UISceneConnectionOptions options)
|
||||
{
|
||||
if (scene is not UIWindowScene windowScene) return;
|
||||
Window = new(windowScene) { RootViewController = new RootViewController(AppModel.Shared) };
|
||||
Window.MakeKeyAndVisible();
|
||||
}
|
||||
|
||||
[Export("sceneDidEnterBackground:")]
|
||||
public void DidEnterBackground(UIScene scene) => AppModel.Shared.DidEnterBackground();
|
||||
|
||||
[Export("sceneWillEnterForeground:")]
|
||||
public void WillEnterForeground(UIScene scene) => AppModel.Shared.WillEnterForeground();
|
||||
|
||||
[Export("sceneDidBecomeActive:")]
|
||||
public void DidBecomeActive(UIScene scene) => AppModel.Shared.DidBecomeActive();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using VoiceCat.Core;
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class ServerListController : UITableViewController
|
||||
{
|
||||
private readonly AppModel model;
|
||||
internal ServerListController(AppModel model) { this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0); }
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "server");
|
||||
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); model.Changed += Reload;
|
||||
}
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => model.Profiles.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
UITableViewCell cell = tableView.DequeueReusableCell("server", indexPath);
|
||||
ServerProfile p = model.Profiles[indexPath.Row];
|
||||
var content = cell.DefaultContentConfiguration; content.Text = p.DisplayName; content.SecondaryText = p.Authentication.ToString(); cell.ContentConfiguration = content;
|
||||
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; return cell;
|
||||
}
|
||||
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
tableView.DeselectRow(indexPath, true); try { await model.ConnectAsync(model.Profiles[indexPath.Row]); } catch (Exception e) { UiHelpers.ShowError(this, e); }
|
||||
}
|
||||
public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
ServerProfile profile = model.Profiles[indexPath.Row];
|
||||
UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { PresentEditor(profile); done(true); });
|
||||
UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { model.RemoveProfile(profile); done(true); });
|
||||
return UISwipeActionsConfiguration.FromActions([delete, edit]);
|
||||
}
|
||||
private void PresentEditor(ServerProfile? profile) => PresentViewController(new UINavigationController(new ServerEditorController(model, profile)), true, null);
|
||||
private void Reload() { TableView.ReloadData(); NavigationItem.Prompt = model.IsConnecting ? model.Status : null; }
|
||||
}
|
||||
|
||||
internal sealed class ServerEditorController : UIViewController
|
||||
{
|
||||
private readonly AppModel model; private readonly ServerProfile? existing;
|
||||
private readonly UITextField host = UiHelpers.Field("Hostname or IP address");
|
||||
private readonly UITextField port = UiHelpers.Field("Port");
|
||||
private readonly UISegmentedControl mode = new(["Guest", "Account"]);
|
||||
private readonly UITextField name = UiHelpers.Field("Nickname or username");
|
||||
private readonly UITextField password = UiHelpers.Field("Password (optional)", true);
|
||||
internal ServerEditorController(AppModel model, ServerProfile? existing) { this.model = model; this.existing = existing; Title = existing is null ? "Add Server" : "Edit Server"; }
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; port.KeyboardType = UIKeyboardType.NumberPad;
|
||||
UIStackView stack = new([host, port, mode, name, password]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
|
||||
View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]);
|
||||
mode.SelectedSegment = existing?.Authentication == ServerAuthentication.Account ? 1 : 0; host.Text = existing?.Host; port.Text = (existing?.Port ?? 8384).ToString(); name.Text = existing?.Username ?? existing?.Nickname;
|
||||
NavigationItem.LeftBarButtonItem = new(UIBarButtonSystemItem.Cancel, (_, _) => DismissViewController(true, null));
|
||||
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Save, (_, _) => Save());
|
||||
}
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ushort.TryParse(port.Text, out ushort number)) throw new ArgumentException("Enter a valid port.");
|
||||
ServerAuthentication auth = mode.SelectedSegment == 1 ? ServerAuthentication.Account : ServerAuthentication.Guest;
|
||||
ServerProfile p = ServerProfile.Create(host.Text ?? "", number, auth, auth == ServerAuthentication.Account ? name.Text : null, auth == ServerAuthentication.Guest ? name.Text : null, existing?.Id);
|
||||
model.UpsertProfile(p, password.Text); DismissViewController(true, null);
|
||||
}
|
||||
catch (Exception e) { UiHelpers.ShowError(this, e); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
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 };
|
||||
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
|
||||
{
|
||||
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" },
|
||||
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"
|
||||
};
|
||||
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);
|
||||
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(); }); }
|
||||
else if (path.Section == 0 && path.Row is 2 or 3 || path.Section == 3) cell.Accessory = UITableViewCellAccessory.DisclosureIndicator;
|
||||
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);
|
||||
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();
|
||||
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()
|
||||
{
|
||||
if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio(); return; }
|
||||
#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); } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using UIKit;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal static class UiHelpers
|
||||
{
|
||||
internal static void ShowError(UIViewController owner, Exception exception) => ShowMessage(owner, "VoiceCat", exception.Message);
|
||||
internal static void ShowMessage(UIViewController owner, string title, string message)
|
||||
{
|
||||
UIAlertController alert = UIAlertController.Create(title, message, UIAlertControllerStyle.Alert);
|
||||
alert.AddAction(UIAlertAction.Create("OK", UIAlertActionStyle.Default, null)); owner.PresentViewController(alert, true, null);
|
||||
}
|
||||
|
||||
internal static UITextField Field(string placeholder, bool secure = false)
|
||||
{
|
||||
var field = new UITextField { Placeholder = placeholder, BorderStyle = UITextBorderStyle.RoundedRect,
|
||||
SecureTextEntry = secure, TranslatesAutoresizingMaskIntoConstraints = false };
|
||||
field.AccessibilityLabel = placeholder; return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-ios27.0</TargetFramework>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">iossimulator-arm64</RuntimeIdentifier>
|
||||
<SupportedOSPlatformVersion>18.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<ApplicationTitle>VoiceCat</ApplicationTitle>
|
||||
<ApplicationId>me.iamtalon.voicecat</ApplicationId>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.0.1</ApplicationDisplayVersion>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<ApplicationManifest>Info.plist</ApplicationManifest>
|
||||
<TrimMode Condition="'$(Configuration)' == 'Release'">full</TrimMode>
|
||||
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
|
||||
<VoiceCatIosStatic>true</VoiceCatIosStatic>
|
||||
<VoiceCatNativeRid Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iossimulator-arm64</VoiceCatNativeRid>
|
||||
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">ios-arm64</VoiceCatNativeRid>
|
||||
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatBroadcastSdk Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iphonesimulator</VoiceCatBroadcastSdk>
|
||||
<VoiceCatBroadcastSdk Condition="'$(VoiceCatBroadcastSdk)' == ''">iphoneos</VoiceCatBroadcastSdk>
|
||||
<VoiceCatBroadcastArch>arm64</VoiceCatBroadcastArch>
|
||||
<VoiceCatBroadcastOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/broadcast'))</VoiceCatBroadcastOutput>
|
||||
<VoiceCatIosCaptureOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/capture'))</VoiceCatIosCaptureOutput>
|
||||
<_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" AdditionalProperties="VoiceCatIosStatic=true" />
|
||||
<NativeReference Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
|
||||
<Kind>Static</Kind>
|
||||
<ForceLoad>true</ForceLoad>
|
||||
</NativeReference>
|
||||
<LinkerArgument Include="-Wl,-force_load,$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')" />
|
||||
<ReferenceNativeSymbol Include="vcm_opus_version;vcm_opus_error;vcm_encoder_create;vcm_encoder_destroy;vcm_encoder_set;vcm_encoder_get_dred;vcm_encode;vcm_decoder_create;vcm_decoder_destroy;vcm_decode;vcm_dred_decoder_create;vcm_dred_decoder_destroy;vcm_dred_create;vcm_dred_destroy;vcm_dred_parse;vcm_dred_decode;vcm_rnnoise_create;vcm_rnnoise_destroy;vcm_rnnoise_process">
|
||||
<SymbolType>Function</SymbolType>
|
||||
</ReferenceNativeSymbol>
|
||||
<NativeReference Include="$(VoiceCatIosCaptureOutput)/libvoicecat_ios_capture.a">
|
||||
<Kind>Static</Kind>
|
||||
<ForceLoad>true</ForceLoad>
|
||||
<Frameworks Condition="!$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">AVFoundation CoreMedia</Frameworks>
|
||||
</NativeReference>
|
||||
<AdditionalAppExtensions Include="$(VoiceCatBroadcastOutput)">
|
||||
<Name>VoiceCatBroadcast</Name>
|
||||
<BuildOutput>.</BuildOutput>
|
||||
<CodesignEntitlements>$(VoiceCatBroadcastOutput)/VoiceCatBroadcast.xcent</CodesignEntitlements>
|
||||
</AdditionalAppExtensions>
|
||||
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<ImageAsset Include="Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
|
||||
<Exec Command=""$(MSBuildThisFileDirectory)build-broadcast-extension.sh" "$(Configuration)" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatBroadcastOutput)"" />
|
||||
</Target>
|
||||
<Target Name="VoiceCatBuildIosCaptureBridge" BeforeTargets="PrepareForBuild">
|
||||
<Exec Command=""$(MSBuildThisFileDirectory)build-screen-capture-bridge.sh" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatIosCaptureOutput)"" />
|
||||
</Target>
|
||||
<Target Name="VoiceCatPrepareIosNativeAssets">
|
||||
<ItemGroup>
|
||||
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib' or '%(Filename)%(Extension)' == 'voicecat_media.dll' or '%(Filename)%(Extension)' == 'libvoicecat_media.so'" />
|
||||
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'NOTICE.txt' or '%(Filename)%(Extension)' == 'Opus.txt' or '%(Filename)%(Extension)' == 'RNNoise.txt'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
configuration="${1:?configuration is required}"
|
||||
sdk="${2:?sdk is required}"
|
||||
architecture="${3:?architecture is required}"
|
||||
output="${4:?output directory is required}"
|
||||
script_dir="${0:A:h}"
|
||||
project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj"
|
||||
|
||||
mkdir -p "$output"
|
||||
signing=()
|
||||
if [[ "$sdk" == "iphonesimulator" ]]; then
|
||||
signing+=(CODE_SIGNING_ALLOWED=NO)
|
||||
elif [[ -n "${VOICECAT_DEVELOPMENT_TEAM:-}" ]]; then
|
||||
signing+=(DEVELOPMENT_TEAM="$VOICECAT_DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Automatic)
|
||||
if [[ "${VOICECAT_ALLOW_PROVISIONING_UPDATES:-}" == "1" ]]; then
|
||||
signing=(-allowProvisioningUpdates "${signing[@]}")
|
||||
fi
|
||||
fi
|
||||
xcodebuild \
|
||||
-project "$project" \
|
||||
-scheme VoiceCatBroadcast \
|
||||
-configuration "$configuration" \
|
||||
-sdk "$sdk" \
|
||||
-arch "$architecture" \
|
||||
-derivedDataPath "$output/derived" \
|
||||
CONFIGURATION_BUILD_DIR="$output" \
|
||||
"${signing[@]}" \
|
||||
build
|
||||
|
||||
xcent=$(find "$output/derived" -name 'VoiceCatBroadcast.appex.xcent' -print -quit)
|
||||
if [[ -f "$xcent" ]]; then
|
||||
cp "$xcent" "$output/VoiceCatBroadcast.xcent"
|
||||
else
|
||||
cp "$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
|
||||
fi
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
sdk="$1"
|
||||
arch="$2"
|
||||
output="$3"
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
mkdir -p "$output"
|
||||
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
|
||||
target="arm64-apple-ios18.0"
|
||||
[ "$sdk" = "iphonesimulator" ] && target="arm64-apple-ios18.0-simulator"
|
||||
source="$root/native/ios_screen_capture.m"
|
||||
flags="-fobjc-arc -fmodules -fmodules-cache-path=$output/module-cache -Wno-unguarded-availability-new"
|
||||
if [ "$sdk" = "iphonesimulator" ]; then source="$root/native/ios_screen_capture_stub.c"; flags=""; fi
|
||||
# shellcheck disable=SC2086
|
||||
xcrun --sdk "$sdk" clang -target "$target" -isysroot "$sdk_path" $flags -Werror -c "$source" -o "$output/ios_screen_capture.o"
|
||||
xcrun --sdk "$sdk" ar rcs "$output/libvoicecat_ios_capture.a" "$output/ios_screen_capture.o"
|
||||
@@ -0,0 +1,124 @@
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <CoreMedia/CoreMedia.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <ScreenCaptureKit/ScreenCaptureKit.h>
|
||||
#import <fcntl.h>
|
||||
#import <stdatomic.h>
|
||||
#import <sys/mman.h>
|
||||
#import <sys/stat.h>
|
||||
#import <unistd.h>
|
||||
|
||||
enum { VCRingHeader = 64, VCRingCapacity = 96000 };
|
||||
|
||||
@interface VCScreenCaptureBridge : NSObject <SCContentSharingPickerObserver, SCStreamOutput, SCStreamDelegate>
|
||||
@property(nonatomic) SCStream *stream;
|
||||
@property(nonatomic) AVAudioConverter *converter;
|
||||
@property(nonatomic) AVAudioFormat *inputFormat;
|
||||
@property(nonatomic) AVAudioFormat *outputFormat;
|
||||
@property(nonatomic) NSString *ringPath;
|
||||
@property(nonatomic) int ringFd;
|
||||
@property(nonatomic) void *ringMap;
|
||||
@end
|
||||
|
||||
@implementation VCScreenCaptureBridge
|
||||
|
||||
static SCContentSharingPicker *VCSharedPicker(void) {
|
||||
Class pickerClass = NSClassFromString(@"SCContentSharingPicker");
|
||||
return pickerClass ? [pickerClass performSelector:@selector(sharedPicker)] : nil;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if ((self = [super init])) {
|
||||
_ringFd = -1;
|
||||
_outputFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16 sampleRate:48000 channels:2 interleaved:YES];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)openRing {
|
||||
if (_ringMap || !_ringPath) return;
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:[_ringPath stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
|
||||
_ringFd = open(_ringPath.fileSystemRepresentation, O_RDWR | O_CREAT, 0644);
|
||||
size_t size = VCRingHeader + VCRingCapacity * sizeof(int16_t);
|
||||
if (_ringFd < 0 || ftruncate(_ringFd, (off_t)size) != 0) return;
|
||||
_ringMap = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, _ringFd, 0);
|
||||
if (_ringMap == MAP_FAILED) _ringMap = NULL;
|
||||
if (_ringMap && *(uint32_t *)_ringMap != 0x56434252) {
|
||||
memset(_ringMap, 0, VCRingHeader); *(uint32_t *)((uint8_t *)_ringMap + 4) = 1; atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)_ringMap = 0x56434252;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setRingActive:(BOOL)active {
|
||||
[self openRing]; if (!_ringMap) return;
|
||||
if (active) { *(uint32_t *)((uint8_t *)_ringMap + 8) = 2; *(uint32_t *)((uint8_t *)_ringMap + 12) = 48000; }
|
||||
atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)((uint8_t *)_ringMap + 16) = active ? 1 : 0;
|
||||
}
|
||||
|
||||
- (void)push:(const int16_t *)samples count:(NSUInteger)count {
|
||||
if (!_ringMap || count == 0 || count > VCRingCapacity) return;
|
||||
uint8_t *base = _ringMap; uint64_t write = *(uint64_t *)(base + 24); atomic_thread_fence(memory_order_seq_cst); uint64_t read = *(uint64_t *)(base + 32);
|
||||
if (VCRingCapacity - (write - read) < count) return;
|
||||
int16_t *data = (int16_t *)(base + VCRingHeader); NSUInteger index = write % VCRingCapacity;
|
||||
NSUInteger first = MIN(count, VCRingCapacity - index); memcpy(data + index, samples, first * sizeof(int16_t));
|
||||
if (first < count) memcpy(data, samples + first, (count - first) * sizeof(int16_t));
|
||||
atomic_thread_fence(memory_order_seq_cst); *(uint64_t *)(base + 24) = write + count;
|
||||
}
|
||||
|
||||
- (void)present:(NSString *)path API_AVAILABLE(ios(27.0)) {
|
||||
self.ringPath = path; [self openRing]; SCContentSharingPicker *picker = VCSharedPicker();
|
||||
Class configurationClass = NSClassFromString(@"SCContentSharingPickerConfiguration");
|
||||
SCContentSharingPickerConfiguration *configuration = [configurationClass new];
|
||||
if (!picker || !configuration) return;
|
||||
configuration.showsMicrophoneControl = NO; configuration.showsCameraControl = NO; picker.defaultConfiguration = configuration;
|
||||
[picker removeObserver:self]; [picker addObserver:self]; picker.active = YES; [picker presentPickerUsingContentStyle:SCShareableContentStyleDisplay];
|
||||
}
|
||||
|
||||
- (void)stop {
|
||||
[self.stream stopCaptureWithCompletionHandler:^(__unused NSError *error) {}]; self.stream = nil;
|
||||
SCContentSharingPicker *picker = VCSharedPicker(); [picker removeObserver:self]; picker.active = NO; [self setRingActive:NO];
|
||||
}
|
||||
|
||||
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didUpdateWithFilter:(SCContentFilter *)filter forStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) {
|
||||
[self stop]; Class configurationClass = NSClassFromString(@"SCStreamConfiguration"); Class streamClass = NSClassFromString(@"SCStream");
|
||||
SCStreamConfiguration *configuration = [configurationClass new]; if (!configuration || !streamClass) return; configuration.capturesAudio = YES;
|
||||
configuration.excludesCurrentProcessAudio = YES; configuration.sampleRate = 48000; configuration.channelCount = 2;
|
||||
configuration.width = 2; configuration.height = 2;
|
||||
self.stream = [[streamClass alloc] initWithFilter:filter configuration:configuration delegate:self]; NSError *error = nil;
|
||||
if (![self.stream addStreamOutput:self type:SCStreamOutputTypeAudio sampleHandlerQueue:dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0) error:&error]) { self.stream = nil; return; }
|
||||
[self.stream startCaptureWithCompletionHandler:^(NSError *captureError) { [self setRingActive:captureError == nil]; }];
|
||||
}
|
||||
|
||||
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didCancelForStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) { [self stop]; }
|
||||
- (void)contentSharingPickerStartDidFailWithError:(NSError *)error API_AVAILABLE(ios(27.0)) { [self stop]; }
|
||||
- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { [self stop]; }
|
||||
|
||||
- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type {
|
||||
if (type != SCStreamOutputTypeAudio || !CMSampleBufferDataIsReady(sampleBuffer)) return;
|
||||
CMAudioFormatDescriptionRef description = CMSampleBufferGetFormatDescription(sampleBuffer); const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(description);
|
||||
if (!asbd) return; AVAudioFormat *format = [[AVAudioFormat alloc] initWithStreamDescription:asbd]; AVAudioFrameCount frames = (AVAudioFrameCount)CMSampleBufferGetNumSamples(sampleBuffer);
|
||||
AVAudioPCMBuffer *input = [[AVAudioPCMBuffer alloc] initWithPCMFormat:format frameCapacity:frames]; input.frameLength = frames;
|
||||
if (CMSampleBufferCopyPCMDataIntoAudioBufferList(sampleBuffer, 0, frames, input.mutableAudioBufferList) != noErr) return;
|
||||
if (!self.converter || ![self.inputFormat isEqual:format]) { self.inputFormat = format; self.converter = [[AVAudioConverter alloc] initFromFormat:format toFormat:self.outputFormat]; }
|
||||
AVAudioFrameCount capacity = (AVAudioFrameCount)ceil(frames * 48000.0 / format.sampleRate) + 64; AVAudioPCMBuffer *output = [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.outputFormat frameCapacity:capacity];
|
||||
__block BOOL supplied = NO; NSError *error = nil; AVAudioConverterOutputStatus status = [self.converter convertToBuffer:output error:&error withInputFromBlock:^AVAudioBuffer *(AVAudioPacketCount count, AVAudioConverterInputStatus *inputStatus) { if (supplied) { *inputStatus = AVAudioConverterInputStatus_NoDataNow; return nil; } supplied = YES; *inputStatus = AVAudioConverterInputStatus_HaveData; return input; }];
|
||||
if (status == AVAudioConverterOutputStatus_Error || output.frameLength == 0 || !output.int16ChannelData) return;
|
||||
[self push:output.int16ChannelData[0] count:output.frameLength * 2];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self setRingActive:NO]; if (_ringMap) munmap(_ringMap, VCRingHeader + VCRingCapacity * sizeof(int16_t)); if (_ringFd >= 0) close(_ringFd);
|
||||
}
|
||||
@end
|
||||
|
||||
static VCScreenCaptureBridge *bridge;
|
||||
|
||||
int vc_ios_screen_capture_available(void) {
|
||||
if (@available(iOS 27.0, *)) return VCSharedPicker().available ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void vc_ios_screen_capture_present(const char *ring_path) {
|
||||
if (@available(iOS 27.0, *)) { if (!bridge) bridge = [VCScreenCaptureBridge new]; [bridge present:[NSString stringWithUTF8String:ring_path]]; }
|
||||
}
|
||||
|
||||
void vc_ios_screen_capture_stop(void) { [bridge stop]; }
|
||||
@@ -0,0 +1,3 @@
|
||||
int vc_ios_screen_capture_available(void) { return 0; }
|
||||
void vc_ios_screen_capture_present(const char *ring_path) { (void)ring_path; }
|
||||
void vc_ios_screen_capture_stop(void) {}
|
||||
Reference in New Issue
Block a user