feat: AudioRoomConnectionViewModel + connection chip in stage

Phase 3d-3 of the Clubhouse/nests integration. Wires the
nestsClient.connectNestsListener() facade into AudioRoomStage via a
dedicated Android ViewModel, and surfaces the listener's StateFlow
through a small assist chip so the user sees connection progress and
failure modes.

amethyst/audiorooms/room:
- New `AudioRoomConnectionViewModel` (Android `ViewModel`):
  * Owns one `NestsListener` + one `AudioRoomPlayer` per host/speaker.
  * `connect(event, signer)` resolves the room HTTP-side, opens
    WebTransport, runs MoQ SETUP, then subscribes to every host +
    speaker pubkey from the 30312 event and wires their Opus stream
    through `MediaCodecOpusDecoder` -> `AudioTrackPlayer`.
  * `disconnect()` is idempotent, also called from `onCleared()`.
  * Mirrors the underlying NestsListener.state into its own
    StateFlow so callers observe one source.
  * Audience members are skipped — they don't publish audio.
- `AudioRoomStage` now mounts the ViewModel keyed by the room
  address, auto-calls `connect()` in a LaunchedEffect, and
  disconnects in a DisposableEffect.
- New `ConnectionChip` composable renders the listener state with
  retry-on-tap for Idle / Failed / Closed, and color-codes Connected
  (primary) and Failed (error) states.

amethyst:
- Added `:nestsClient` to the project's dependencies.

res:
- New strings for the five connection-chip states.

