fix(audio-rooms): hide audio-transport UI that has no backend yet

The branch already covers the Nostr-side of audio rooms (drawer
listing, 30312-event parsing, 10312 presence publishing, chat, hand-
raise). The audio-transport pieces (WebTransport + MoQ + Opus play-
back) are blocked on Phase 3b-2 (pure-Kotlin QUIC — see the plan in
`docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`).

The problem: the in-progress AudioRoomConnectionViewModel was
auto-calling connectNestsListener() on stage enter, and the stub
`KwikWebTransportFactory` throws `NotImplemented`. That surfaced as a
persistent red "Audio failed: WebTransport NotImplemented" chip
every time a user opened any audio room — scary, confusing, and
misleading.

Hiding the broken UI until the transport lands:

- Deleted `AudioRoomConnectionViewModel.kt`. The
  AudioRoomConnectionViewModel + ConnectionChip code is recoverable
  from git (commit `64b33674`) when Phase 3b-2 makes the underlying
  transport real.
- Removed the auto-connect LaunchedEffect + ConnectionChip render
  from AudioRoomStage.
- Removed the mute button. Pressing mute when we're not producing a
  mic stream would publish `["muted","0"|"1"]` on 10312 presence
  with no corresponding audio signal — misleading to peers. The
  builder-side support for the muted tag stays in
  MeetingRoomPresenceEvent (Quartz) for future use; the UI will
  re-add the button when audio capture actually runs.
- `publishPresence(..., muted = null)` so the muted tag is elided
  from the broadcast event entirely while the feature is hidden.
- Dropped the unused strings (audio_room_mute, audio_room_unmute,
  audio_room_conn_*) from strings.xml.

What still works on the branch (verified):
- Audio Rooms drawer entry lists kind 30312 rooms.
- Tap a room → live-activity channel screen with the audio-room
  stage overlay showing title, summary, host+speaker avatars,
  audience avatars.
- Kind 1311 chat (read + send) via the existing channel infra.
- Kind 10312 presence published on enter + every 30 s with the
  correct `a`-tag and `["hand","1"|"0"]` reflecting local state.
- Hand-raise button toggles the `hand` tag — browser clients +
  other NIP-53 peers can see the signal and hosts can promote.

What's hidden until Phase 3b-2 lands:
- WebTransport connection attempt, connection chip, mute button.
  Re-enabling them is additive: resurrect AudioRoomConnectionViewModel
  from git, paste three LaunchedEffect / DisposableEffect /
  ConnectionChip blocks back into AudioRoomStage, restore the five
  string resources, restore the mute button + the two-arg muted
  presence call. Zero protocol impact on anything else.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 13:38:35 +00:00
parent ac1e751bec
commit 3ca613d64b
3 changed files with 24 additions and 288 deletions
@@ -1,166 +0,0 @@
/*
* 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()
}
}
@@ -29,18 +29,12 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -55,8 +49,6 @@ 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
@@ -65,8 +57,6 @@ 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.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
@@ -80,10 +70,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].
*
* Phase 2 scope: shows host + speaker + audience avatars and lets the local user
* publish their kind 10312 presence (with hand-raise + mute flags) on relays.
* The mic toggle is a Nostr-only signal at this point — actual audio capture
* arrives in Phase 3 with the MoQ/WebTransport transport.
* Current (shipping) scope — pure Nostr, no audio transport:
* - 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.
*
* 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.
*/
@Composable
fun AudioRoomStage(
@@ -114,40 +114,28 @@ private fun AudioRoomStageContent(
}
var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) }
var muted by rememberSaveable(event.address().toValue()) { mutableStateOf(true) }
val scope = rememberCoroutineScope()
val account = accountViewModel.account
// Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed.
LaunchedEffect(event.address().toValue(), handRaised, muted) {
publishPresence(account, event, handRaised, muted)
LaunchedEffect(event.address().toValue(), handRaised) {
publishPresence(account, event, handRaised)
while (isActive) {
delay(PRESENCE_REFRESH_MS)
publishPresence(account, event, handRaised, muted)
publishPresence(account, event, handRaised)
}
}
// Best-effort "leave" — re-publish a CLOSED presence so peers see us drop sooner
// than the 30 s heartbeat would otherwise allow.
// Best-effort "leave" — re-publish a lowered-hand presence so peers see us
// drop sooner than the 30 s heartbeat would otherwise allow.
DisposableEffect(event.address().toValue()) {
onDispose {
scope.launch(Dispatchers.IO) {
runCatching { publishPresence(account, event, handRaised = false, muted = true) }
runCatching { publishPresence(account, event, handRaised = false) }
}
}
}
// 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),
@@ -168,11 +156,6 @@ 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),
@@ -205,87 +188,11 @@ private fun AudioRoomStageContent(
),
)
}
StdHorzSpacer
FilledIconButton(
onClick = { muted = !muted },
colors =
if (muted) {
IconButtonDefaults.filledIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
},
) {
Icon(
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription =
stringRes(
if (muted) R.string.audio_room_unmute else R.string.audio_room_mute,
),
)
}
}
}
}
}
@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,
@@ -320,14 +227,16 @@ private suspend fun publishPresence(
account: com.vitorpamplona.amethyst.model.Account,
event: MeetingSpaceEvent,
handRaised: Boolean,
muted: Boolean,
) {
runCatching {
account.signAndComputeBroadcast(
MeetingRoomPresenceEvent.build(
root = event,
handRaised = handRaised,
muted = muted,
// 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 = null,
),
)
}
-7
View File
@@ -493,13 +493,6 @@
<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_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>