fix(ios): rebuild audio only when needed and stop VoiceOver list churn
Two iOS bugs with the same shape: unconditional rebuilds where a conditional check belongs. The audio graph was torn down on every unintentional disconnect and every foreground transition. A lost connection ran the same teardown as an explicit disconnect, deactivating the AVAudioSession and so dropping the Bluetooth HFP link for a transport blip, and foregrounding always called Reconfigure even though the `audio` background mode keeps the graph live. Both cost seconds of dead audio on a headset. Split "session ended" from "transport blipped". Detach unbinds the client but keeps the session, graph, and route, so a reconnect rebinds to a live HFP link; the route is parked on stream id 0 so capture cannot feed the next connection a stream it never announced. StartListening reuses a running graph, StartMicrophone reuses a running tap of the same width, and Reconfigure gained a non-forcing mode that no-ops when tap presence, channel width, and voice processing all still match. Foregrounding now ensures the graph is running and only reconfigures if it actually stopped. Route changes, media-services resets, and the stall watchdog still force a full rebuild. Every list also reloaded on a model event raised 20 times a second by the microphone level timer. ReloadData recreates the accessibility element tree, so VoiceOver explore mode re-announced the row under a dragging finger and a double tap landed on an element that no longer existed. No controller ever unsubscribed, so popped controllers kept reloading too. Move the level to its own LevelChanged event, and reload lists through ListRefresher, which subscribes only while on screen and only reloads when the rendered content signature changed. The voice bar publishes its accessibility value on 5% steps, MoveUserController reloads just its two checkmark rows, and the chat transcripts skip reassigning identical text. The changed logic sits on UIKit and AVFoundation types the net10.0 test project cannot reference, so this carries no tests; the Bluetooth reconnect and foreground paths need device verification.
This commit is contained in:
+10
-1
@@ -30,7 +30,16 @@ still needs device validation.
|
||||
|
||||
The iOS user list now opens a remote-user detail view with independent tuning for each active
|
||||
audio stream. Private messages are grouped into per-user conversations with direct access to the
|
||||
same user and audio controls.
|
||||
same user and audio controls. Lists reload only when their rendered content actually changed and
|
||||
only while on screen, and the 20 Hz microphone level is a separate signal from the general model
|
||||
change, so VoiceOver explore mode no longer re-announces the row under a dragging finger or loses
|
||||
the element a double tap was aimed at.
|
||||
|
||||
The iOS audio graph is rebuilt only when the audio configuration changed. A lost connection
|
||||
unbinds the client but keeps the session, graph, and route alive, so a reconnect rebinds to a
|
||||
live Bluetooth HFP link instead of renegotiating one; restoring voice reuses a running capture
|
||||
tap of the same width; and foregrounding ensures the graph is running rather than rebuilding it.
|
||||
Route changes, media-services resets, and the stall watchdog still force a full rebuild.
|
||||
|
||||
The media path now survives changing networks. A client whose source address changes proves
|
||||
possession of its media key from the new address with an authenticated `Rebind` frame and the
|
||||
|
||||
@@ -6,9 +6,18 @@ 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(); }
|
||||
private readonly AppModel model; private readonly ListRefresher refresher;
|
||||
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";
|
||||
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
|
||||
}
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature() => string.Join('\n', Visible.Select(user =>
|
||||
$"{user.Id}|{user.Nickname}|{user.Id == model.SelfUserId}|{user.ServerDeafened}|{user.ServerMuted}|{user.SelfDeafened}|{user.SelfMicMuted}|{user.IsGuest}"));
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
@@ -34,7 +43,7 @@ internal sealed class UsersController : UITableViewController
|
||||
|
||||
internal sealed class UserDetailController : FormController
|
||||
{
|
||||
private readonly AppModel model; private readonly uint userId;
|
||||
private readonly AppModel model; private readonly uint userId; private readonly ListRefresher refresher;
|
||||
private User? User => model.Users.FirstOrDefault(value => value.Id == userId);
|
||||
private IReadOnlyList<(string Title, Action Run, bool Destructive)> AdminActions
|
||||
{
|
||||
@@ -55,8 +64,20 @@ internal sealed class UserDetailController : FormController
|
||||
}
|
||||
}
|
||||
internal UserDetailController(AppModel model, uint userId) : base(model.Users.FirstOrDefault(value => value.Id == userId)?.Nickname ?? $"User {userId}")
|
||||
{ this.model = model; this.userId = userId; model.Changed += Reload; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); Reload(); }
|
||||
{
|
||||
this.model = model; this.userId = userId;
|
||||
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
|
||||
}
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature()
|
||||
{
|
||||
User? user = User; Title = user?.Nickname ?? $"User {userId}";
|
||||
string streams = user is null ? "offline" : string.Join(',', user.Streams.Select(stream =>
|
||||
$"{stream.StreamId}:{stream.Kind}:{stream.Label}:{model.Client?.Audio.GetRemotePlayback(userId, stream.StreamId)}"));
|
||||
return $"{Title}|{streams}|{string.Join(',', AdminActions.Select(action => action.Title))}";
|
||||
}
|
||||
public override nint NumberOfSections(UITableView tableView) => AdminActions.Count == 0 ? 2 : 3;
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => section switch
|
||||
{ 0 => Math.Max(User?.Streams.Count ?? 0, 1), 1 => 1, _ => AdminActions.Count };
|
||||
@@ -93,7 +114,6 @@ internal sealed class UserDetailController : FormController
|
||||
else if (indexPath.Section == 1) NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true);
|
||||
else if (indexPath.Section == 2) AdminActions[indexPath.Row].Run();
|
||||
}
|
||||
private void Reload() { Title = User?.Nickname ?? $"User {userId}"; if (IsViewLoaded) TableView.ReloadData(); }
|
||||
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); } }
|
||||
}
|
||||
@@ -124,7 +144,16 @@ internal sealed class MoveUserController : FormController
|
||||
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(); }
|
||||
// Only the two checkmarks move. Reloading the whole table would rebuild every accessibility
|
||||
// element and drop VoiceOver's focus on the row that was just chosen.
|
||||
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
int previous = model.Channels.ToList().FindIndex(channel => channel.Id == selected);
|
||||
selected = model.Channels[indexPath.Row].Id;
|
||||
NSIndexPath[] rows = previous >= 0 && previous != indexPath.Row
|
||||
? [indexPath, NSIndexPath.FromRowSection(previous, 0)] : [indexPath];
|
||||
tableView.ReloadRows(rows, UITableViewRowAnimation.None);
|
||||
}
|
||||
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); } }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ internal sealed class AppModel
|
||||
private int diagnosticPolls;
|
||||
|
||||
internal event Action? Changed;
|
||||
// Raised by the 20 Hz level timer only. Subscribers must be cheap and must not reload a list.
|
||||
internal event Action? LevelChanged;
|
||||
internal event Action<ServerIdentityChallenge>? IdentityRequested;
|
||||
internal IReadOnlyList<ServerProfile> Profiles => profiles;
|
||||
internal IReadOnlyList<ChatEntry> Messages => messages;
|
||||
@@ -137,7 +139,7 @@ internal sealed class AppModel
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
|
||||
IsConnecting = false; Status = exception.Message;
|
||||
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(); }
|
||||
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
|
||||
await next.DisposeAsync();
|
||||
Notify();
|
||||
if (restoring && !explicitDisconnect) ScheduleReconnect();
|
||||
@@ -199,7 +201,7 @@ internal sealed class AppModel
|
||||
// Handle that state change directly so a Wi-Fi transition cannot strand this session.
|
||||
CaptureRestoreState(owner);
|
||||
client = null; Status = "Connection lost";
|
||||
try { await StopSessionResourcesAsync(); }
|
||||
try { await StopSessionResourcesAsync(releaseAudio: false); }
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); }
|
||||
try { await owner.DisposeAsync(); }
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); }
|
||||
@@ -273,7 +275,7 @@ internal sealed class AppModel
|
||||
|
||||
internal async Task DisconnectAsync()
|
||||
{
|
||||
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync();
|
||||
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(releaseAudio: true);
|
||||
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");
|
||||
}
|
||||
@@ -325,7 +327,10 @@ internal sealed class AppModel
|
||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; }
|
||||
lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
|
||||
}
|
||||
Notify();
|
||||
// Only the voice bar renders the level. Raising the general Changed event at 20 Hz made
|
||||
// every list reload itself that often, which tears down VoiceOver's element tree under an
|
||||
// exploring finger; keep the fast signal on its own event.
|
||||
LevelChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void HandleUserEvent(VoiceCatClient owner, UserEvent value)
|
||||
@@ -367,9 +372,15 @@ internal sealed class AppModel
|
||||
owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}");
|
||||
}
|
||||
|
||||
private async Task StopSessionResourcesAsync()
|
||||
// `releaseAudio` distinguishes an ended session from an interrupted one. Ending releases the
|
||||
// AVAudioSession, which on Bluetooth drops the HFP link and costs seconds of renegotiation on
|
||||
// the way back; a lost connection is a transport event that changed nothing about the audio
|
||||
// configuration, so it only unbinds the client and leaves the live route in place.
|
||||
private async Task StopSessionResourcesAsync(bool releaseAudio)
|
||||
{
|
||||
levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false;
|
||||
levelTimer?.Dispose(); levelTimer = null;
|
||||
if (releaseAudio) IosAudioEngine.Shared.Stop(); else IosAudioEngine.Shared.Detach();
|
||||
microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false;
|
||||
if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ internal sealed class IosAudioEngine
|
||||
private AVAudioConverterInputHandler? inputProvider;
|
||||
private bool inputProvided;
|
||||
private bool tapInstalled;
|
||||
// The voice-processing state the live graph was actually built with, so Reconfigure can tell
|
||||
// a settings change apart from a re-check of an unchanged graph.
|
||||
private bool voiceProcessing;
|
||||
private long captureCallbacks, capturedFrames, convertedFrames, rejectedFeeds, converterFailures, stereoFrames, stereoDifferentFrames;
|
||||
private long renderCallbacks, lastRenderTimestamp;
|
||||
internal bool IsConnected { get; private set; }
|
||||
@@ -50,26 +53,60 @@ internal sealed class IosAudioEngine
|
||||
|
||||
internal void StartListening(VoiceCatClient owner)
|
||||
{
|
||||
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
|
||||
if (client is { } previous) previous.Audio.MixedPcm -= ReceiveMixedPcm;
|
||||
client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm;
|
||||
// A reconnect after Detach finds the session active and the graph already running on the
|
||||
// right hardware. Rebuilding it there would release an HFP headset and pay a Bluetooth
|
||||
// profile renegotiation for a transport blip that changed no audio configuration.
|
||||
if (engine?.Running == true) { playbackRing.Resynchronize(); return; }
|
||||
Volatile.Write(ref microphone, null); Rebuild();
|
||||
}
|
||||
|
||||
// Unbinds the client without touching the session or the graph, for a connection that was
|
||||
// lost rather than ended. Capture keeps feeding a ring nobody drains and Render emits silence
|
||||
// until StartListening rebinds, which keeps the route and its Bluetooth profile alive.
|
||||
internal void Detach()
|
||||
{
|
||||
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
|
||||
client = null; playbackRing.Resynchronize();
|
||||
// The route's stream id belongs to the connection that just died. Keep the tap and its
|
||||
// hardware, but park the route on the unbound id so a rebind cannot feed the next
|
||||
// connection a stream it never announced.
|
||||
if (Volatile.Read(ref microphone) is { } stale && stale.StreamId != 0)
|
||||
Volatile.Write(ref microphone, CreateMicrophoneRoute(0, stale.Channels));
|
||||
}
|
||||
|
||||
internal void StartMicrophone(uint streamId, int channels)
|
||||
{
|
||||
MicrophoneRoute? current = Volatile.Read(ref microphone);
|
||||
// Restoring voice after a reconnect only changes which stream id the existing capture
|
||||
// feeds. Reuse a running tap of the same width instead of rebuilding the graph around it.
|
||||
if (current is { StreamId: 0 } && current.Channels == Math.Clamp(channels, 1, 2) && tapInstalled && engine?.Running == true)
|
||||
{
|
||||
current.Ring.Resynchronize();
|
||||
Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); return;
|
||||
}
|
||||
Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); Rebuild();
|
||||
}
|
||||
|
||||
internal void StopMicrophone() { Volatile.Write(ref microphone, null); Rebuild(); }
|
||||
internal void Reconfigure()
|
||||
|
||||
// `force` rebuilds unconditionally, which is what a route change, a media-services reset and
|
||||
// the stall watchdog all need. Callers that are only re-checking a graph they expect to be
|
||||
// healthy — foregrounding, above all — pass false and get a no-op when nothing has changed.
|
||||
internal void Reconfigure(bool force = true)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
MicrophoneRoute? current = Volatile.Read(ref microphone);
|
||||
int channels = IosAudioRouter.Shared.CaptureChannels;
|
||||
if (!force && engine?.Running == true && tapInstalled == (current is not null) &&
|
||||
(current?.Channels ?? channels) == channels && voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing) return;
|
||||
if (current is not null)
|
||||
{
|
||||
// Stop the old tap before publishing a route with a different sample width. Otherwise
|
||||
// an in-flight callback could interpret its old converter buffer using the new width.
|
||||
DestroyGraph();
|
||||
if (current.Channels != channels) client!.Audio.SetCaptureChannels(current.StreamId, channels);
|
||||
if (current.Channels != channels && current.StreamId != 0) client?.Audio.SetCaptureChannels(current.StreamId, channels);
|
||||
Volatile.Write(ref microphone, CreateMicrophoneRoute(current.StreamId, channels));
|
||||
}
|
||||
Rebuild();
|
||||
@@ -88,6 +125,7 @@ internal sealed class IosAudioEngine
|
||||
private void Rebuild()
|
||||
{
|
||||
DestroyGraph(); MicrophoneRoute? route = Volatile.Read(ref microphone); IosAudioRouter.Shared.Apply(route is not null);
|
||||
voiceProcessing = IosAudioRouter.Shared.UsesVoiceProcessing;
|
||||
var next = new AVAudioEngine();
|
||||
AVAudioInputNode? input = null;
|
||||
if (route is not null)
|
||||
@@ -189,7 +227,8 @@ internal sealed class IosAudioEngine
|
||||
private void PumpMicrophoneChunk(VoiceCatClient owner)
|
||||
{
|
||||
MicrophoneRoute? route = Volatile.Read(ref microphone);
|
||||
if (route is null) return;
|
||||
// Stream id 0 is a route parked by Detach: still capturing, not yet bound to a connection.
|
||||
if (route is null || route.StreamId == 0) return;
|
||||
int required = 960 * route.Channels;
|
||||
if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required &&
|
||||
ReferenceEquals(route, Volatile.Read(ref microphone)) &&
|
||||
|
||||
@@ -240,13 +240,13 @@ internal sealed class IosAudioRouter
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
||||
}
|
||||
|
||||
internal void Recover(string reason)
|
||||
internal void Recover(string reason, bool force = true)
|
||||
{
|
||||
RefreshRoutes();
|
||||
if (!IosAudioEngine.Shared.IsConnected || interrupted) return;
|
||||
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
try { IosAudioEngine.Shared.Reconfigure(); }
|
||||
try { IosAudioEngine.Shared.Reconfigure(force); }
|
||||
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
||||
});
|
||||
}
|
||||
@@ -262,8 +262,17 @@ internal sealed class IosAudioRouter
|
||||
|
||||
private void ResetWatchdog() { lastRenderCallbacks = -1; watchdogMisses = 0; }
|
||||
|
||||
// An interruption that ends while the app is suspended never delivers its Ended notification.
|
||||
internal void ResumeForeground() { interrupted = false; ResetWatchdog(); Recover("foreground"); }
|
||||
// An interruption that ends while the app is suspended never delivers its Ended notification,
|
||||
// so foregrounding still has to check. It must not rebuild unconditionally though: the `audio`
|
||||
// background mode keeps the session and graph live across a backgrounding, so the graph is
|
||||
// almost always healthy here and a rebuild costs a visible glitch plus, on Bluetooth, an HFP
|
||||
// renegotiation. Ensure it is running, and only reconfigure when it actually stopped.
|
||||
internal void ResumeForeground()
|
||||
{
|
||||
interrupted = false; ResetWatchdog(); RefreshRoutes();
|
||||
if (IosAudioEngine.Shared.IsConnected && !IosAudioEngine.Shared.IsRunning) Recover("foreground");
|
||||
else EnsureAudio("foreground");
|
||||
}
|
||||
|
||||
private void TickWatchdog()
|
||||
{
|
||||
|
||||
@@ -34,7 +34,13 @@ 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 int renderedLevel = -1;
|
||||
internal VoiceControlsView(AppModel model) { this.model = model; model.Changed += Refresh; model.LevelChanged += RefreshLevel; Build(); }
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) { model.Changed -= Refresh; model.LevelChanged -= RefreshLevel; }
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
private void Build()
|
||||
{
|
||||
BackgroundColor = UIColor.SecondarySystemBackground; join.TouchUpInside += async (_, _) => await Run(model.ToggleVoiceAsync);
|
||||
@@ -54,22 +60,50 @@ internal sealed class VoiceControlsView : UIView
|
||||
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}";
|
||||
level.AccessibilityLabel = "Microphone level"; RefreshLevel();
|
||||
}
|
||||
|
||||
// Driven at 20 Hz. Rewriting AccessibilityValue on every tick makes VoiceOver re-announce the
|
||||
// meter continuously while it is focused, so only publish a value that actually changed.
|
||||
private void RefreshLevel()
|
||||
{
|
||||
float progress = Math.Clamp(model.MicrophoneLevel * 10, 0, 1); level.Progress = progress;
|
||||
int percent = (int)MathF.Round(progress * 100 / 5) * 5;
|
||||
if (percent == renderedLevel) return;
|
||||
renderedLevel = percent; level.AccessibilityValue = $"{percent}%";
|
||||
}
|
||||
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; }
|
||||
private readonly AppModel model; private readonly ListRefresher refresher; private IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten();
|
||||
internal ChannelsController(AppModel model)
|
||||
{
|
||||
this.model = model; Title = "Channels";
|
||||
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
|
||||
}
|
||||
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();
|
||||
new(UIBarButtonSystemItem.Add, (_, _) => NavigationController?.PushViewController(new ChannelEditorController(model, null), true))];
|
||||
}
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); UpdateActions(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature()
|
||||
{
|
||||
UpdateActions();
|
||||
return string.Join('\n', Visible.Select(entry =>
|
||||
$"{entry.Channel.Id}|{entry.Depth}|{entry.Channel.Name}|{entry.Channel.Topic}|{entry.Channel.PasswordProtected}|" +
|
||||
$"{entry.Channel.Id == model.CurrentChannelId}|{model.Users.Count(user => user.ChannelId == entry.Channel.Id)}"));
|
||||
}
|
||||
// The add button is not part of the table, so it can follow every notification cheaply.
|
||||
private void UpdateActions()
|
||||
{
|
||||
if (NavigationItem.RightBarButtonItems is not { Length: > 1 } items) return;
|
||||
items[1].Enabled = model.Client?.Permissions is { } p && (p.IsAdmin || p.CanCreateTempChannel);
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -112,7 +146,9 @@ internal sealed class ChannelsController : UITableViewController
|
||||
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; }
|
||||
internal ChatController(AppModel model) { this.model = model; Title = "Chat"; }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); model.Changed += Refresh; Refresh(); }
|
||||
public override void ViewDidDisappear(bool animated) { model.Changed -= Refresh; base.ViewDidDisappear(animated); }
|
||||
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;
|
||||
@@ -125,7 +161,11 @@ internal sealed class ChatController : UIViewController
|
||||
{
|
||||
IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Where(message => !message.Private).Select(message => (message.Timestamp, $"{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));
|
||||
string text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}"));
|
||||
// Reassigning identical text still resets the text view's accessibility state, which
|
||||
// interrupts VoiceOver mid-read of the transcript.
|
||||
if (text == log.Text) return;
|
||||
log.Text = text; if (text.Length > 0) log.ScrollRangeToVisible(new(text.Length - 1, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +185,24 @@ internal sealed class PrivateChatsController : UITableViewController
|
||||
}).OrderByDescending(peer => peer.Latest?.Timestamp ?? DateTime.MinValue).ThenBy(peer => peer.Name).ToArray();
|
||||
}
|
||||
}
|
||||
internal PrivateChatsController(AppModel model) { this.model = model; Title = "Private Chats"; model.Changed += Reload; }
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); Reload(); }
|
||||
private readonly ListRefresher refresher;
|
||||
internal PrivateChatsController(AppModel model)
|
||||
{
|
||||
this.model = model; Title = "Private Chats";
|
||||
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
|
||||
}
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature()
|
||||
{
|
||||
IReadOnlyList<(uint Id, string Name, ChatEntry? Latest, bool Online)> peers = Peers;
|
||||
// The empty-state view is not a table row, so it is kept current outside the reload.
|
||||
bool empty = peers.Count == 0, shown = TableView.BackgroundView is not null;
|
||||
if (empty != shown)
|
||||
TableView.BackgroundView = empty ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null;
|
||||
return string.Join('\n', peers.Select(peer => $"{peer.Id}|{peer.Name}|{peer.Online}|{peer.Latest?.Text}"));
|
||||
}
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => Peers.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
@@ -158,7 +214,6 @@ internal sealed class PrivateChatsController : UITableViewController
|
||||
{
|
||||
(uint id, string name, _, _) = Peers[indexPath.Row]; tableView.DeselectRow(indexPath, true); NavigationController?.PushViewController(new PrivateConversationController(model, id, name), true);
|
||||
}
|
||||
private void Reload() { if (IsViewLoaded) { TableView.ReloadData(); TableView.BackgroundView = Peers.Count == 0 ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null; } }
|
||||
}
|
||||
|
||||
internal sealed class PrivateConversationController : UIViewController
|
||||
@@ -167,7 +222,9 @@ internal sealed class PrivateConversationController : UIViewController
|
||||
private readonly UITextView transcript = new(); private readonly UITextField compose = UiHelpers.Field("Private message"); private readonly UIButton send = UIButton.FromType(UIButtonType.System);
|
||||
private User? Peer => model.Users.FirstOrDefault(user => user.Id == peerUserId);
|
||||
internal PrivateConversationController(AppModel model, uint peerUserId, string fallbackName)
|
||||
{ this.model = model; this.peerUserId = peerUserId; this.fallbackName = fallbackName; Title = fallbackName; model.Changed += Refresh; }
|
||||
{ this.model = model; this.peerUserId = peerUserId; this.fallbackName = fallbackName; Title = fallbackName; }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); model.Changed += Refresh; Refresh(); }
|
||||
public override void ViewDidDisappear(bool animated) { model.Changed -= Refresh; base.ViewDidDisappear(animated); }
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; transcript.Editable = false; transcript.Font = UIFont.PreferredBody; transcript.AccessibilityLabel = $"Private conversation with {fallbackName}"; transcript.TranslatesAutoresizingMaskIntoConstraints = false;
|
||||
@@ -182,8 +239,11 @@ internal sealed class PrivateConversationController : UIViewController
|
||||
{
|
||||
if (!IsViewLoaded) return; string name = Peer?.Nickname ?? fallbackName; Title = name;
|
||||
IEnumerable<ChatEntry> messages = model.Messages.Where(message => message.Private && message.PeerUserId == peerUserId).OrderBy(message => message.Timestamp);
|
||||
transcript.Text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}"));
|
||||
if (transcript.Text.Length > 0) transcript.ScrollRangeToVisible(new(transcript.Text.Length - 1, 1));
|
||||
string text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}"));
|
||||
if (text != transcript.Text)
|
||||
{
|
||||
transcript.Text = text; if (text.Length > 0) transcript.ScrollRangeToVisible(new(text.Length - 1, 1));
|
||||
}
|
||||
bool online = Peer is not null; compose.Enabled = online; send.Enabled = online; NavigationItem.RightBarButtonItem!.Enabled = online;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,24 @@ 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); }
|
||||
private readonly AppModel model; private readonly ListRefresher refresher;
|
||||
internal ServerListController(AppModel model)
|
||||
{
|
||||
this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0);
|
||||
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
|
||||
}
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "server");
|
||||
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); model.Changed += Reload;
|
||||
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null));
|
||||
}
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature()
|
||||
{
|
||||
// The connection prompt is navigation-bar chrome, not a row, so it follows every change.
|
||||
NavigationItem.Prompt = model.IsConnecting ? model.Status : null;
|
||||
return string.Join('\n', model.Profiles.Select(profile => $"{profile.Id}|{profile.DisplayName}|{profile.Authentication}"));
|
||||
}
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => model.Profiles.Count;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
@@ -32,7 +44,6 @@ internal sealed class ServerListController : UITableViewController
|
||||
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
|
||||
|
||||
@@ -9,10 +9,22 @@ 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; }
|
||||
private readonly AppModel model; private readonly ListRefresher refresher;
|
||||
internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped)
|
||||
{
|
||||
this.model = model; Title = "Settings";
|
||||
refresher = new(this, Signature,
|
||||
handler => { model.Changed += handler; IosAudioRouter.Shared.Changed += handler; },
|
||||
handler => { model.Changed -= handler; IosAudioRouter.Shared.Changed -= handler; });
|
||||
}
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
|
||||
private void Reload() => TableView.ReloadData();
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature() =>
|
||||
$"{IosAudioRouter.Shared.Preset}|{IosAudioRouter.Shared.ForceSpeaker}|{model.Settings.AudioBufferMilliseconds}|{model.ScreenSharing}|" +
|
||||
$"{model.Settings.InputMode}|{model.Settings.VadThreshold}|{model.Settings.InputGain}|{model.Settings.InputNoiseReduction}|" +
|
||||
$"{model.Settings.EventSounds}|{model.Settings.EventVolume}|{model.Settings.SpokenEvents}|{model.Settings.SelfTalkSounds}|{model.Settings.PushToTalkSound}|" +
|
||||
$"{model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts)}";
|
||||
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" };
|
||||
@@ -69,9 +81,19 @@ internal sealed class SettingsController : UITableViewController
|
||||
|
||||
internal sealed class AdvancedAudioController : UITableViewController
|
||||
{
|
||||
private readonly IosAudioRouter router = IosAudioRouter.Shared;
|
||||
internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped) { Title = "Advanced Audio"; router.Changed += () => TableView.ReloadData(); }
|
||||
private readonly IosAudioRouter router = IosAudioRouter.Shared; private readonly ListRefresher refresher;
|
||||
internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped)
|
||||
{
|
||||
Title = "Advanced Audio";
|
||||
refresher = new(this, Signature, handler => IosAudioRouter.Shared.Changed += handler, handler => IosAudioRouter.Shared.Changed -= handler);
|
||||
}
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "audio"); router.RefreshRoutes(); }
|
||||
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
|
||||
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
|
||||
private string Signature() =>
|
||||
$"{router.SelectedInputId}|{router.SelectedDataSourceId}|{router.SelectedPolarPattern}|{router.MicMode}|{router.CaptureChannels}|" +
|
||||
$"{router.BluetoothMode}|{router.UsesVoiceProcessing}|{router.AutomaticGainControl}|" +
|
||||
$"{string.Join(',', router.Inputs.Select(value => value.Name))}|{string.Join(',', router.Outputs.Select(value => value.Name))}";
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => 8;
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
|
||||
{
|
||||
|
||||
@@ -18,3 +18,41 @@ internal static class UiHelpers
|
||||
field.AccessibilityLabel = placeholder; return field;
|
||||
}
|
||||
}
|
||||
|
||||
// VoiceOver rebuilds its element tree from scratch on every UITableView.ReloadData. The model
|
||||
// notifies far more often than any list's content actually changes - the microphone level timer
|
||||
// alone ticks at 20 Hz - so reloading unconditionally re-announces the row under an exploring
|
||||
// finger and destroys the element a double tap was aimed at. Reload only when the rendered text
|
||||
// actually differs, and only while the view is on screen, so an off-screen or popped controller
|
||||
// stops reloading instead of holding a subscription for the lifetime of the connection.
|
||||
internal sealed class ListRefresher(UITableViewController owner, Func<string> signature, Action<Action> subscribe, Action<Action> unsubscribe)
|
||||
{
|
||||
// Null means nothing has been rendered yet. A signature is never null, so the first
|
||||
// Refresh after Start or Invalidate always reloads.
|
||||
private string? rendered;
|
||||
private bool observing;
|
||||
|
||||
internal void Start()
|
||||
{
|
||||
if (observing) return;
|
||||
observing = true; subscribe(Refresh); Refresh();
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
if (!observing) return;
|
||||
observing = false; unsubscribe(Refresh);
|
||||
}
|
||||
|
||||
// Forces the next Refresh to reload even when the signature is unchanged, for state the
|
||||
// signature cannot see.
|
||||
internal void Invalidate() => rendered = null;
|
||||
|
||||
internal void Refresh()
|
||||
{
|
||||
if (!observing || !owner.IsViewLoaded) return;
|
||||
string next = signature();
|
||||
if (next == rendered) return;
|
||||
rendered = next; owner.TableView.ReloadData();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user