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:
Claude
2026-04-27 01:35:18 +00:00
parent 3fb9f02b6d
commit d9fe3b5f83
6 changed files with 265 additions and 0 deletions
@@ -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],
)
}
@@ -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
+1
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.serialization)
}
kotlin {
@@ -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
@@ -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()
}
}
}
@@ -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())
}
}