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
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -51,7 +55,9 @@ 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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.model.nip53LiveActivities.LiveActivitiesChannel
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.ui.note.ClickableUserPicture
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.Size40dp
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioRecordCapture
import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder
import com.vitorpamplona.nestsclient.audio.MediaCodecOpusEncoder
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
@@ -226,6 +235,11 @@ private fun AudioRoomStageContent(
if (viewModel != null && ui != null) {
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 {
Text(
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)
}
/**
* 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
* in `commons/` so a future desktop port can reuse the orchestration once
* 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 val signer: com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
@@ -396,6 +519,8 @@ private class AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
captureFactory = { AudioRecordCapture() },
encoderFactory = { MediaCodecOpusEncoder() },
) as T
}
+8
View File
@@ -504,6 +504,14 @@
<string name="audio_room_connected">Audio connected</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_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="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>
@@ -24,13 +24,19 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.nestsclient.BroadcastHandle
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsListener
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.AudioRoomPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -79,10 +85,17 @@ class AudioRoomViewModel(
private val signer: NostrSigner,
private val serviceBase: 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
// the real `connectNestsListener`. Tests inject a fake that returns a
// listener whose state they can drive directly.
private val connector: NestsListenerConnector = DefaultNestsListenerConnector,
private val speakerConnector: NestsSpeakerConnector = DefaultNestsSpeakerConnector,
) : ViewModel() {
private val _uiState = MutableStateFlow(AudioRoomUiState())
val uiState: StateFlow<AudioRoomUiState> = _uiState.asStateFlow()
@@ -96,6 +109,12 @@ class AudioRoomViewModel(
private var requestedSpeakers: Set<String> = emptySet()
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. */
fun updateSpeakers(speakerPubkeys: Set<String>) {
if (closed) return
@@ -150,18 +169,152 @@ class AudioRoomViewModel(
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). */
fun disconnect() {
if (closed) return
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Idle)
}
override fun onCleared() {
closed = true
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Closed)
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) {
stateObserverJob?.cancel()
stateObserverJob =
@@ -409,8 +562,25 @@ data class AudioRoomUiState(
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
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.
* 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
sealed class ConnectionUiState {
data object Idle : ConnectionUiState()