feat: audio room stage with NIP-53 presence (kind 10312)

Phase 2 of the Clubhouse/nests integration: when the live-activity
channel screen is opened on a kind 30312 MeetingSpaceEvent, render an
audio-room "stage" above the chat that shows host/speaker/audience
avatars and exposes hand-raise + mute toggles backed by NIP-53 kind
10312 presence events.

Quartz:
- New MutedTag (`["muted","1"|"0"]`) and `muted()` builder helper for
  MeetingRoomPresenceEvent.
- New MeetingRoomPresenceEvent.build() overload that accepts a 30312
  MeetingSpaceEvent root (matching the existing 30313 overload) and
  optionally encodes hand-raise + mute flags in one call.

Amethyst:
- AudioRoomStage composable: filters participants from the 30312 event
  into hosts / speakers / audience, renders avatars, and publishes
  presence on enter + every 30 s while composed (on dispose it pushes
  one final lowered-hand presence so peers drop us before timeout).
- ChannelView wires AudioRoomStage in next to ShowVideoStreaming; the
  latter is a no-op for non-30311 events so non-audio rooms are
  unaffected.

No audio is captured yet — the mic toggle is a Nostr-only signal
until Phase 3 brings the MoQ/WebTransport transport.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 02:25:06 +00:00
parent 1fd89b23e5
commit 0db80c66b8
6 changed files with 340 additions and 0 deletions
@@ -0,0 +1,258 @@
/*
* 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.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.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.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
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
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.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
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.
*/
@Composable
fun AudioRoomStage(
baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
) {
LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote ->
addressableNote ?: return@LoadAddressableNote
val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote
AudioRoomStageContent(event, accountViewModel)
}
}
@Composable
private fun AudioRoomStageContent(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
) {
val participants = remember(event) { event.participants() }
val hosts = remember(participants) { participants.filter { it.role.equals(ROLE.HOST.code, true) } }
val speakers = remember(participants) { participants.filter { it.role.equals(ROLE.SPEAKER.code, true) } }
val audience =
remember(participants) {
participants.filter {
!it.role.equals(ROLE.HOST.code, true) &&
!it.role.equals(ROLE.SPEAKER.code, true)
}
}
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)
while (isActive) {
delay(PRESENCE_REFRESH_MS)
publishPresence(account, event, handRaised, muted)
}
}
// Best-effort "leave" — re-publish a CLOSED 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) }
}
}
}
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
event.room()?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
)
}
event.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hosts.isNotEmpty() || speakers.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
people = hosts + speakers,
avatarSize = Size40dp,
accountViewModel = accountViewModel,
)
}
if (audience.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_audience),
people = audience,
avatarSize = Size35dp,
accountViewModel = accountViewModel,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(onClick = { handRaised = !handRaised }) {
Icon(
imageVector = if (handRaised) Icons.Filled.PanTool else Icons.Outlined.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.audio_room_lower_hand else R.string.audio_room_raise_hand,
),
)
}
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 StagePeopleRow(
label: String,
people: List<ParticipantTag>,
avatarSize: androidx.compose.ui.unit.Dp,
accountViewModel: AccountViewModel,
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyRow(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items = people, key = { it.pubKey }) {
ClickableUserPicture(
baseUserHex = it.pubKey,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
}
private const val PRESENCE_REFRESH_MS = 30_000L
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,
),
)
}
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomStage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
@@ -129,6 +130,7 @@ fun LiveActivityChannelView(
.weight(1f, true),
) {
ShowVideoStreaming(channel, accountViewModel)
AudioRoomStage(channel, accountViewModel)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView(
+6
View File
@@ -489,6 +489,12 @@
<string name="follow_packs">Follow Packs</string>
<string name="live_streams">Live Streams</string>
<string name="audio_rooms">Audio Rooms</string>
<string name="audio_room_stage">Stage</string>
<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="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>
@@ -31,8 +31,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -53,6 +55,8 @@ class MeetingRoomPresenceEvent(
fun handRaised() = tags.firstNotNullOfOrNull(HandRaisedTag::parse)
fun muted() = tags.firstNotNullOfOrNull(MutedTag::parse)
companion object Companion {
const val KIND = 10312
const val ALT = "Room Presence tag"
@@ -78,5 +82,27 @@ class MeetingRoomPresenceEvent(
}
initializer()
}
/**
* Convenience builder when the parent is a kind 30312 [MeetingSpaceEvent]
* (Clubhouse-style audio room). Mirrors the [MeetingRoomEvent] overload so
* a participant can publish presence + hand-raise + mute against an audio
* room without adapting to the meeting-room (kind 30313) variant.
*/
fun build(
root: MeetingSpaceEvent,
handRaised: Boolean? = null,
muted: Boolean? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MeetingRoomPresenceEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(root.room() ?: ALT)
roomMeeting(MeetingSpaceTag(root.address(), root.relays().firstOrNull()))
handRaised?.let { handRaised(it) }
muted?.let { muted(it) }
initializer()
}
}
}
@@ -26,9 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised))
fun TagArrayBuilder<MeetingRoomPresenceEvent>.muted(muted: Boolean) = addUnique(MutedTag.assemble(muted))
@@ -0,0 +1,45 @@
/*
* 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.quartz.nip53LiveActivities.presence.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Mute flag for kind 10312 presence events used by Clubhouse-style audio
* rooms (e.g. nostrnests/nests). Carried alongside the optional `hand` tag so
* other clients can render the speaker as muted without waiting for the audio
* transport to drop frames.
*/
class MutedTag {
companion object Companion {
const val TAG_NAME = "muted"
fun parse(tag: Array<String>): Boolean? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1] == "1"
}
fun assemble(muted: Boolean) = arrayOf(TAG_NAME, if (muted) "1" else "0")
}
}