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
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.AssistChipDefaults
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
@@ -66,21 +69,25 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sticky bottom action bar for the room screen. Replaces the three
|
* Sticky bottom action bar for the room screen.
|
||||||
* separate rows (`ConnectionRow`, `TalkRow`, hand/react/leave) that
|
|
||||||
* used to scroll away with the rest of the metadata.
|
|
||||||
*
|
*
|
||||||
* Layout adapts to connection / broadcast / on-stage state:
|
* Layout: `[ start cluster ] · · · [ end cluster ]` with an optional
|
||||||
* - Disconnected → `[Connect]` (start) · `[Leave]` (end)
|
* red status strip above for connection / broadcast / mute failures.
|
||||||
* - 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.
|
|
||||||
*
|
*
|
||||||
* The Hand button hides when the user is already on stage — the
|
* Start cluster — driven by connection × broadcast × on-stage state:
|
||||||
* action it represents ("I want to speak") doesn't apply once you can.
|
*
|
||||||
|
* | 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
|
@Composable
|
||||||
internal fun NestActionBar(
|
internal fun NestActionBar(
|
||||||
@@ -100,18 +107,11 @@ internal fun NestActionBar(
|
|||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
tonalElevation = 3.dp,
|
tonalElevation = 3.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
||||||
ActionBarStatusStrip(ui = ui)
|
ActionBarStatusStrip(ui = ui)
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -126,6 +126,7 @@ internal fun NestActionBar(
|
|||||||
}
|
}
|
||||||
EndCluster(
|
EndCluster(
|
||||||
isOnStage = isOnStage,
|
isOnStage = isOnStage,
|
||||||
|
isConnected = ui.connection is ConnectionUiState.Connected,
|
||||||
handRaised = handRaised,
|
handRaised = handRaised,
|
||||||
onHandRaisedChange = onHandRaisedChange,
|
onHandRaisedChange = onHandRaisedChange,
|
||||||
onShowReactionPicker = onShowReactionPicker,
|
onShowReactionPicker = onShowReactionPicker,
|
||||||
@@ -136,27 +137,10 @@ internal fun NestActionBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single-line red error strip. Surfaces the most relevant failure. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActionBarStatusStrip(ui: NestUiState) {
|
private fun ActionBarStatusStrip(ui: NestUiState) {
|
||||||
val text =
|
val text = ui.statusStripText() ?: return
|
||||||
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
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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
|
@Composable
|
||||||
private fun StartCluster(
|
private fun StartCluster(
|
||||||
viewModel: NestViewModel,
|
viewModel: NestViewModel,
|
||||||
@@ -178,61 +175,40 @@ private fun StartCluster(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
when (val connection = ui.connection) {
|
when (val connection = ui.connection) {
|
||||||
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
|
is ConnectionUiState.Idle,
|
||||||
Button(onClick = { viewModel.connect() }) {
|
is ConnectionUiState.Closed,
|
||||||
Text(stringRes(R.string.nest_connect))
|
is ConnectionUiState.Failed,
|
||||||
}
|
-> {
|
||||||
|
ConnectButton(onClick = viewModel::connect)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ConnectionUiState.Connecting -> {
|
is ConnectionUiState.Connecting -> {
|
||||||
AssistChip(
|
StatusChip(label = connectingLabel(connection))
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
label = { Text(connectingLabel(connection)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ConnectionUiState.Reconnecting -> {
|
is ConnectionUiState.Reconnecting -> {
|
||||||
AssistChip(
|
StatusChip(label = stringRes(R.string.nest_reconnecting))
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
label = { Text(stringRes(R.string.nest_reconnecting)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ConnectionUiState.Connected -> {
|
is ConnectionUiState.Connected -> {
|
||||||
if (canBroadcast && isOnStage) {
|
when {
|
||||||
OnStageControls(
|
canBroadcast && isOnStage -> {
|
||||||
viewModel = viewModel,
|
OnStageControls(
|
||||||
ui = ui,
|
viewModel = viewModel,
|
||||||
speakerPubkeyHex = speakerPubkeyHex,
|
broadcast = ui.broadcast,
|
||||||
)
|
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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ConnectionUiState.Failed -> {
|
// On stage but no signing/permission — only thing we can do is step down.
|
||||||
Button(onClick = { viewModel.connect() }) {
|
isOnStage -> {
|
||||||
Text(stringRes(R.string.nest_connect))
|
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audience: nothing here. Phone volume keys cover local volume.
|
||||||
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,160 +218,78 @@ private fun StartCluster(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OnStageControls(
|
private fun OnStageControls(
|
||||||
viewModel: NestViewModel,
|
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,
|
speakerPubkeyHex: String,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var permissionDenied by rememberSaveable { mutableStateOf(false) }
|
var permissionDenied by rememberSaveable { mutableStateOf(false) }
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
if (granted) {
|
permissionDenied = !granted
|
||||||
permissionDenied = false
|
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)
|
viewModel.startBroadcast(speakerPubkeyHex)
|
||||||
} else {
|
} else {
|
||||||
permissionDenied = true
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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
|
@Composable
|
||||||
private fun EndCluster(
|
private fun EndCluster(
|
||||||
isOnStage: Boolean,
|
isOnStage: Boolean,
|
||||||
|
isConnected: Boolean,
|
||||||
handRaised: Boolean,
|
handRaised: Boolean,
|
||||||
onHandRaisedChange: (Boolean) -> Unit,
|
onHandRaisedChange: (Boolean) -> Unit,
|
||||||
onShowReactionPicker: () -> Unit,
|
onShowReactionPicker: () -> Unit,
|
||||||
@@ -412,24 +307,13 @@ private fun EndCluster(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
// Hand-raise only makes sense for audience: if you're already
|
// Hand-raise: only meaningful for connected audience. On stage
|
||||||
// on stage, you don't need to ask. Hidden when on-stage.
|
// it's moot; disconnected it can't reach the room.
|
||||||
if (!isOnStage) {
|
if (!isOnStage && isConnected) {
|
||||||
FilledTonalIconToggleButton(
|
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
||||||
checked = handRaised,
|
|
||||||
onCheckedChange = onHandRaisedChange,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
symbol = MaterialSymbols.PanTool,
|
|
||||||
contentDescription =
|
|
||||||
stringRes(
|
|
||||||
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// React is always visible — even disconnected users can
|
// React works in any state — even disconnected users can react
|
||||||
// (un)react via the reactions picker on the room note.
|
// via the room note.
|
||||||
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||||
Icon(
|
Icon(
|
||||||
symbol = MaterialSymbols.EmojiEmotions,
|
symbol = MaterialSymbols.EmojiEmotions,
|
||||||
@@ -437,21 +321,139 @@ private fun EndCluster(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
OutlinedButton(
|
LeaveRoomButton(onClick = onLeave)
|
||||||
onClick = onLeave,
|
}
|
||||||
colors =
|
}
|
||||||
ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
// ── Reusable affordances ────────────────────────────────────────────────
|
||||||
),
|
|
||||||
) {
|
@Composable
|
||||||
Icon(
|
private fun ConnectButton(onClick: () -> Unit) {
|
||||||
symbol = MaterialSymbols.AutoMirrored.Logout,
|
Button(onClick = onClick) {
|
||||||
contentDescription = null,
|
Text(stringRes(R.string.nest_connect))
|
||||||
tint = MaterialTheme.colorScheme.error,
|
}
|
||||||
)
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(stringRes(R.string.nest_leave))
|
/** 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.OpeningTransport -> stringRes(R.string.nest_connecting_transport)
|
||||||
ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.nest_connecting_handshake)
|
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_mute">Mute mic</string>
|
||||||
<string name="nest_mic_unmute">Unmute mic</string>
|
<string name="nest_mic_unmute">Unmute mic</string>
|
||||||
<string name="nest_broadcast_connecting">Going live…</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_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_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">Nests</string>
|
||||||
<string name="nest_notification_channel_description">Keeps audio playing while a room is open.</string>
|
<string name="nest_notification_channel_description">Keeps audio playing while a room is open.</string>
|
||||||
<string name="nest_notification_listening">Nest connected</string>
|
<string name="nest_notification_listening">Nest connected</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user