From 1c6d939d2827a8aa8638eec218d618e3d09771e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 03:26:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(audio-rooms):=20speaker=20UI=20=E2=80=94?= =?UTF-8?q?=20Talk=20button=20+=20mic-mute=20+=20RECORD=5FAUDIO=20permissi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../audiorooms/room/AudioRoomStage.kt | 127 ++++++++++- amethyst/src/main/res/values/strings.xml | 8 + .../commons/viewmodels/AudioRoomViewModel.kt | 200 ++++++++++++++++++ 3 files changed, 334 insertions(+), 1 deletion(-) 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()