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:
@@ -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
|
||||
|
||||
|
||||
+166
@@ -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()
|
||||
}
|
||||
}
|
||||
+76
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user