Files
voice-cat/clients/apple/VoiceCat.iOS/ServerListController.cs
T
Talon 0b81b81c0c
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run
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.
2026-09-25 17:00:25 +02:00

79 lines
5.4 KiB
C#

using VoiceCat.Core;
using UIKit;
namespace VoiceCat.iOS;
internal sealed class ServerListController : UITableViewController
{
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));
}
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)
{
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);
}
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); }
}
}