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.
387 lines
21 KiB
C#
387 lines
21 KiB
C#
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, uint SenderId, uint PeerUserId, string Sender, string Peer, 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;
|
|
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;
|
|
internal IReadOnlyList<ActivityEntry> Activity => activity;
|
|
internal IosSettings Settings => settings;
|
|
internal VoiceCatClient? Client => client;
|
|
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
|
|
internal bool SupportsAdaptivePacketLoss => client?.SupportsAdaptivePacketLoss == true;
|
|
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.ResumeForeground();
|
|
}
|
|
|
|
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();
|
|
// The AVAudioSourceNode render callback drives Audio.RunCycle and the 20 ms capture
|
|
// handoff; a sleep-paced audio worker stalls when iOS coalesces backgrounded wakeups and
|
|
// the call glitches after several minutes in the background.
|
|
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath, deviceClockedAudio: true);
|
|
next.ConnectionStateChanged += state =>
|
|
{
|
|
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
|
|
Console.Error.WriteLine($"VoiceCat control connection failed: {failure}");
|
|
if (state == ClientConnectionState.Disconnected)
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
|
|
{
|
|
try { await HandleConnectionLostAsync(next); }
|
|
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect cleanup failed: {exception}"); }
|
|
});
|
|
};
|
|
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(_ =>
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(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;
|
|
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
|
|
await next.DisposeAsync();
|
|
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);
|
|
bool privateMessage = text.Scope == TextScope.TextPrivate;
|
|
uint peerUserId = privateMessage ? text.SenderId == SelfUserId ? text.TargetId : text.SenderId : 0;
|
|
User? peer = privateMessage ? owner.Users.FirstOrDefault(user => user.Id == peerUserId) : null;
|
|
messages.Add(new(DateTime.Now, text.SenderId, peerUserId, sender?.Nickname ?? $"User {text.SenderId}", peer?.Nickname ?? $"User {peerUserId}", text.Body, privateMessage));
|
|
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
|
|
{
|
|
await HandleConnectionLostAsync(owner);
|
|
}
|
|
}
|
|
|
|
private async Task HandleConnectionLostAsync(VoiceCatClient owner)
|
|
{
|
|
if (!ReferenceEquals(client, owner) || IsConnecting || explicitDisconnect) return;
|
|
// A failed TLS reader changes State but leaves the event channel open until disposal.
|
|
// Handle that state change directly so a Wi-Fi transition cannot strand this session.
|
|
CaptureRestoreState(owner);
|
|
client = null; Status = "Connection lost";
|
|
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}"); }
|
|
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); 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(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");
|
|
}
|
|
|
|
private void ScheduleReconnect()
|
|
{
|
|
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
|
|
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30);
|
|
CancellationToken token = lifetime?.Token ?? default;
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try { await Task.Delay(TimeSpan.FromSeconds(delay), token); }
|
|
catch (OperationCanceledException) { return; }
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
|
|
{
|
|
if (token.IsCancellationRequested || explicitDisconnect) return;
|
|
try { await ConnectAsync(profile, restoring: true); }
|
|
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect failed: {exception}"); }
|
|
});
|
|
});
|
|
}
|
|
|
|
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 || owner.State != ClientConnectionState.Connected || stream == 0) return;
|
|
(float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level;
|
|
if (++diagnosticPolls >= 20)
|
|
{
|
|
diagnosticPolls = 0;
|
|
LocalAudioDiagnostics audio = owner.Audio.GetLocalDiagnostics(stream);
|
|
Console.Error.WriteLine($"VC_AUDIO {IosAudioEngine.Shared.CaptureDiagnostics()} cycles={audio.Cycles} starved={audio.StarvedCycles} " +
|
|
$"encoded={audio.EncodedPackets} packetDrops={audio.RejectedPackets} queued={audio.BufferedFrames}");
|
|
}
|
|
if (talking != lastTalking)
|
|
{
|
|
try { owner.PublishStreamState(stream, talking); }
|
|
catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; }
|
|
lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
|
|
}
|
|
// 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)
|
|
{
|
|
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(() =>
|
|
{
|
|
// Presenting either system picker can replace or interrupt the app's audio session.
|
|
// Rebuild after the producer becomes active so playback and an existing mic tap are
|
|
// attached to the session that will remain in use for the broadcast.
|
|
if (ScreenSharing) IosAudioRouter.Shared.Recover("screen sharing started");
|
|
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" : "")}");
|
|
}
|
|
|
|
// `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;
|
|
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(); }
|
|
}
|
|
}
|