diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 5570b200db..91c6c94912 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -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], ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt index 4f0a2d0fcb..7aedc495c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt @@ -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 diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 61bcf5dbb7..2dc92ff7a3 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.serialization) } kotlin { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index 5e5e23516c..7b0b17a423 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -159,6 +159,18 @@ class AudioRoomViewModel( private val _recentReactions = MutableStateFlow>>(emptyMap()) val recentReactions: StateFlow>> = _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>(emptyMap()) + val speakerCatalogs: StateFlow> = _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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalog.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalog.kt new file mode 100644 index 0000000000..3694843e86 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalog.kt @@ -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 = 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(text) }.getOrNull() + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt new file mode 100644 index 0000000000..4f4ffab802 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt @@ -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()) + } +}