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:
@@ -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); } }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user