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