feat(audio-rooms): M1 listener-only wire-up — Connect button + Connected chip + mute

Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.

UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
  above the existing hand-raise. State chip walks ResolvingRoom →
  OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
  reason inline with a Retry button. The 30312 `service` tag drives
  visibility — rooms hosted on non-nests servers show "Audio not
  available" and the rest of the stage UI continues to work.

ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
  `NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
  a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
  whenever the screen pushes a new speaker set via `updateSpeakers`.
  Audio-pipeline construction is injected via `decoderFactory` /
  `playerFactory` so a future desktop port can reuse the orchestration
  once Compose Desktop has WebTransport. Unit-tested with a fake
  `NestsListenerConnector` driving state transitions directly.

nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
  routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
  decode + network pipeline running so unmute is sample-accurate.

Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
  the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
  Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
  `:nestsClient`'s platform source sets and are bound by an
  Android-side `AudioRoomViewModelFactory` co-located with the screen.

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
This commit is contained in:
Claude
2026-04-26 02:54:43 +00:00
parent c24e676004
commit f9aa762d9b
8 changed files with 936 additions and 13 deletions
@@ -28,14 +28,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,16 +50,25 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
@@ -68,20 +82,20 @@ import kotlinx.coroutines.launch
* Clubhouse-style audio-room "stage" rendered in place of the video player when
* the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent].
*
* Current (shipping) scope — pure Nostr, no audio transport:
* Responsibilities (M1 = listener-only):
* - Displays host / speaker / audience avatars parsed from the 30312 `p` tags.
* - Publishes kind 10312 presence on enter and every 30 s while composed.
* - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event
* so a host on any NIP-53 client (browser, other Android, etc.) can see the
* request and promote the user to speaker.
* so a host on any NIP-53 client can see the request and promote.
* - **Connect button** opens the listener-side audio pipeline (HTTP →
* WebTransport over QUIC → MoQ → Opus decode → AudioTrack). On Connected,
* auto-subscribes to every host's speaker track.
* - **Mute toggle** silences the local audio device without halting the
* network pipeline so unmute is instant.
*
* Audio playback / capture + the "Audio connected" chip + the mute button live
* behind the WebTransport + QUIC work tracked in
* `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`. They're not
* exposed in the UI until that transport actually runs — a chip that always
* reads "Failed: NotImplemented" and a mute button with no mic to mute would
* mislead users. Re-enabling them is a small UI patch once
* `QuicWebTransportFactory.connect()` produces a real session.
* Speaker-side (mic capture + publish) is M5+ in the audio-rooms completion
* plan — `nestsClient/plans/2026-04-26-audio-rooms-completion.md`. Until then
* the presence event omits the `muted` tag (we have no mic to mute).
*/
@Composable
fun AudioRoomStage(
@@ -172,6 +186,12 @@ private fun AudioRoomStageContent(
)
}
AudioConnectionRow(
event = event,
hosts = hosts,
accountViewModel = accountViewModel,
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
@@ -222,6 +242,147 @@ private fun StagePeopleRow(
}
}
/**
* Connect / state-chip / mute row. Wires [AudioRoomViewModel] for the listener
* audio pipeline. Hidden when the room event has no `service` tag (legacy
* recordings or rooms hosted on non-nests servers — nothing to connect to).
*/
@Composable
private fun AudioConnectionRow(
event: MeetingSpaceEvent,
hosts: List<ParticipantTag>,
accountViewModel: AccountViewModel,
) {
val serviceBase = event.service()
val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || roomId.isBlank()) {
Text(
text = stringRes(R.string.audio_room_audio_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
return
}
val signer = accountViewModel.account.signer
val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" }
val viewModel: AudioRoomViewModel =
viewModel(
key = viewModelKey,
factory =
remember(viewModelKey, signer) {
AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
)
},
)
val hostKeys = remember(hosts) { hosts.map { it.pubKey }.toSet() }
LaunchedEffect(viewModel, hostKeys) {
viewModel.updateSpeakers(hostKeys)
}
val ui by viewModel.uiState.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val connection = ui.connection) {
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.audio_room_connect))
}
}
is ConnectionUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(connectingLabel(connection)) },
)
}
is ConnectionUiState.Connected -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.audio_room_connected)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.primary,
),
)
FilledTonalIconToggleButton(
checked = ui.isMuted,
onCheckedChange = { viewModel.setMuted(it) },
) {
Icon(
symbol = if (ui.isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription =
stringRes(
if (ui.isMuted) R.string.audio_room_unmute else R.string.audio_room_mute,
),
)
}
OutlinedButton(onClick = { viewModel.disconnect() }) {
Text(stringRes(R.string.audio_room_disconnect))
}
}
is ConnectionUiState.Failed -> {
Text(
text = stringRes(R.string.audio_room_audio_failed, connection.reason),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f, fill = false),
)
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.audio_room_connect))
}
}
}
}
}
@Composable
private fun connectingLabel(connection: ConnectionUiState.Connecting): String =
when (connection.step) {
ConnectionUiState.Step.ResolvingRoom -> stringRes(R.string.audio_room_connecting_resolving)
ConnectionUiState.Step.OpeningTransport -> stringRes(R.string.audio_room_connecting_transport)
ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.audio_room_connecting_handshake)
}
/**
* Android-side Factory for [AudioRoomViewModel]. The ViewModel itself lives
* in `commons/` so a future desktop port can reuse the orchestration once
* Compose Desktop has WebTransport; this factory binds it to the Android
* actuals (OkHttp HTTP, pure-Kotlin QUIC, MediaCodec Opus, AudioTrack).
*/
private class AudioRoomViewModelFactory(
private val signer: com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
private val serviceBase: String,
private val roomId: String,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
AudioRoomViewModel(
httpClient = OkHttpNestsClient(),
transport = QuicWebTransportFactory(),
decoderFactory = { MediaCodecOpusDecoder() },
playerFactory = { AudioTrackPlayer() },
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
) as T
}
private const val PRESENCE_REFRESH_MS = 30_000L
private suspend fun publishPresence(
@@ -234,9 +395,9 @@ private suspend fun publishPresence(
MeetingRoomPresenceEvent.build(
root = event,
handRaised = handRaised,
// muted tag intentionally omitted while audio transport is not
// shipping — we're not producing a mic stream so any muted
// value would be misleading.
// muted tag intentionally omitted — listener-only mute (M1)
// silences our speakers, not a mic we don't yet broadcast.
// Re-evaluate when M5 (publisher path) lands.
muted = null,
),
)
+11
View File
@@ -493,6 +493,17 @@
<string name="audio_room_audience">Audience</string>
<string name="audio_room_raise_hand">Raise hand</string>
<string name="audio_room_lower_hand">Lower hand</string>
<string name="audio_room_connect">Connect audio</string>
<string name="audio_room_disconnect">Disconnect</string>
<string name="audio_room_mute">Mute</string>
<string name="audio_room_unmute">Unmute</string>
<string name="audio_room_connecting">Connecting…</string>
<string name="audio_room_connecting_resolving">Resolving room</string>
<string name="audio_room_connecting_transport">Opening transport</string>
<string name="audio_room_connecting_handshake">Negotiating audio</string>
<string name="audio_room_connected">Audio connected</string>
<string name="audio_room_audio_failed">Audio failed: %1$s</string>
<string name="audio_room_audio_unavailable">Audio is not available for this room</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>
+5
View File
@@ -47,6 +47,11 @@ kotlin {
commonMain {
dependencies {
implementation(project(":quartz"))
// Audio-rooms ViewModel needs the listener orchestration + audio
// pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer
// interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals
// stay in :nestsClient's platform source sets.
implementation(project(":nestsClient"))
// Compose Multiplatform
implementation(libs.jetbrains.compose.ui)
@@ -0,0 +1,417 @@
/*
* 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.commons.viewmodels
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Per-screen state holder for a NIP-53 audio-room.
*
* Owns one [NestsListener] for the lifetime of the screen and one
* [AudioRoomPlayer] per active speaker subscription. The screen tells the
* VM which speakers it cares about via [updateSpeakers]; subscribe / play
* happen automatically once the listener is [NestsListenerState.Connected].
*
* Lifecycle:
* - Construction: idle. Nothing is on the wire until [connect] is called.
* - [connect]: launches the HTTP→WebTransport→MoQ handshake. Idempotent
* while connecting/connected.
* - [setMuted]: routes through to every player so the mute toggle is
* instant; the network keeps running so unmute has no extra latency.
* - [disconnect] / [onCleared]: cancels subscriptions, stops players,
* closes the listener. Idempotent.
*
* Audio-pipeline construction is injected via [decoderFactory] /
* [playerFactory] so commonMain doesn't have to know which platform's
* MediaCodec / AudioTrack is in play. M1 wires Android-only — desktop
* passes nothing here yet.
*/
@Stable
class AudioRoomViewModel(
private val httpClient: NestsClient,
private val transport: WebTransportFactory,
private val decoderFactory: () -> OpusDecoder,
private val playerFactory: () -> AudioPlayer,
private val signer: NostrSigner,
private val serviceBase: String,
private val roomId: String,
// Seam for tests — production code uses the default which delegates to
// the real `connectNestsListener`. Tests inject a fake that returns a
// listener whose state they can drive directly.
private val connector: NestsListenerConnector = DefaultNestsListenerConnector,
) : ViewModel() {
private val _uiState = MutableStateFlow(AudioRoomUiState())
val uiState: StateFlow<AudioRoomUiState> = _uiState.asStateFlow()
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
private var requestedSpeakers: Set<String> = emptySet()
private var closed = false
/** Push the latest known speaker set from the room event. */
fun updateSpeakers(speakerPubkeys: Set<String>) {
if (closed) return
requestedSpeakers = speakerPubkeys
if (_uiState.value.connection is ConnectionUiState.Connected) {
reconcileSubscriptions()
}
}
/**
* Kick off the HTTP → WebTransport → MoQ handshake. No-op if a connect
* attempt is already in flight or already connected.
*/
fun connect() {
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"))
}
}
}
}
fun setMuted(muted: Boolean) {
if (closed) return
_uiState.update { it.copy(isMuted = muted) }
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
}
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
fun disconnect() {
if (closed) return
teardown(targetState = ConnectionUiState.Idle)
}
override fun onCleared() {
closed = true
teardown(targetState = ConnectionUiState.Closed)
super.onCleared()
}
private fun observeListenerState(l: NestsListener) {
stateObserverJob?.cancel()
stateObserverJob =
viewModelScope.launch {
l.state.collect { state ->
_uiState.update { ui ->
ui.copy(connection = state.toUiState(ui.connection))
}
if (state is NestsListenerState.Connected) {
reconcileSubscriptions()
}
}
}
}
private fun reconcileSubscriptions() {
val l = listener ?: return
if (_uiState.value.connection !is ConnectionUiState.Connected) return
val toAdd = requestedSpeakers - activeSubscriptions.keys
val toRemove = activeSubscriptions.keys - requestedSpeakers
toRemove.forEach { pubkey ->
activeSubscriptions.remove(pubkey)?.let { closeSubscription(it) }
}
toAdd.forEach { pubkey ->
// Mark as pending immediately so concurrent reconciles don't
// double-subscribe; flip to active once the SUBSCRIBE_OK arrives.
val pending = ActiveSubscription.pending(pubkey)
activeSubscriptions[pubkey] = pending
viewModelScope.launch {
openSubscription(l, pubkey, pending)
}
}
publishActiveSpeakers()
}
/**
* Stop the per-speaker player synchronously (releases the audio device
* immediately) and fire-and-forget the MoQ UNSUBSCRIBE on the VM scope.
*/
private fun closeSubscription(slot: ActiveSubscription) {
val handle = slot.detach()
if (handle != null) {
viewModelScope.launch { runCatching { handle.unsubscribe() } }
}
}
private suspend fun openSubscription(
l: NestsListener,
pubkey: String,
slot: ActiveSubscription,
) {
if (closed || activeSubscriptions[pubkey] !== slot) return
try {
val handle = l.subscribeSpeaker(pubkey)
val decoder = decoderFactory()
val player = playerFactory()
val isMuted = _uiState.value.isMuted
val roomPlayer = AudioRoomPlayer(decoder, player, viewModelScope)
// Apply current mute state before play() opens the device so the
// first frame respects it.
player.setMutedSafe(isMuted)
roomPlayer.play(handle.objects, onError = { /* swallow per-packet decoder errors */ })
slot.attach(handle, roomPlayer, player)
publishActiveSpeakers()
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
// Roll back the slot we reserved so a future reconcile can retry.
if (activeSubscriptions[pubkey] === slot) {
activeSubscriptions.remove(pubkey)
publishActiveSpeakers()
}
}
}
private fun teardown(targetState: ConnectionUiState) {
connectJob?.cancel()
connectJob = null
stateObserverJob?.cancel()
stateObserverJob = null
// Stop players synchronously — unsubscribe happens implicitly when
// the listener.close() below tears down the MoQ session.
activeSubscriptions.values.forEach { it.detach() }
activeSubscriptions.clear()
val l = listener
listener = null
if (l != null) {
// Closing the listener is suspending; fire-and-forget on the VM
// scope is fine — even if the scope is cancelled (onCleared), the
// underlying transport's own cleanup runs.
viewModelScope.launch { runCatching { l.close() } }
}
_uiState.update { it.copy(connection = targetState, activeSpeakers = persistentSetOf()) }
}
private fun publishActiveSpeakers() {
val active =
activeSubscriptions
.filterValues { it.isPlaying }
.keys
.toPersistentSet()
_uiState.update { it.copy(activeSpeakers = active) }
}
private class ActiveSubscription private constructor(
val pubkey: String,
) {
private var handle: SubscribeHandle? = null
private var roomPlayer: AudioRoomPlayer? = null
var player: AudioPlayer? = null
private set
var isPlaying: Boolean = false
private set
fun attach(
handle: SubscribeHandle,
roomPlayer: AudioRoomPlayer,
player: AudioPlayer,
) {
this.handle = handle
this.roomPlayer = roomPlayer
this.player = player
this.isPlaying = true
}
/**
* Stop the player + decoder synchronously and return the
* [SubscribeHandle] (if any) so the caller can fire-and-forget
* UNSUBSCRIBE on its own coroutine scope.
*/
fun detach(): SubscribeHandle? {
isPlaying = false
roomPlayer?.let { runCatching { it.stop() } }
val h = handle
roomPlayer = null
handle = null
player = null
return h
}
companion object {
fun pending(pubkey: String) = ActiveSubscription(pubkey)
}
}
// Platform-specific Factory lives in `amethyst/.../audiorooms/room/`,
// not commonMain — the lifecycle KMP `ViewModelProvider.Factory`
// signature has been migrating between releases; a thin Android-side
// factory keeps that churn out of shared code.
}
/**
* Map [NestsListenerState] (from the transport library) onto the small set of
* UI-level states the screen actually needs to render. We collapse the three
* `Connecting` substeps into one enum so the chip can show a single
* progress message; advanced UI can branch on [ConnectionUiState.Step]
* later if it cares.
*/
private fun NestsListenerState.toUiState(previous: ConnectionUiState): ConnectionUiState =
when (this) {
// The transport library starts in Idle, but the VM has already
// shown "Connecting → ResolvingRoom" by the time observation starts;
// don't regress the UI back to Idle on the very first emission.
NestsListenerState.Idle -> {
if (previous is ConnectionUiState.Connecting) previous else ConnectionUiState.Idle
}
is NestsListenerState.Connecting -> {
ConnectionUiState.Connecting(
step =
when (step) {
NestsListenerState.Connecting.ConnectStep.ResolvingRoom -> ConnectionUiState.Step.ResolvingRoom
NestsListenerState.Connecting.ConnectStep.OpeningTransport -> ConnectionUiState.Step.OpeningTransport
NestsListenerState.Connecting.ConnectStep.MoqHandshake -> ConnectionUiState.Step.MoqHandshake
},
)
}
is NestsListenerState.Connected -> {
ConnectionUiState.Connected
}
is NestsListenerState.Failed -> {
ConnectionUiState.Failed(reason)
}
NestsListenerState.Closed -> {
ConnectionUiState.Closed
}
}
private fun AudioPlayer.setMutedSafe(muted: Boolean) {
runCatching { setMuted(muted) }
}
@Immutable
data class AudioRoomUiState(
val connection: ConnectionUiState = ConnectionUiState.Idle,
val isMuted: Boolean = false,
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
)
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake +
* WebTransport fake.
*/
fun interface NestsListenerConnector {
suspend fun connect(
httpClient: NestsClient,
transport: WebTransportFactory,
scope: CoroutineScope,
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsListener
}
private val DefaultNestsListenerConnector =
NestsListenerConnector { httpClient, transport, scope, serviceBase, roomId, signer ->
connectNestsListener(
httpClient = httpClient,
transport = transport,
scope = scope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
)
}
@Immutable
sealed class ConnectionUiState {
data object Idle : ConnectionUiState()
data class Connecting(
val step: Step,
) : ConnectionUiState()
data object Connected : ConnectionUiState()
data class Failed(
val reason: String,
) : ConnectionUiState()
data object Closed : ConnectionUiState()
enum class Step {
ResolvingRoom,
OpeningTransport,
MoqHandshake,
}
}
@@ -0,0 +1,295 @@
/*
* 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.commons.viewmodels
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsException
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsRoomInfo
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.nestsclient.transport.WebTransportSession
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Drives [AudioRoomViewModel] with a fake [NestsListenerConnector] and
* verifies its state-flow transitions:
* - connect() shows Connecting before the underlying connector returns
* - listener emits Connected uiState collapses to Connected
* - listener emits Failed uiState becomes Failed(reason)
* - setMuted updates uiState and propagates to active subscriptions
* - disconnect() returns to Idle and tears down the listener
*
* Subscribe-side (subscribeSpeaker AudioRoomPlayer.play) is exercised via
* the existing nestsClient tests; the cross-module audio glue would need
* an internal SubscribeHandle constructor that's intentionally not visible
* here.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AudioRoomViewModelTest {
@BeforeTest
fun setupMainDispatcher() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun resetMainDispatcher() {
Dispatchers.resetMain()
}
@Test
fun connectShowsConnectingThenConnected() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
// Initial UI shows the resolving substep before the connector
// (suspend) has a chance to actually return — that's the
// ResolvingRoom optimistic enter we set on connect().
assertIs<ConnectionUiState.Connecting>(vm.uiState.value.connection)
// Connector resolves with the listener; it's still Idle until we
// emit something. Drive it Connected directly.
fakeListener.emit(NestsListenerState.Connected(roomInfo = ROOM_INFO, negotiatedMoqVersion = 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
}
@Test
fun listenerFailedSurfacesAsUiFailed() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Failed("relay rejected", IllegalStateException("oops")))
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Failed>(ui)
assertEquals("relay rejected", ui.reason)
}
@Test
fun connectorThrowsBecomesUiFailed() =
runTest {
val vm = newViewModel { throw NestsException("dns blew up") }
vm.connect()
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Failed>(ui)
assertEquals("dns blew up", ui.reason)
}
@Test
fun setMutedFlipsUiStateAndIsRetained() =
runTest {
val vm = newViewModel { FakeNestsListener() }
assertFalse(vm.uiState.value.isMuted)
vm.setMuted(true)
assertTrue(vm.uiState.value.isMuted)
vm.setMuted(false)
assertFalse(vm.uiState.value.isMuted)
}
@Test
fun connectIsIdempotentWhileConnecting() =
runTest {
val fakeListener = FakeNestsListener()
var connectCalls = 0
val vm =
newViewModel {
connectCalls++
fakeListener
}
vm.connect()
vm.connect()
vm.connect()
assertEquals(1, connectCalls, "connect() should not re-fire while a Connecting flow is in flight")
}
@Test
fun disconnectReturnsToIdleAndClosesListener() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
vm.disconnect()
assertEquals(ConnectionUiState.Idle, vm.uiState.value.connection)
assertTrue(fakeListener.closeCallCount > 0, "listener.close() should run on disconnect()")
}
@Test
fun connectingStepMapsThroughToUiStep() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(
NestsListenerState.Connecting(
NestsListenerState.Connecting.ConnectStep.OpeningTransport,
),
)
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Connecting>(ui)
assertEquals(ConnectionUiState.Step.OpeningTransport, ui.step)
}
private fun newViewModel(connect: suspend (CoroutineScope) -> NestsListener): AudioRoomViewModel =
AudioRoomViewModel(
httpClient = NoopNestsClient,
transport = NoopWebTransportFactory,
decoderFactory = { NoopOpusDecoder },
playerFactory = { NoopAudioPlayer() },
signer = NoopSigner,
serviceBase = "https://example.test/api/v1/nests",
roomId = "test-room",
connector =
NestsListenerConnector { _, _, scope, _, _, _ ->
connect(scope)
},
)
private class FakeNestsListener : NestsListener {
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
override val state: StateFlow<NestsListenerState> = mutable.asStateFlow()
var closeCallCount: Int = 0
private set
fun emit(s: NestsListenerState) {
mutable.value = s
}
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("subscribeSpeaker not exercised in these tests — see AudioRoomPlayerTest in :nestsClient")
override suspend fun close() {
closeCallCount++
mutable.value = NestsListenerState.Closed
}
}
private object NoopNestsClient : NestsClient {
override suspend fun resolveRoom(
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsRoomInfo = error("resolveRoom not used (connector seam bypasses it)")
}
private object NoopWebTransportFactory : WebTransportFactory {
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession = error("connect not used (connector seam bypasses it)")
}
private object NoopOpusDecoder : OpusDecoder {
override fun decode(opusPacket: ByteArray): ShortArray = ShortArray(0)
override fun release() {}
}
private class NoopAudioPlayer : AudioPlayer {
override fun start() {}
override suspend fun enqueue(pcm: ShortArray) {}
override fun setMuted(muted: Boolean) {}
override fun stop() {}
}
private object NoopSigner : NostrSigner(pubKey = "0".repeat(64)) {
override fun isWriteable(): Boolean = false
override fun hasForegroundSupport(): Boolean = false
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T = error("sign not used in this VM test")
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: String,
): String = error("not used")
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: String,
): String = error("not used")
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: String,
): String = error("not used")
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: String,
): String = error("not used")
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used")
override suspend fun deriveKey(nonce: String): String = error("not used")
}
companion object {
private val ROOM_INFO = NestsRoomInfo(endpoint = "https://relay.example.test/moq")
}
}
@@ -41,6 +41,7 @@ class AudioTrackPlayer(
private val contentType: Int = AudioAttributes.CONTENT_TYPE_SPEECH,
) : AudioPlayer {
private var track: AudioTrack? = null
private var muted: Boolean = false
override fun start() {
if (track != null) return
@@ -104,6 +105,7 @@ class AudioTrackPlayer(
t,
)
}
applyMuteVolume(newTrack, muted)
track = newTrack
}
@@ -122,6 +124,11 @@ class AudioTrackPlayer(
}
}
override fun setMuted(muted: Boolean) {
this.muted = muted
track?.let { applyMuteVolume(it, muted) }
}
override fun stop() {
val t = track ?: return
track = null
@@ -133,4 +140,14 @@ class AudioTrackPlayer(
@Suppress("unused")
val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation
private fun applyMuteVolume(
track: AudioTrack,
muted: Boolean,
) {
// setVolume is preferred over pause(): it keeps the streaming pipeline
// running so unmute is sample-accurate and there's no AudioTrack-restart
// glitch. AudioTrack default gain is 1.0 (RFC: AudioTrack#setVolume).
runCatching { track.setVolume(if (muted) 0f else 1f) }
}
}
@@ -110,6 +110,17 @@ interface AudioPlayer {
*/
suspend fun enqueue(pcm: ShortArray)
/**
* Toggle output silence without halting the underlying decode/network
* pipeline. The producer keeps pushing PCM into [enqueue]; the device
* just stops emitting sound. Default is unmuted.
*
* Used so the audio-room UI mute button is instant nothing to
* re-handshake on unmute. Setting before [start] is allowed; the value
* is applied when the device opens.
*/
fun setMuted(muted: Boolean)
/** Stop playback and release resources. After this, the player is unusable. */
fun stop()
}
@@ -206,6 +206,8 @@ class AudioRoomPlayerTest {
private set
val stopped: Boolean get() = stopCount > 0
val queued = mutableListOf<ShortArray>()
var muted: Boolean = false
private set
override fun start() {
started = true
@@ -215,6 +217,10 @@ class AudioRoomPlayerTest {
queued.add(pcm)
}
override fun setMuted(muted: Boolean) {
this.muted = muted
}
override fun stop() {
stopCount++
}