diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
index b5ea39e63..51ead34af 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
@@ -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
}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index afc098bf1..efacb3dd5 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -504,6 +504,14 @@
Audio connected
Audio failed: %1$s
Audio is not available for this room
+ Talk
+ Stop talking
+ Mute mic
+ Unmute mic
+ Going live…
+ Live
+ Broadcast failed: %1$s
+ Microphone access is required to talk in this room.
Videos
Articles
Private Bookmarks
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 6779922fc..c6c816196 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
@@ -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 = _uiState.asStateFlow()
@@ -96,6 +109,12 @@ class AudioRoomViewModel(
private var requestedSpeakers: Set = 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) {
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 = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
val speakingNow: ImmutableSet = 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()