feat(audio-rooms): speaker UI — Talk button + mic-mute + RECORD_AUDIO permission

Wires the M5–M7 publisher path into the existing audio-room screen so
hosts and speakers can broadcast their own audio.

ViewModel (commons AudioRoomViewModel):
- New optional constructor params `captureFactory` / `encoderFactory`.
  When both are non-null, `canBroadcast = true`; otherwise the speaker
  UI is hidden (desktop, listener-only).
- `startBroadcast(speakerPubkeyHex)` — kicks off
  `connectNestsSpeaker(...)` + `NestsSpeaker.startBroadcasting()`,
  surfaces a `BroadcastUiState.Connecting` → `Broadcasting(isMuted)`
  transition.
- `setMicMuted` / `stopBroadcast` — mic-side counterparts to the
  listener's `setMuted` / `disconnect`.
- `BroadcastUiState` sealed hierarchy added to `AudioRoomUiState`.
- `disconnect` and `onCleared` tear the broadcast down before tearing
  the listener down so SUBSCRIBE_DONE / UNANNOUNCE go out cleanly.
- `NestsSpeakerConnector` test seam mirrors `NestsListenerConnector`.

Screen (AudioRoomStage):
- AudioRoomViewModelFactory now wires the Android speaker actuals
  (`AudioRecordCapture` + `MediaCodecOpusEncoder`).
- New `AudioTalkRow` composable: Talk button gated on
  `RECORD_AUDIO` (granted via `ActivityResultContracts.RequestPermission`),
  Live indicator (red AssistChip) + mic-mute toggle while broadcasting,
  Stop talking button. Hidden unless the user is in the room's `p` tags
  as host or speaker AND `viewModel.canBroadcast`.
- Permission denial surfaces an inline error message rather than
  reprompting; user can re-tap Talk to try again.
- Strings: 8 new `audio_room_*` keys (talk / stop_talking / mic_mute /
  mic_unmute / broadcast_connecting / broadcasting / broadcast_failed /
  record_permission_required).

`AndroidManifest.xml` already declares `RECORD_AUDIO` (and the
foreground-microphone permission for M9), so no manifest change here.

