From 3ca613d64b0b76734ff86d1f284156bf5075ce4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:38:35 +0000 Subject: [PATCH] fix(audio-rooms): hide audio-transport UI that has no backend yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../room/AudioRoomConnectionViewModel.kt | 166 ------------------ .../audiorooms/room/AudioRoomStage.kt | 139 +++------------ amethyst/src/main/res/values/strings.xml | 7 - 3 files changed, 24 insertions(+), 288 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt deleted file mode 100644 index 2ee98aa1a..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt +++ /dev/null @@ -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.Idle) - val state: StateFlow = _state.asStateFlow() - - private var listener: NestsListener? = null - private val playersBySpeaker = LinkedHashMap() - 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() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt index 22d912dff..048dee14e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -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, ), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8618d0a6f..a87b2df76 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -493,13 +493,6 @@ Audience Raise hand Lower hand - Mute - Unmute - Audio not connected — tap to retry - Connecting to audio (%1$s) - Audio connected - Audio failed: %1$s - Audio session closed Videos Articles Private Bookmarks