Behavior today: on opening an audio-room, the chip will report
`Connecting (OpeningTransport)` then immediately
`Failed: WebTransport NotImplemented: ...` because the Kwik handshake
in `KwikWebTransportFactory` is the next phase. Tapping the chip
retries — same outcome until 3b-2 ships. Everything else (presence,
chat, hand-raise, mute) is unaffected.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 07:29:42 +00:00
parent c1355f1dd8
commit 64b3367472
4 changed files with 248 additions and 0 deletions
+1
View File
@@ -247,6 +247,7 @@ dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':ammolite')
implementation project(path: ':nestsClient')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer
import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.transport.KwikWebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Audio-pipeline owner for one open audio-room screen. Bridges the Compose
* stage UI to the [NestsListener] facade in `nestsClient`.
*
* The ViewModel is intentionally Android-only (lives in `amethyst/`) — it
* needs `viewModelScope`, the `MediaCodecOpusDecoder`, and the
* `AudioTrackPlayer`, none of which exist in commons. It is a thin shell
* that owns lifetime; all the real work happens in the `nestsClient` module.
*
* Lifecycle:
* - `connect(event, signer)` resolves the room HTTP-side, opens the
* WebTransport (currently throws NotImplemented from the Kwik stub —
* Phase 3b-2), runs the MoQ handshake, then subscribes to every speaker
* listed in the 30312 event and pipes their Opus frames through one
* [AudioRoomPlayer] each.
* - `disconnect()` tears down all per-speaker players and closes the
* listener. Idempotent, also called from `onCleared()`.
*/
class AudioRoomConnectionViewModel : ViewModel() {
private val _state = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
val state: StateFlow<NestsListenerState> = _state.asStateFlow()
private var listener: NestsListener? = null
private val playersBySpeaker = LinkedHashMap<String, AudioRoomPlayer>()
private var connectJob: Job? = null
/**
* Resolve the room URL and start playing every host/speaker track. Re-entrant
* calls cancel any in-flight connect and start fresh — typically only useful
* after a [disconnect] / failure.
*/
fun connect(
event: MeetingSpaceEvent,
signer: NostrSigner,
) {
connectJob?.cancel()
connectJob =
viewModelScope.launch(Dispatchers.IO) {
disconnectInternal()
_state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom)
val service = event.service()
if (service.isNullOrBlank()) {
_state.value =
NestsListenerState.Failed(
"Room has no `service` URL — cannot resolve a nests endpoint",
)
return@launch
}
val l =
com.vitorpamplona.nestsclient
.connectNestsListener(
httpClient = OkHttpNestsClient(),
transport = KwikWebTransportFactory(),
scope = viewModelScope,
serviceBase = service,
roomId = event.dTag(),
signer = signer,
)
listener = l
// Mirror the underlying listener's state so consumers only need
// to observe one StateFlow.
viewModelScope.launch { l.state.collect { _state.value = it } }
if (l.state.value !is NestsListenerState.Connected) {
return@launch
}
// Subscribe to every host + speaker. Audience members don't
// publish audio; ignore them.
val speakerKeys =
event
.participants()
.filter { it.role.equals(ROLE.HOST.code, true) || it.role.equals(ROLE.SPEAKER.code, true) }
.map { it.pubKey }
.distinct()
for (pubkey in speakerKeys) {
runCatching {
val handle = l.subscribeSpeaker(pubkey)
val player =
AudioRoomPlayer(
decoder = MediaCodecOpusDecoder(),
player = AudioTrackPlayer(),
scope = viewModelScope,
)
player.play(handle.objects) { /* per-speaker decode errors swallowed */ }
playersBySpeaker[pubkey] = player
}
}
}
}
/** Stop playback and tear down the listener. Idempotent. */
fun disconnect() {
connectJob?.cancel()
connectJob = null
viewModelScope.launch(Dispatchers.IO) { disconnectInternal() }
}
private suspend fun disconnectInternal() {
for ((_, p) in playersBySpeaker) runCatching { p.stop() }
playersBySpeaker.clear()
runCatching { listener?.close() }
listener = null
// Leave _state alone if it's already Closed; otherwise reset to Idle so
// the UI's "tap to retry" path is available.
if (_state.value !is NestsListenerState.Closed) {
_state.value = NestsListenerState.Idle
}
}
override fun onCleared() {
// Best-effort sync teardown — viewModelScope is being cancelled around us,
// so any suspending listener.close() in disconnectInternal() may not run.
runCatching {
for ((_, p) in playersBySpeaker) p.stop()
playersBySpeaker.clear()
}
super.onCleared()
}
}
@@ -33,6 +33,8 @@ import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.outlined.PanTool
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledIconButton
@@ -53,6 +55,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -62,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
@@ -132,6 +137,17 @@ private fun AudioRoomStageContent(
}
}
// Audio listener owner. Auto-connects on enter, tears down on dispose.
val connectionVm: AudioRoomConnectionViewModel =
viewModel(key = "AudioRoom-${event.address().toValue()}")
val connectionState by connectionVm.state.collectAsStateWithLifecycle()
LaunchedEffect(event.address().toValue()) {
connectionVm.connect(event, accountViewModel.account.signer)
}
DisposableEffect(event.address().toValue()) {
onDispose { connectionVm.disconnect() }
}
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
@@ -152,6 +168,11 @@ private fun AudioRoomStageContent(
)
}
ConnectionChip(
state = connectionState,
onRetry = { connectionVm.connect(event, accountViewModel.account.signer) },
)
if (hosts.isNotEmpty() || speakers.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
@@ -210,6 +231,61 @@ private fun AudioRoomStageContent(
}
}
@Composable
private fun ConnectionChip(
state: NestsListenerState,
onRetry: () -> Unit,
) {
val (label, color, clickable) =
when (state) {
is NestsListenerState.Idle -> {
Triple(
stringRes(R.string.audio_room_conn_idle),
MaterialTheme.colorScheme.surface,
true,
)
}
is NestsListenerState.Connecting -> {
Triple(
stringRes(R.string.audio_room_conn_connecting, state.step.name),
MaterialTheme.colorScheme.surface,
false,
)
}
is NestsListenerState.Connected -> {
Triple(
stringRes(R.string.audio_room_conn_connected),
MaterialTheme.colorScheme.primaryContainer,
false,
)
}
is NestsListenerState.Failed -> {
Triple(
stringRes(R.string.audio_room_conn_failed, state.reason),
MaterialTheme.colorScheme.errorContainer,
true,
)
}
is NestsListenerState.Closed -> {
Triple(
stringRes(R.string.audio_room_conn_closed),
MaterialTheme.colorScheme.surface,
true,
)
}
}
AssistChip(
modifier = Modifier.padding(top = 8.dp),
onClick = { if (clickable) onRetry() },
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
colors = AssistChipDefaults.assistChipColors(containerColor = color),
)
}
@Composable
private fun StagePeopleRow(
label: String,
+5
View File
@@ -495,6 +495,11 @@
<string name="audio_room_lower_hand">Lower hand</string>
<string name="audio_room_mute">Mute</string>
<string name="audio_room_unmute">Unmute</string>
<string name="audio_room_conn_idle">Audio not connected — tap to retry</string>
<string name="audio_room_conn_connecting">Connecting to audio (%1$s)</string>
<string name="audio_room_conn_connected">Audio connected</string>
<string name="audio_room_conn_failed">Audio failed: %1$s</string>
<string name="audio_room_conn_closed">Audio session closed</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>