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:
+258
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+45
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user