feat(audio-rooms): M8 polish + M3/M9 foreground service
M8a — presence event reflects mic-mute state:
- AudioRoomStage's `publishPresence` now passes the broadcaster's
current mic state into the kind 10312 `muted` tag: `null` when not
broadcasting (no mic to be muted on), explicit `true` / `false` while
the speaker path is `Broadcasting`. Other clients can now render a
mute indicator on our avatar.
M8b — auto-reconnect with capped exponential backoff:
- AudioRoomViewModel detects `NestsListenerState.Failed` and schedules a
retry after 1s, 2s, 4s, ..., capped at 16s. Up to 3 attempts before
the UI stays in Failed for a manual retry.
- User-initiated `connect()` and `disconnect()` reset the retry counter
so manual recovery starts fresh.
M8c — iOS:
- Skipped: neither `:nestsClient` nor `:quic` declares an iOS target
yet, so there's no iosMain source set to populate. When iOS lands,
the audio capture/playback + transport actuals will need iOS impls
(and the speaker UI will need an iOS shell).
M3 + M9 — foreground service:
- New `AudioRoomForegroundService` (foregroundServiceType
`mediaPlayback|microphone`) anchors the process so audio keeps
playing with the screen off. Holds a partial wake-lock + media-style
notification; the notification's "Stop" action stops the service.
- Lifecycle wired in AudioRoomStage via:
* LaunchedEffect(isConnected, isBroadcasting) on the listener +
broadcast UI state — promotes to mediaPlayback+microphone type
when broadcasting starts (Android 14+ split foreground-type
permission requirement), falls back to mediaPlayback when only
listening, stops entirely when listener drops.
* DisposableEffect(Unit) for screen-exit cleanup.
- The service does NOT own the MoQ session / decoder / player — those
remain in the VM. Screen-off works; "navigate away keeps audio" would
require moving the audio stack into the service, which is a bigger
refactor outside the audio-rooms completion plan's scope.
- Strings: 6 new `audio_room_notification_*` keys.
- Manifest: declares the service with `mediaPlayback|microphone`
foreground type. RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE were
already declared.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
This commit is contained in:
+101
-2
@@ -109,6 +109,12 @@ class AudioRoomViewModel(
|
||||
private var requestedSpeakers: Set<String> = emptySet()
|
||||
private var closed = false
|
||||
|
||||
// Auto-reconnect state — incremented every time we observe Failed and
|
||||
// schedule a retry; reset to 0 on Connected or on a user-initiated
|
||||
// connect()/disconnect() so manual recovery starts fresh.
|
||||
private var autoRetryAttempts = 0
|
||||
private var autoRetryJob: Job? = null
|
||||
|
||||
// Speaker / publisher path
|
||||
private var speaker: NestsSpeaker? = null
|
||||
private var broadcastHandle: BroadcastHandle? = null
|
||||
@@ -133,6 +139,11 @@ class AudioRoomViewModel(
|
||||
val current = _uiState.value.connection
|
||||
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
|
||||
|
||||
// User-initiated connect — clear any pending auto-retry timer + counter.
|
||||
autoRetryJob?.cancel()
|
||||
autoRetryJob = null
|
||||
autoRetryAttempts = 0
|
||||
|
||||
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
|
||||
|
||||
connectJob =
|
||||
@@ -264,12 +275,17 @@ class AudioRoomViewModel(
|
||||
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
|
||||
fun disconnect() {
|
||||
if (closed) return
|
||||
autoRetryJob?.cancel()
|
||||
autoRetryJob = null
|
||||
autoRetryAttempts = 0
|
||||
teardownBroadcast(BroadcastUiState.Idle)
|
||||
teardown(targetState = ConnectionUiState.Idle)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
closed = true
|
||||
autoRetryJob?.cancel()
|
||||
autoRetryJob = null
|
||||
teardownBroadcast(BroadcastUiState.Idle)
|
||||
teardown(targetState = ConnectionUiState.Closed)
|
||||
super.onCleared()
|
||||
@@ -323,13 +339,87 @@ class AudioRoomViewModel(
|
||||
_uiState.update { ui ->
|
||||
ui.copy(connection = state.toUiState(ui.connection))
|
||||
}
|
||||
if (state is NestsListenerState.Connected) {
|
||||
reconcileSubscriptions()
|
||||
when (state) {
|
||||
is NestsListenerState.Connected -> {
|
||||
autoRetryAttempts = 0
|
||||
reconcileSubscriptions()
|
||||
}
|
||||
|
||||
is NestsListenerState.Failed -> {
|
||||
scheduleAutoRetry()
|
||||
}
|
||||
|
||||
else -> { /* no extra side effect */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-reconnect on listener Failed with capped exponential backoff.
|
||||
* The user can still tap Connect manually at any time; that resets the
|
||||
* retry counter via the `connect()` path.
|
||||
*/
|
||||
private fun scheduleAutoRetry() {
|
||||
if (closed) return
|
||||
if (autoRetryJob?.isActive == true) return
|
||||
if (autoRetryAttempts >= MAX_AUTO_RETRIES) return
|
||||
|
||||
val attempt = autoRetryAttempts
|
||||
autoRetryAttempts = attempt + 1
|
||||
val backoffMs = minOf(MAX_RETRY_BACKOFF_MS, INITIAL_RETRY_BACKOFF_MS shl attempt)
|
||||
autoRetryJob =
|
||||
viewModelScope.launch {
|
||||
delay(backoffMs)
|
||||
if (closed) return@launch
|
||||
if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
|
||||
// Drop the previous listener cleanly before starting a new
|
||||
// attempt. Reset state to Idle so the connect() guard passes.
|
||||
teardown(targetState = ConnectionUiState.Idle)
|
||||
connectInternal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal connect that bypasses the auto-retry counter reset — used by
|
||||
* [scheduleAutoRetry] so successive auto-retries continue to back off.
|
||||
*/
|
||||
private fun connectInternal() {
|
||||
if (closed) return
|
||||
val current = _uiState.value.connection
|
||||
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
|
||||
|
||||
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
|
||||
|
||||
connectJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val l =
|
||||
connector.connect(
|
||||
httpClient = httpClient,
|
||||
transport = transport,
|
||||
scope = viewModelScope,
|
||||
serviceBase = serviceBase,
|
||||
roomId = roomId,
|
||||
signer = signer,
|
||||
)
|
||||
if (closed) {
|
||||
runCatching { l.close() }
|
||||
return@launch
|
||||
}
|
||||
listener = l
|
||||
observeListenerState(l)
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (t: Throwable) {
|
||||
_uiState.update {
|
||||
it.copy(connection = ConnectionUiState.Failed(t.message ?: t::class.simpleName ?: "connect failed"))
|
||||
}
|
||||
scheduleAutoRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconcileSubscriptions() {
|
||||
val l = listener ?: return
|
||||
if (_uiState.value.connection !is ConnectionUiState.Connected) return
|
||||
@@ -588,6 +678,15 @@ sealed class BroadcastUiState {
|
||||
*/
|
||||
const val SPEAKING_TIMEOUT_MS: Long = 250L
|
||||
|
||||
/** Max number of auto-reconnect attempts after a Failed listener state. */
|
||||
private const val MAX_AUTO_RETRIES = 3
|
||||
|
||||
/** First auto-retry backoff in ms; doubles each subsequent attempt. */
|
||||
private const val INITIAL_RETRY_BACKOFF_MS = 1_000L
|
||||
|
||||
/** Cap on the auto-retry backoff so the wait stays human-acceptable. */
|
||||
private const val MAX_RETRY_BACKOFF_MS = 16_000L
|
||||
|
||||
/**
|
||||
* Indirection over the top-level `connectNestsListener` so tests can drive
|
||||
* a fake [NestsListener] directly without standing up an HTTP fake +
|
||||
|
||||
Reference in New Issue
Block a user