Verified: `:commons:jvmTest` + `:nestsClient:jvmTest` (78 tests, all
green) + `:amethyst:compilePlayDebugKotlin` clean.
This commit is contained in:
Claude
2026-04-26 03:26:56 +00:00
parent 0afde79afd
commit 1c6d939d28
3 changed files with 334 additions and 1 deletions
@@ -20,6 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -51,7 +55,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -60,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -68,8 +75,10 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.nestsclient.OkHttpNestsClient import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioRecordCapture
import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusEncoder
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
@@ -226,6 +235,11 @@ private fun AudioRoomStageContent(
if (viewModel != null && ui != null) { if (viewModel != null && ui != null) {
AudioConnectionRow(viewModel = viewModel, ui = ui) AudioConnectionRow(viewModel = viewModel, ui = ui)
val myPubkey = account.signer.pubKey
val canTalkRoleWise = onStage.any { it.pubKey == myPubkey }
if (viewModel.canBroadcast && canTalkRoleWise) {
AudioTalkRow(viewModel = viewModel, ui = ui, speakerPubkeyHex = myPubkey)
}
} else { } else {
Text( Text(
text = stringRes(R.string.audio_room_audio_unavailable), text = stringRes(R.string.audio_room_audio_unavailable),
@@ -375,11 +389,120 @@ private fun connectingLabel(connection: ConnectionUiState.Connecting): String =
ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.audio_room_connecting_handshake) ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.audio_room_connecting_handshake)
} }
/**
* Talk button + mic-mute toggle + Live indicator. Hidden unless the user is
* in the room's `p` tags as host or speaker AND the listener path is
* Connected (this composable's caller already checks `canBroadcast` +
* role).
*/
@Composable
private fun AudioTalkRow(
viewModel: AudioRoomViewModel,
ui: com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomUiState,
speakerPubkeyHex: String,
) {
if (ui.connection !is ConnectionUiState.Connected) return
val context = LocalContext.current
var permissionDeniedShown by rememberSaveable { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionDeniedShown = true
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val broadcast = ui.broadcast) {
BroadcastUiState.Idle -> {
Button(
onClick = {
val granted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
) {
Text(stringRes(R.string.audio_room_talk))
}
if (permissionDeniedShown) {
Text(
text = stringRes(R.string.audio_room_record_permission_required),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f, fill = false),
)
}
}
BroadcastUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.audio_room_broadcast_connecting)) },
)
}
is BroadcastUiState.Broadcasting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.audio_room_broadcasting)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.error,
),
)
FilledTonalIconToggleButton(
checked = broadcast.isMuted,
onCheckedChange = { viewModel.setMicMuted(it) },
) {
Icon(
symbol = if (broadcast.isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription =
stringRes(
if (broadcast.isMuted) R.string.audio_room_mic_unmute else R.string.audio_room_mic_mute,
),
)
}
OutlinedButton(onClick = { viewModel.stopBroadcast() }) {
Text(stringRes(R.string.audio_room_stop_talking))
}
}
is BroadcastUiState.Failed -> {
Text(
text = stringRes(R.string.audio_room_broadcast_failed, broadcast.reason),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f, fill = false),
)
Button(onClick = { viewModel.startBroadcast(speakerPubkeyHex) }) {
Text(stringRes(R.string.audio_room_talk))
}
}
}
}
}
/** /**
* Android-side Factory for [AudioRoomViewModel]. The ViewModel itself lives * Android-side Factory for [AudioRoomViewModel]. The ViewModel itself lives
* in `commons/` so a future desktop port can reuse the orchestration once * in `commons/` so a future desktop port can reuse the orchestration once
* Compose Desktop has WebTransport; this factory binds it to the Android * Compose Desktop has WebTransport; this factory binds it to the Android
* actuals (OkHttp HTTP, pure-Kotlin QUIC, MediaCodec Opus, AudioTrack). * actuals (OkHttp HTTP, pure-Kotlin QUIC, MediaCodec Opus, AudioTrack +
* AudioRecord on the speaker side).
*/ */
private class AudioRoomViewModelFactory( private class AudioRoomViewModelFactory(
private val signer: com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, private val signer: com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
@@ -396,6 +519,8 @@ private class AudioRoomViewModelFactory(
signer = signer, signer = signer,
serviceBase = serviceBase, serviceBase = serviceBase,
roomId = roomId, roomId = roomId,
captureFactory = { AudioRecordCapture() },
encoderFactory = { MediaCodecOpusEncoder() },
) as T ) as T
} }
+8
View File
@@ -504,6 +504,14 @@
<string name="audio_room_connected">Audio connected</string> <string name="audio_room_connected">Audio connected</string>
<string name="audio_room_audio_failed">Audio failed: %1$s</string> <string name="audio_room_audio_failed">Audio failed: %1$s</string>
<string name="audio_room_audio_unavailable">Audio is not available for this room</string> <string name="audio_room_audio_unavailable">Audio is not available for this room</string>
<string name="audio_room_talk">Talk</string>
<string name="audio_room_stop_talking">Stop talking</string>
<string name="audio_room_mic_mute">Mute mic</string>
<string name="audio_room_mic_unmute">Unmute mic</string>
<string name="audio_room_broadcast_connecting">Going live…</string>
<string name="audio_room_broadcasting">Live</string>
<string name="audio_room_broadcast_failed">Broadcast failed: %1$s</string>
<string name="audio_room_record_permission_required">Microphone access is required to talk in this room.</string>
<string name="longs">Videos</string> <string name="longs">Videos</string>
<string name="articles">Articles</string> <string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string> <string name="private_bookmarks">Private Bookmarks</string>
@@ -24,13 +24,19 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.nestsclient.BroadcastHandle
import com.vitorpamplona.nestsclient.NestsClient import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsListener import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsSpeaker
import com.vitorpamplona.nestsclient.NestsSpeakerState
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.AudioPlayer import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.connectNestsListener import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.moq.SubscribeHandle import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -79,10 +85,17 @@ class AudioRoomViewModel(
private val signer: NostrSigner, private val signer: NostrSigner,
private val serviceBase: String, private val serviceBase: String,
private val roomId: String, private val roomId: String,
// Speaker-side audio capture/encode actuals. Optional — desktop and
// listener-only callers pass null and the speaker UI hides the talk
// button. Android passes `{ AudioRecordCapture() }` /
// `{ MediaCodecOpusEncoder() }`.
private val captureFactory: (() -> AudioCapture)? = null,
private val encoderFactory: (() -> OpusEncoder)? = null,
// Seam for tests — production code uses the default which delegates to // Seam for tests — production code uses the default which delegates to
// the real `connectNestsListener`. Tests inject a fake that returns a // the real `connectNestsListener`. Tests inject a fake that returns a
// listener whose state they can drive directly. // listener whose state they can drive directly.
private val connector: NestsListenerConnector = DefaultNestsListenerConnector, private val connector: NestsListenerConnector = DefaultNestsListenerConnector,
private val speakerConnector: NestsSpeakerConnector = DefaultNestsSpeakerConnector,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AudioRoomUiState()) private val _uiState = MutableStateFlow(AudioRoomUiState())
val uiState: StateFlow<AudioRoomUiState> = _uiState.asStateFlow() val uiState: StateFlow<AudioRoomUiState> = _uiState.asStateFlow()
@@ -96,6 +109,12 @@ class AudioRoomViewModel(
private var requestedSpeakers: Set<String> = emptySet() private var requestedSpeakers: Set<String> = emptySet()
private var closed = false private var closed = false
// Speaker / publisher path
private var speaker: NestsSpeaker? = null
private var broadcastHandle: BroadcastHandle? = null
private var speakerStateJob: Job? = null
private var speakerConnectJob: Job? = null
/** Push the latest known speaker set from the room event. */ /** Push the latest known speaker set from the room event. */
fun updateSpeakers(speakerPubkeys: Set<String>) { fun updateSpeakers(speakerPubkeys: Set<String>) {
if (closed) return if (closed) return
@@ -150,18 +169,152 @@ class AudioRoomViewModel(
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) } activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
} }
/**
* Whether this VM was constructed with capture + encoder factories. The
* UI uses this to decide whether to render the talk button at all
* desktop and listener-only screens leave it false.
*/
val canBroadcast: Boolean = captureFactory != null && encoderFactory != null
/**
* Begin broadcasting our own pubkey's audio. Caller must have already
* obtained `RECORD_AUDIO`. Idempotent: returns immediately if a
* broadcast is already in flight.
*
* If the listener path isn't [ConnectionUiState.Connected] yet, the
* call is a no-op (the UI shouldn't expose the talk button before
* Connected anyway).
*
* Note that the listener and speaker paths each own their own MoQ
* session over a separate WebTransport nests' protocol uses one
* session per direction.
*/
fun startBroadcast(speakerPubkeyHex: String) {
if (closed || !canBroadcast) return
if (_uiState.value.connection !is ConnectionUiState.Connected) return
if (_uiState.value.broadcast is BroadcastUiState.Broadcasting ||
_uiState.value.broadcast is BroadcastUiState.Connecting
) {
return
}
_uiState.update {
it.copy(broadcast = BroadcastUiState.Connecting)
}
speakerConnectJob =
viewModelScope.launch {
try {
val s =
speakerConnector.connect(
httpClient = httpClient,
transport = transport,
scope = viewModelScope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory!!,
encoderFactory = encoderFactory!!,
)
if (closed) {
runCatching { s.close() }
return@launch
}
speaker = s
observeSpeakerState(s)
val handle = s.startBroadcasting()
broadcastHandle = handle
_uiState.update { it.copy(broadcast = BroadcastUiState.Broadcasting(isMuted = false)) }
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
_uiState.update {
it.copy(
broadcast =
BroadcastUiState.Failed(
t.message ?: t::class.simpleName ?: "broadcast failed",
),
)
}
}
}
}
/** Toggle the speaker-side mic mute. Cheap; the network keeps running. */
fun setMicMuted(muted: Boolean) {
if (closed) return
viewModelScope.launch {
broadcastHandle?.runCatching { setMuted(muted) }
}
_uiState.update {
val current = it.broadcast
if (current is BroadcastUiState.Broadcasting) {
it.copy(broadcast = current.copy(isMuted = muted))
} else {
it
}
}
}
/** Stop broadcasting. UI keeps the listener path connected. Idempotent. */
fun stopBroadcast() {
if (closed) return
teardownBroadcast(BroadcastUiState.Idle)
}
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */ /** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
fun disconnect() { fun disconnect() {
if (closed) return if (closed) return
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Idle) teardown(targetState = ConnectionUiState.Idle)
} }
override fun onCleared() { override fun onCleared() {
closed = true closed = true
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Closed) teardown(targetState = ConnectionUiState.Closed)
super.onCleared() super.onCleared()
} }
private fun observeSpeakerState(s: NestsSpeaker) {
speakerStateJob?.cancel()
speakerStateJob =
viewModelScope.launch {
s.state.collect { state ->
when (state) {
is NestsSpeakerState.Failed -> {
_uiState.update {
it.copy(broadcast = BroadcastUiState.Failed(state.reason))
}
}
NestsSpeakerState.Closed -> {
_uiState.update { it.copy(broadcast = BroadcastUiState.Idle) }
}
else -> { /* startBroadcast already set Broadcasting; no extra action */ }
}
}
}
}
private fun teardownBroadcast(targetState: BroadcastUiState) {
speakerConnectJob?.cancel()
speakerConnectJob = null
speakerStateJob?.cancel()
speakerStateJob = null
val handle = broadcastHandle
val s = speaker
broadcastHandle = null
speaker = null
if (handle != null || s != null) {
viewModelScope.launch {
handle?.runCatching { close() }
s?.runCatching { close() }
}
}
_uiState.update { it.copy(broadcast = targetState) }
}
private fun observeListenerState(l: NestsListener) { private fun observeListenerState(l: NestsListener) {
stateObserverJob?.cancel() stateObserverJob?.cancel()
stateObserverJob = stateObserverJob =
@@ -409,8 +562,25 @@ data class AudioRoomUiState(
val activeSpeakers: ImmutableSet<String> = persistentSetOf(), val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */ /** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
val speakingNow: ImmutableSet<String> = persistentSetOf(), val speakingNow: ImmutableSet<String> = persistentSetOf(),
/** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */
val broadcast: BroadcastUiState = BroadcastUiState.Idle,
) )
@Immutable
sealed class BroadcastUiState {
data object Idle : BroadcastUiState()
data object Connecting : BroadcastUiState()
data class Broadcasting(
val isMuted: Boolean,
) : BroadcastUiState()
data class Failed(
val reason: String,
) : BroadcastUiState()
}
/** /**
* How long a speaker stays "speaking" after their last received MoQ object. * How long a speaker stays "speaking" after their last received MoQ object.
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the * Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
@@ -446,6 +616,36 @@ private val DefaultNestsListenerConnector =
) )
} }
/** Speaker-side equivalent of [NestsListenerConnector]. */
fun interface NestsSpeakerConnector {
suspend fun connect(
httpClient: NestsClient,
transport: WebTransportFactory,
scope: CoroutineScope,
serviceBase: String,
roomId: String,
signer: NostrSigner,
speakerPubkeyHex: String,
captureFactory: () -> AudioCapture,
encoderFactory: () -> OpusEncoder,
): NestsSpeaker
}
private val DefaultNestsSpeakerConnector =
NestsSpeakerConnector { httpClient, transport, scope, serviceBase, roomId, signer, pubkey, capF, encF ->
connectNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = capF,
encoderFactory = encF,
)
}
@Immutable @Immutable
sealed class ConnectionUiState { sealed class ConnectionUiState {
data object Idle : ConnectionUiState() data object Idle : ConnectionUiState()