feat(audio-rooms): consume moq-lite catalog metadata in VM
The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:
* RoomSpeakerCatalog data class (commons) — kotlinx.serialization
parser for moq-lite's `catalog.json` payload (version, audio
track list with codec / sample_rate / channel_count / bitrate).
Forward-compat: ignoreUnknownKeys + nullable fields tolerate
new keys and partial publishers.
* AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
RoomSpeakerCatalog>> populated lazily as per-speaker
subscriptions land. Catalog fetch piggybacks on openSubscription
via subscribeCatalog — best-effort, doesn't gate audio.
* Participant context sheet renders a single-line summary
("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
available.
* Enabled kotlinx.serialization plugin on :commons (was already
a transitive dep, just not wired for @Serializable codegen).
Tests:
* RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
describe() formatting (codec uppercased, kHz / channel
short-formed), all-null fallback, unknown-key tolerance,
garbage-bytes fallback, and empty-audio-list.
This commit is contained in:
+2
@@ -208,12 +208,14 @@ internal fun AudioRoomFullScreen(
|
||||
reactionsByPubkey = reactionsByPubkey,
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
)
|
||||
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
||||
hostMenuTarget?.let { target ->
|
||||
ParticipantHostActionsSheet(
|
||||
target = target,
|
||||
event = event,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { hostMenuTarget = null },
|
||||
catalog = speakerCatalogs[target],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -83,6 +83,7 @@ internal fun ParticipantHostActionsSheet(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
isLocalUserHost: Boolean = accountViewModel.account.signer.pubKey == event.pubKey,
|
||||
catalog: com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -121,6 +122,17 @@ internal fun ParticipantHostActionsSheet(
|
||||
text = target.take(8) + "…",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
// moq-lite catalog summary, when the speaker has published
|
||||
// one. Tells the audience what codec / sample rate / channel
|
||||
// count this broadcast carries — visible cue that the seat
|
||||
// is actually a live audio source vs a silent stage slot.
|
||||
catalog?.primaryAudio()?.describe()?.let { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// View Profile — AudioRoomActivity is a separate Android
|
||||
// Activity without its own nav stack, so the deep-link
|
||||
|
||||
@@ -6,6 +6,7 @@ plugins {
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
+44
@@ -159,6 +159,18 @@ class AudioRoomViewModel(
|
||||
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
|
||||
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
|
||||
|
||||
/**
|
||||
* moq-lite `catalog.json` payload per speaker pubkey. Populated
|
||||
* lazily as the per-speaker subscriptions land — listeners
|
||||
* without catalog support (IETF reference path) leave this map
|
||||
* empty. The participant context sheet reads this to surface
|
||||
* codec / sample-rate info; future commits could use it for
|
||||
* "speaker is broadcasting" indicators independent of the
|
||||
* actively-emitting `speakingNow` set.
|
||||
*/
|
||||
private val _speakerCatalogs = MutableStateFlow<Map<String, RoomSpeakerCatalog>>(emptyMap())
|
||||
val speakerCatalogs: StateFlow<Map<String, RoomSpeakerCatalog>> = _speakerCatalogs.asStateFlow()
|
||||
|
||||
/**
|
||||
* `true` once the local user has been kicked (#5) — the platform
|
||||
* layer flips this on a valid kind-4312 from a host/moderator and
|
||||
@@ -616,6 +628,9 @@ class AudioRoomViewModel(
|
||||
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
|
||||
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
|
||||
}
|
||||
if (_speakerCatalogs.value.containsKey(slot.pubkey)) {
|
||||
_speakerCatalogs.update { it - slot.pubkey }
|
||||
}
|
||||
if (roomPlayer != null || handle != null) {
|
||||
viewModelScope.launch {
|
||||
roomPlayer?.runCatching { stop() }
|
||||
@@ -624,6 +639,32 @@ class AudioRoomViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the speaker's `catalog.json` track in the background, parse
|
||||
* the first frame, and stash it in [speakerCatalogs]. Best-effort —
|
||||
* any failure (IETF listener throws UnsupportedOperationException,
|
||||
* the publisher doesn't publish a catalog, the JSON is malformed)
|
||||
* is silent. The catalog channel uses the same re-issuing pump
|
||||
* the audio path does, so it survives reconnects.
|
||||
*/
|
||||
private fun fetchSpeakerCatalog(
|
||||
l: NestsListener,
|
||||
pubkey: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val handle = runCatching { l.subscribeCatalog(pubkey) }.getOrNull() ?: return@launch
|
||||
try {
|
||||
handle.objects.collect { obj ->
|
||||
if (closed) return@collect
|
||||
val parsed = RoomSpeakerCatalog.parseOrNull(obj.payload) ?: return@collect
|
||||
_speakerCatalogs.update { it + (pubkey to parsed) }
|
||||
}
|
||||
} finally {
|
||||
runCatching { handle.unsubscribe() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun openSubscription(
|
||||
l: NestsListener,
|
||||
pubkey: String,
|
||||
@@ -632,6 +673,9 @@ class AudioRoomViewModel(
|
||||
if (closed || activeSubscriptions[pubkey] !== slot) return
|
||||
try {
|
||||
val handle = l.subscribeSpeaker(pubkey)
|
||||
// Parallel catalog fetch — best-effort, doesn't gate audio
|
||||
// playback. Cancelled implicitly when the VM scope is cancelled.
|
||||
fetchSpeakerCatalog(l, pubkey)
|
||||
// Re-check after the suspending subscribeSpeaker — the user
|
||||
// may have removed this speaker via updateSpeakers / disconnected
|
||||
// while the SUBSCRIBE was in flight. If so, abandon the handle
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.commons.viewmodels
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Decoded shape of a moq-lite `catalog.json` track for a single
|
||||
* audio-room speaker. Each broadcast advertises one or more tracks
|
||||
* (one per codec / quality tier) — for nests audio rooms there's
|
||||
* exactly one Opus track today, but the model carries a list for
|
||||
* forward compatibility.
|
||||
*
|
||||
* Best-effort schema: nostrnests' upstream moq-lite catalog spec
|
||||
* (kixelated/moq) has evolved across revisions, and not every
|
||||
* publisher fills in every field. The parser tolerates missing
|
||||
* keys via `ignoreUnknownKeys = true` on [JsonMapper] and the
|
||||
* `?`-marked properties.
|
||||
*/
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class RoomSpeakerCatalog(
|
||||
val version: Int? = null,
|
||||
val audio: List<AudioTrack> = emptyList(),
|
||||
) {
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class AudioTrack(
|
||||
val track: String? = null,
|
||||
val codec: String? = null,
|
||||
@SerialName("sample_rate") val sampleRate: Int? = null,
|
||||
@SerialName("channel_count") val channelCount: Int? = null,
|
||||
val bitrate: Int? = null,
|
||||
) {
|
||||
/**
|
||||
* Short single-line summary suitable for a UI tooltip /
|
||||
* subtitle. Returns null when there's nothing useful to
|
||||
* show — the caller can omit the line entirely rather than
|
||||
* render "unknown · unknown".
|
||||
*/
|
||||
fun describe(): String? {
|
||||
val parts =
|
||||
buildList {
|
||||
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
|
||||
sampleRate?.let { add("${it / 1000}kHz") }
|
||||
channelCount?.let { add(if (it == 1) "mono" else "${it}ch") }
|
||||
}
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/** First audio track, if any. The current single-Opus reality. */
|
||||
fun primaryAudio(): AudioTrack? = audio.firstOrNull()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a UTF-8 JSON byte array delivered on the
|
||||
* `catalog.json` track. Returns null when the bytes don't
|
||||
* decode as JSON or don't match the expected shape — the
|
||||
* caller falls back to "no catalog" rather than blowing up
|
||||
* the UI.
|
||||
*/
|
||||
fun parseOrNull(bytes: ByteArray): RoomSpeakerCatalog? {
|
||||
val text =
|
||||
runCatching { bytes.decodeToString() }.getOrNull()
|
||||
?: return null
|
||||
return runCatching { JsonMapper.fromJson<RoomSpeakerCatalog>(text) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.commons.viewmodels
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class RoomSpeakerCatalogTest {
|
||||
@Test
|
||||
fun parsesNostrnestsShape() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"audio": [
|
||||
{
|
||||
"track": "audio/data",
|
||||
"codec": "opus",
|
||||
"sample_rate": 48000,
|
||||
"channel_count": 2,
|
||||
"bitrate": 64000
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
|
||||
assertNotNull(catalog)
|
||||
assertEquals(1, catalog.version)
|
||||
assertEquals(1, catalog.audio.size)
|
||||
val track = catalog.primaryAudio()
|
||||
assertNotNull(track)
|
||||
assertEquals("audio/data", track.track)
|
||||
assertEquals("opus", track.codec)
|
||||
assertEquals(48_000, track.sampleRate)
|
||||
assertEquals(2, track.channelCount)
|
||||
assertEquals(64_000, track.bitrate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun describeFormatsHumanReadable() {
|
||||
val track =
|
||||
RoomSpeakerCatalog.AudioTrack(
|
||||
codec = "opus",
|
||||
sampleRate = 48_000,
|
||||
channelCount = 2,
|
||||
)
|
||||
// Codec uppercased, kHz / channel count short-formed —
|
||||
// intended for a single-line tooltip in the participant
|
||||
// sheet, not a verbose codec dump.
|
||||
assertEquals("OPUS · 48kHz · 2ch", track.describe())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun describeMonoIsLabelled() {
|
||||
val track = RoomSpeakerCatalog.AudioTrack(codec = "opus", channelCount = 1)
|
||||
assertEquals("OPUS · mono", track.describe())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun describeAllNullReturnsNull() {
|
||||
val track = RoomSpeakerCatalog.AudioTrack()
|
||||
// Empty catalog → caller doesn't render a "unknown · unknown"
|
||||
// line; describe() returns null so the UI can omit cleanly.
|
||||
assertNull(track.describe())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toleratesUnknownKeys() {
|
||||
// Forward-compat: future moq-lite catalog revisions can add
|
||||
// fields without breaking older clients.
|
||||
val json =
|
||||
"""{"version":2,"audio":[{"track":"audio/data","codec":"opus","extra":"future-only"}],"new_top_level":true}"""
|
||||
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
|
||||
assertNotNull(catalog)
|
||||
assertEquals("opus", catalog.primaryAudio()?.codec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageBytesReturnNull() {
|
||||
// An invalid UTF-8 sequence or non-JSON payload should yield
|
||||
// null rather than throw — the catalog channel is best-effort.
|
||||
val catalog = RoomSpeakerCatalog.parseOrNull(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x00))
|
||||
assertNull(catalog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyAudioListIsAllowed() {
|
||||
// A publisher might emit an "advertising" catalog with no
|
||||
// tracks yet (e.g. between codec switches). Accept the shape
|
||||
// and let primaryAudio() return null.
|
||||
val catalog = RoomSpeakerCatalog.parseOrNull("""{"version":1,"audio":[]}""".encodeToByteArray())
|
||||
assertNotNull(catalog)
|
||||
assertNull(catalog.primaryAudio())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user