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:
+126
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+200
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user