Merge pull request #2667 from vitorpamplona/claude/fix-mute-button-height-6Vfip
Refactor NestActionBar UI layout and state handling
This commit is contained in:
+281
-262
@@ -21,7 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -34,7 +38,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
@@ -66,21 +69,25 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Sticky bottom action bar for the room screen. Replaces the three
|
||||
* separate rows (`ConnectionRow`, `TalkRow`, hand/react/leave) that
|
||||
* used to scroll away with the rest of the metadata.
|
||||
* Sticky bottom action bar for the room screen.
|
||||
*
|
||||
* Layout adapts to connection / broadcast / on-stage state:
|
||||
* - Disconnected → `[Connect]` (start) · `[Leave]` (end)
|
||||
* - Connecting / Reconnecting → state chip · `[Leave]`
|
||||
* - Connected, audience → `[Listen mute toggle]` · `[Hand] [React] [Leave]`
|
||||
* - Connected, on-stage idle → `[Talk] [Leave stage]` · `[React] [Leave]`
|
||||
* - Connected, on-stage live → `[Mic mute] [Stop] [Leave stage]` · `[React] [Leave]`
|
||||
* - Failed (connection or broadcast) → status strip above the bar
|
||||
* plus a retry button in the start cluster.
|
||||
* Layout: `[ start cluster ] · · · [ end cluster ]` with an optional
|
||||
* red status strip above for connection / broadcast / mute failures.
|
||||
*
|
||||
* The Hand button hides when the user is already on stage — the
|
||||
* action it represents ("I want to speak") doesn't apply once you can.
|
||||
* Start cluster — driven by connection × broadcast × on-stage state:
|
||||
*
|
||||
* | State | Start cluster contents |
|
||||
* |---------------------------------------------|-----------------------------------------|
|
||||
* | Idle / Closed / Failed connection | `[Connect]` |
|
||||
* | Connecting / Reconnecting | status chip |
|
||||
* | Connected, audience | empty (system volume keys are enough) |
|
||||
* | Connected, on stage, !canBroadcast | `[Leave the Stage]` |
|
||||
* | Connected, on stage, mic idle | `[Talk] [Leave the Stage]` (+ pill) |
|
||||
* | Connected, on stage, going live | status chip + `[Leave the Stage]` |
|
||||
* | Connected, on stage, broadcasting | `[MicMute] [Stop] [Leave the Stage]` |
|
||||
* | Connected, on stage, broadcast failed | `[Retry] [Leave the Stage]` |
|
||||
*
|
||||
* End cluster: hand-raise (audience + connected only), react, leave room.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NestActionBar(
|
||||
@@ -100,18 +107,11 @@ internal fun NestActionBar(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
||||
ActionBarStatusStrip(ui = ui)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -126,6 +126,7 @@ internal fun NestActionBar(
|
||||
}
|
||||
EndCluster(
|
||||
isOnStage = isOnStage,
|
||||
isConnected = ui.connection is ConnectionUiState.Connected,
|
||||
handRaised = handRaised,
|
||||
onHandRaisedChange = onHandRaisedChange,
|
||||
onShowReactionPicker = onShowReactionPicker,
|
||||
@@ -136,27 +137,10 @@ internal fun NestActionBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-line red error strip. Surfaces the most relevant failure. */
|
||||
@Composable
|
||||
private fun ActionBarStatusStrip(ui: NestUiState) {
|
||||
val text =
|
||||
when (val connection = ui.connection) {
|
||||
is ConnectionUiState.Failed -> {
|
||||
stringRes(R.string.nest_audio_failed, connection.reason)
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (val broadcast = ui.broadcast) {
|
||||
is BroadcastUiState.Failed -> {
|
||||
stringRes(R.string.nest_broadcast_failed, broadcast.reason)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text == null) return
|
||||
val text = ui.statusStripText() ?: return
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -165,6 +149,19 @@ private fun ActionBarStatusStrip(ui: NestUiState) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NestUiState.statusStripText(): String? {
|
||||
val connection = connection
|
||||
if (connection is ConnectionUiState.Failed) {
|
||||
return stringRes(R.string.nest_audio_failed, connection.reason)
|
||||
}
|
||||
return when (val b = broadcast) {
|
||||
is BroadcastUiState.Failed -> stringRes(R.string.nest_broadcast_failed, b.reason)
|
||||
is BroadcastUiState.Broadcasting -> b.muteError?.let { stringRes(R.string.nest_mute_failed, it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartCluster(
|
||||
viewModel: NestViewModel,
|
||||
@@ -178,61 +175,40 @@ private fun StartCluster(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (val connection = ui.connection) {
|
||||
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
|
||||
Button(onClick = { viewModel.connect() }) {
|
||||
Text(stringRes(R.string.nest_connect))
|
||||
}
|
||||
is ConnectionUiState.Idle,
|
||||
is ConnectionUiState.Closed,
|
||||
is ConnectionUiState.Failed,
|
||||
-> {
|
||||
ConnectButton(onClick = viewModel::connect)
|
||||
}
|
||||
|
||||
is ConnectionUiState.Connecting -> {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(connectingLabel(connection)) },
|
||||
)
|
||||
StatusChip(label = connectingLabel(connection))
|
||||
}
|
||||
|
||||
is ConnectionUiState.Reconnecting -> {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(stringRes(R.string.nest_reconnecting)) },
|
||||
)
|
||||
StatusChip(label = stringRes(R.string.nest_reconnecting))
|
||||
}
|
||||
|
||||
is ConnectionUiState.Connected -> {
|
||||
if (canBroadcast && isOnStage) {
|
||||
OnStageControls(
|
||||
viewModel = viewModel,
|
||||
ui = ui,
|
||||
speakerPubkeyHex = speakerPubkeyHex,
|
||||
)
|
||||
} else {
|
||||
// Audience: a single mute toggle for the inbound
|
||||
// listener stream. Mirrors the old ConnectionRow.
|
||||
FilledTonalIconToggleButton(
|
||||
checked = ui.isMuted,
|
||||
onCheckedChange = { viewModel.setMuted(it) },
|
||||
) {
|
||||
Icon(
|
||||
symbol =
|
||||
if (ui.isMuted) {
|
||||
MaterialSymbols.AutoMirrored.VolumeOff
|
||||
} else {
|
||||
MaterialSymbols.AutoMirrored.VolumeUp
|
||||
},
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (ui.isMuted) R.string.nest_unmute else R.string.nest_mute,
|
||||
),
|
||||
when {
|
||||
canBroadcast && isOnStage -> {
|
||||
OnStageControls(
|
||||
viewModel = viewModel,
|
||||
broadcast = ui.broadcast,
|
||||
speakerPubkeyHex = speakerPubkeyHex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectionUiState.Failed -> {
|
||||
Button(onClick = { viewModel.connect() }) {
|
||||
Text(stringRes(R.string.nest_connect))
|
||||
// On stage but no signing/permission — only thing we can do is step down.
|
||||
isOnStage -> {
|
||||
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
||||
}
|
||||
|
||||
// Audience: nothing here. Phone volume keys cover local volume.
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,160 +218,78 @@ private fun StartCluster(
|
||||
@Composable
|
||||
private fun OnStageControls(
|
||||
viewModel: NestViewModel,
|
||||
ui: NestUiState,
|
||||
broadcast: BroadcastUiState,
|
||||
speakerPubkeyHex: String,
|
||||
) {
|
||||
val leaveStage = {
|
||||
viewModel.stopBroadcast()
|
||||
viewModel.setOnStage(false)
|
||||
}
|
||||
when (broadcast) {
|
||||
BroadcastUiState.Idle -> {
|
||||
OnStageIdleControls(
|
||||
viewModel = viewModel,
|
||||
speakerPubkeyHex = speakerPubkeyHex,
|
||||
)
|
||||
}
|
||||
|
||||
BroadcastUiState.Connecting -> {
|
||||
StatusChip(label = stringRes(R.string.nest_broadcast_connecting))
|
||||
// stopBroadcast() cancels the in-flight speakerConnectJob,
|
||||
// so leaving mid-handshake is safe.
|
||||
LeaveStageButton(onClick = leaveStage)
|
||||
}
|
||||
|
||||
is BroadcastUiState.Broadcasting -> {
|
||||
MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted)
|
||||
StopBroadcastButton(onClick = viewModel::stopBroadcast)
|
||||
LeaveStageButton(onClick = leaveStage)
|
||||
}
|
||||
|
||||
is BroadcastUiState.Failed -> {
|
||||
// Reason is shown in the status strip; this button retries.
|
||||
TalkButton(
|
||||
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
|
||||
contentDescription = stringRes(R.string.nest_talk),
|
||||
)
|
||||
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnStageIdleControls(
|
||||
viewModel: NestViewModel,
|
||||
speakerPubkeyHex: String,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by rememberSaveable { mutableStateOf(false) }
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
permissionDenied = false
|
||||
permissionDenied = !granted
|
||||
if (granted) viewModel.startBroadcast(speakerPubkeyHex)
|
||||
}
|
||||
// The launcher callback never fires for Settings-deep-link grants, so
|
||||
// re-check on every recomposition to auto-clear the warning.
|
||||
val showDenialWarning =
|
||||
permissionDenied && !context.hasMicPermission()
|
||||
|
||||
TalkButton(
|
||||
onClick = {
|
||||
if (context.hasMicPermission()) {
|
||||
viewModel.startBroadcast(speakerPubkeyHex)
|
||||
} else {
|
||||
permissionDenied = true
|
||||
}
|
||||
}
|
||||
// If the user grants RECORD_AUDIO via the system Settings deep-link
|
||||
// and returns to the activity, the launcher callback never fires —
|
||||
// recompute every time so the warning auto-clears.
|
||||
val showDenialWarning =
|
||||
permissionDenied &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
when (val broadcast = ui.broadcast) {
|
||||
BroadcastUiState.Idle -> {
|
||||
// Mic OFF visual: filled primary button signals "tap to go live".
|
||||
// Sized larger than the surrounding 40.dp icon buttons so the
|
||||
// mic state is unmistakable at a glance.
|
||||
FilledIconButton(
|
||||
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)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MicOff,
|
||||
contentDescription = stringRes(R.string.nest_talk),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
|
||||
Text(stringRes(R.string.nest_leave_stage))
|
||||
}
|
||||
if (showDenialWarning) {
|
||||
// After "Don't ask again" the permission launcher
|
||||
// silently returns false; surface a Settings deep-link.
|
||||
OutlinedButton(onClick = {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
android.content
|
||||
.Intent(
|
||||
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
android.net.Uri.fromParts("package", context.packageName, null),
|
||||
).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(stringRes(R.string.nest_open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BroadcastUiState.Connecting -> {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(stringRes(R.string.nest_broadcast_connecting)) },
|
||||
)
|
||||
}
|
||||
|
||||
is BroadcastUiState.Broadcasting -> {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(stringRes(R.string.nest_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.nest_mic_unmute else R.string.nest_mic_mute,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Mic ON visual: filled error-color button screams "MIC IS LIVE,
|
||||
// tap to stop transmitting". Same large footprint as the Idle
|
||||
// mic button so the state swap is impossible to miss.
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.stopBroadcast() },
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Mic,
|
||||
contentDescription = stringRes(R.string.nest_stop_talking),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.stopBroadcast()
|
||||
viewModel.setOnStage(false)
|
||||
}) {
|
||||
Text(stringRes(R.string.nest_leave_stage))
|
||||
}
|
||||
}
|
||||
|
||||
is BroadcastUiState.Failed -> {
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MicOff,
|
||||
contentDescription = stringRes(R.string.nest_talk),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
contentDescription = stringRes(R.string.nest_talk),
|
||||
)
|
||||
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
||||
if (showDenialWarning) {
|
||||
// After "Don't ask again" the launcher silently returns false;
|
||||
// expose a Settings deep-link.
|
||||
OutlinedButton(onClick = { context.openAppSettings() }) {
|
||||
Text(stringRes(R.string.nest_open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,6 +297,7 @@ private fun OnStageControls(
|
||||
@Composable
|
||||
private fun EndCluster(
|
||||
isOnStage: Boolean,
|
||||
isConnected: Boolean,
|
||||
handRaised: Boolean,
|
||||
onHandRaisedChange: (Boolean) -> Unit,
|
||||
onShowReactionPicker: () -> Unit,
|
||||
@@ -412,24 +307,13 @@ private fun EndCluster(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Hand-raise only makes sense for audience: if you're already
|
||||
// on stage, you don't need to ask. Hidden when on-stage.
|
||||
if (!isOnStage) {
|
||||
FilledTonalIconToggleButton(
|
||||
checked = handRaised,
|
||||
onCheckedChange = onHandRaisedChange,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PanTool,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Hand-raise: only meaningful for connected audience. On stage
|
||||
// it's moot; disconnected it can't reach the room.
|
||||
if (!isOnStage && isConnected) {
|
||||
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
||||
}
|
||||
// React is always visible — even disconnected users can
|
||||
// (un)react via the reactions picker on the room note.
|
||||
// React works in any state — even disconnected users can react
|
||||
// via the room note.
|
||||
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.EmojiEmotions,
|
||||
@@ -437,21 +321,139 @@ private fun EndCluster(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = onLeave,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.Logout,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringRes(R.string.nest_leave))
|
||||
}
|
||||
LeaveRoomButton(onClick = onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable affordances ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ConnectButton(onClick: () -> Unit) {
|
||||
Button(onClick = onClick) {
|
||||
Text(stringRes(R.string.nest_connect))
|
||||
}
|
||||
}
|
||||
|
||||
/** Disabled assist chip used as a status indicator (no click target). */
|
||||
@Composable
|
||||
private fun StatusChip(label: String) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveStageButton(onClick: () -> Unit) {
|
||||
OutlinedButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.nest_leave_stage))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Big primary 56dp mic button shown in the off-states (Idle, Failed)
|
||||
* to invite the user to start broadcasting. Larger than surrounding
|
||||
* 40dp icon buttons so the mic state is unmistakable.
|
||||
*/
|
||||
@Composable
|
||||
private fun TalkButton(
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String,
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MicOff,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Big error-color 56dp mic button shown while broadcasting. Same
|
||||
* footprint as [TalkButton] so the on/off swap is impossible to miss.
|
||||
*/
|
||||
@Composable
|
||||
private fun StopBroadcastButton(onClick: () -> Unit) {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Mic,
|
||||
contentDescription = stringRes(R.string.nest_stop_talking),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap broadcast-side mute toggle. Keeps the MoQ session open and
|
||||
* just stops sending audio frames; unmute is sample-accurate.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteToggle(
|
||||
isMuted: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
FilledTonalIconToggleButton(
|
||||
checked = isMuted,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
|
||||
contentDescription = stringRes(if (isMuted) R.string.nest_mic_unmute else R.string.nest_mic_mute),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRaiseToggle(
|
||||
handRaised: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
FilledTonalIconToggleButton(
|
||||
checked = handRaised,
|
||||
onCheckedChange = onToggle,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PanTool,
|
||||
contentDescription = stringRes(if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveRoomButton(onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.Logout,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringRes(R.string.nest_leave))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,3 +464,20 @@ private fun connectingLabel(connection: ConnectionUiState.Connecting): String =
|
||||
ConnectionUiState.Step.OpeningTransport -> stringRes(R.string.nest_connecting_transport)
|
||||
ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.nest_connecting_handshake)
|
||||
}
|
||||
|
||||
// ── Permission helpers ──────────────────────────────────────────────────
|
||||
|
||||
private fun Context.hasMicPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun Context.openAppSettings() {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null),
|
||||
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,10 +521,10 @@
|
||||
<string name="nest_mic_mute">Mute mic</string>
|
||||
<string name="nest_mic_unmute">Unmute mic</string>
|
||||
<string name="nest_broadcast_connecting">Going live…</string>
|
||||
<string name="nest_broadcasting">Live</string>
|
||||
<string name="nest_broadcast_failed">Broadcast failed: %1$s</string>
|
||||
<string name="nest_mute_failed">Mute failed: %1$s</string>
|
||||
<string name="nest_record_permission_required">Microphone access is required to talk in this room.</string>
|
||||
<string name="nest_open_settings">Open settings</string>
|
||||
<string name="nest_open_settings">No permissions</string>
|
||||
<string name="nest_notification_channel">Nests</string>
|
||||
<string name="nest_notification_channel_description">Keeps audio playing while a room is open.</string>
|
||||
<string name="nest_notification_listening">Nest connected</string>
|
||||
|
||||
Reference in New Issue
Block a user