Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
+407
@@ -0,0 +1,407 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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 com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NestActionBar(
|
||||||
|
viewModel: NestViewModel,
|
||||||
|
ui: NestUiState,
|
||||||
|
isOnStage: Boolean,
|
||||||
|
canBroadcast: Boolean,
|
||||||
|
speakerPubkeyHex: String,
|
||||||
|
handRaised: Boolean,
|
||||||
|
onHandRaisedChange: (Boolean) -> Unit,
|
||||||
|
onShowReactionPicker: () -> Unit,
|
||||||
|
onLeave: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
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),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.weight(1f, fill = true)) {
|
||||||
|
StartCluster(
|
||||||
|
viewModel = viewModel,
|
||||||
|
ui = ui,
|
||||||
|
isOnStage = isOnStage,
|
||||||
|
canBroadcast = canBroadcast,
|
||||||
|
speakerPubkeyHex = speakerPubkeyHex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EndCluster(
|
||||||
|
isOnStage = isOnStage,
|
||||||
|
handRaised = handRaised,
|
||||||
|
onHandRaisedChange = onHandRaisedChange,
|
||||||
|
onShowReactionPicker = onShowReactionPicker,
|
||||||
|
onLeave = onLeave,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StartCluster(
|
||||||
|
viewModel: NestViewModel,
|
||||||
|
ui: NestUiState,
|
||||||
|
isOnStage: Boolean,
|
||||||
|
canBroadcast: Boolean,
|
||||||
|
speakerPubkeyHex: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
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.Connecting -> {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(connectingLabel(connection)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionUiState.Reconnecting -> {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionUiState.Failed -> {
|
||||||
|
Button(onClick = { viewModel.connect() }) {
|
||||||
|
Text(stringRes(R.string.nest_connect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnStageControls(
|
||||||
|
viewModel: NestViewModel,
|
||||||
|
ui: NestUiState,
|
||||||
|
speakerPubkeyHex: String,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var permissionDenied by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
permissionDenied = false
|
||||||
|
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 -> {
|
||||||
|
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.nest_talk))
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { viewModel.stopBroadcast() }) {
|
||||||
|
Text(stringRes(R.string.nest_stop_talking))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
viewModel.stopBroadcast()
|
||||||
|
viewModel.setOnStage(false)
|
||||||
|
}) {
|
||||||
|
Text(stringRes(R.string.nest_leave_stage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BroadcastUiState.Failed -> {
|
||||||
|
Button(onClick = { viewModel.startBroadcast(speakerPubkeyHex) }) {
|
||||||
|
Text(stringRes(R.string.nest_talk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EndCluster(
|
||||||
|
isOnStage: Boolean,
|
||||||
|
handRaised: Boolean,
|
||||||
|
onHandRaisedChange: (Boolean) -> Unit,
|
||||||
|
onShowReactionPicker: () -> Unit,
|
||||||
|
onLeave: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// React is always visible — even disconnected users can
|
||||||
|
// (un)react via the reactions picker on the room note.
|
||||||
|
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.EmojiEmotions,
|
||||||
|
contentDescription = stringRes(R.string.nest_reactions_button),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+308
-406
@@ -20,10 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,21 +27,20 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.AssistChipDefaults
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -53,6 +48,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -60,14 +56,12 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
|
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid
|
import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid
|
||||||
@@ -82,10 +76,21 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTa
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen layout for [NestActivity]. Renders title + summary,
|
* Full-screen layout for [NestActivity]. Vertically split into:
|
||||||
* host/speaker/audience rows (with active-speaker rings), connection chip
|
*
|
||||||
* + mute, talk row (when allowed), hand-raise, and a Leave button that
|
* 1. TopAppBar — room title + overflow menu (Share, host's Edit).
|
||||||
* finishes the activity.
|
* 2. Header strip — LIVE chip, listener count, optional 1-line summary.
|
||||||
|
* 3. Stage — vertical adaptive grid of host/speakers (height-bounded
|
||||||
|
* so a 30-speaker room scrolls inside the strip and never pushes
|
||||||
|
* the chat below the fold).
|
||||||
|
* 4. Tabs — `Chat | Audience · N | Hands · N` (Hands host-only,
|
||||||
|
* shown only while there's at least one raised hand).
|
||||||
|
* 5. Tab content — fills the remaining vertical space:
|
||||||
|
* - Chat: the kind-1311 transcript + composer
|
||||||
|
* - Audience: lazy vertical grid (handles 1,000+ listeners)
|
||||||
|
* - Hands: host's promote-to-speaker queue
|
||||||
|
* 6. Sticky action bar — connection / talk / hand / react / leave
|
||||||
|
* controls; never scrolls.
|
||||||
*
|
*
|
||||||
* The PIP variant lives in [NestPipScreen]; the activity flips
|
* The PIP variant lives in [NestPipScreen]; the activity flips
|
||||||
* between them based on `isInPipMode`.
|
* between them based on `isInPipMode`.
|
||||||
@@ -105,16 +110,61 @@ internal fun NestFullScreen(
|
|||||||
var showEditSheet by rememberSaveable { mutableStateOf(false) }
|
var showEditSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
var showHostMenu by rememberSaveable { mutableStateOf(false) }
|
var showHostMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
|
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
// Tab selection survives configuration changes and PIP transitions.
|
||||||
|
// Stored as ordinal so rememberSaveable can persist it without a
|
||||||
|
// custom Saver.
|
||||||
|
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
|
||||||
|
|
||||||
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
|
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
|
||||||
|
val myPubkey = accountViewModel.account.signer.pubKey
|
||||||
|
val isOnStageMe = remember(onStage, myPubkey) { onStage.any { it.pubKey == myPubkey } }
|
||||||
val leaveScope = rememberCoroutineScope()
|
val leaveScope = rememberCoroutineScope()
|
||||||
val topBarContext = LocalContext.current
|
val topBarContext = LocalContext.current
|
||||||
|
|
||||||
// Scaffold owns safeDrawing insets via its `contentWindowInsets`
|
val presences by viewModel.presences.collectAsState()
|
||||||
// default, so we don't manage them manually anymore. The
|
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
||||||
// container is transparent because NestThemedScope's outer
|
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
||||||
// Surface already paints the themed background (and overlays
|
|
||||||
// the optional `bg` image on top of it); painting again here
|
val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() }
|
||||||
// would double up.
|
val participantGrid =
|
||||||
|
remember(event, presences) {
|
||||||
|
buildParticipantGrid(
|
||||||
|
participants = event.participants(),
|
||||||
|
presences = presences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Same logic HandRaiseQueueSection uses internally — duplicated
|
||||||
|
// here so the tab label can show a count without coupling the
|
||||||
|
// section to the screen.
|
||||||
|
val handsCount =
|
||||||
|
remember(presences, onStageKeys) {
|
||||||
|
presences.values.count { it.handRaised && it.pubkey !in onStageKeys }
|
||||||
|
}
|
||||||
|
val showHandsTab = isHost && handsCount > 0
|
||||||
|
|
||||||
|
// Tab roster changes when the Hands tab appears/disappears.
|
||||||
|
// If the user was on Hands and the queue empties, fall back to
|
||||||
|
// Chat — kept as a stable default rather than Audience because
|
||||||
|
// chat is the room's primary engagement surface.
|
||||||
|
val tabs =
|
||||||
|
remember(showHandsTab) {
|
||||||
|
buildList {
|
||||||
|
add(NestTab.Chat)
|
||||||
|
add(NestTab.Audience)
|
||||||
|
if (showHandsTab) add(NestTab.Hands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val effectiveTab = tabs.getOrNull(selectedTabIndex) ?: NestTab.Chat
|
||||||
|
|
||||||
|
// Long-press on any participant opens the host-actions sheet
|
||||||
|
// (T2 #2). The sheet itself gates which rows render based on
|
||||||
|
// host status. Skip self.
|
||||||
|
val onLongPressParticipant: ((String) -> Unit) = { target ->
|
||||||
|
if (target != myPubkey) hostMenuTarget = target
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
@@ -135,185 +185,153 @@ internal fun NestFullScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
bottomBar = {
|
||||||
|
NestActionBar(
|
||||||
|
viewModel = viewModel,
|
||||||
|
ui = ui,
|
||||||
|
isOnStage = isOnStageMe,
|
||||||
|
canBroadcast = viewModel.canBroadcast,
|
||||||
|
speakerPubkeyHex = myPubkey,
|
||||||
|
handRaised = handRaised,
|
||||||
|
onHandRaisedChange = onHandRaisedChange,
|
||||||
|
onShowReactionPicker = { showReactionPicker = true },
|
||||||
|
onLeave = {
|
||||||
|
if (isHost) {
|
||||||
|
showHostLeaveConfirm = true
|
||||||
|
} else {
|
||||||
|
onLeave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// Inner column is just the two weighted siblings — top
|
|
||||||
// metadata (scrolls internally if overflow) and the chat
|
|
||||||
// panel (takes the rest). Title and overflow menu live in
|
|
||||||
// the TopAppBar above.
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
Column(
|
RoomHeaderStrip(
|
||||||
modifier =
|
summary = event.summary(),
|
||||||
Modifier
|
listenerCount = presences.size,
|
||||||
.weight(1f)
|
)
|
||||||
.verticalScroll(rememberScrollState())
|
StageGrid(
|
||||||
.padding(horizontal = 16.dp),
|
members = participantGrid.onStage,
|
||||||
) {
|
speakingNow = ui.speakingNow,
|
||||||
event.summary()?.let {
|
accountViewModel = accountViewModel,
|
||||||
Text(
|
reactionsByPubkey = reactionsByPubkey,
|
||||||
text = it,
|
connectingSpeakers = ui.connectingSpeakers,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
NestTabRow(
|
||||||
|
tabs = tabs,
|
||||||
// Listener counter — counts every active kind-10312 presence
|
selectedTab = effectiveTab,
|
||||||
// in the room. Hidden until the aggregator has at least one
|
audienceCount = participantGrid.audience.size,
|
||||||
// entry so the placeholder doesn't flash on entry.
|
handsCount = handsCount,
|
||||||
val presences by viewModel.presences.collectAsState()
|
onSelect = { tab -> selectedTabIndex = tabs.indexOf(tab).coerceAtLeast(0) },
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
)
|
||||||
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
|
when (effectiveTab) {
|
||||||
// Long-press opens the participant context sheet for ANYONE
|
NestTab.Chat -> {
|
||||||
// (T2 #2). The sheet's own gating decides which rows to show
|
NestChatPanel(
|
||||||
// (follow/mute always; promote/demote/kick host-only).
|
|
||||||
val onLongPressParticipant: ((String) -> Unit) = { target ->
|
|
||||||
if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target
|
|
||||||
}
|
|
||||||
// Tier-2 #1: replace the two LazyRow sections with a single
|
|
||||||
// pure-projection ParticipantGrid. The `absent` flag (member
|
|
||||||
// promoted in the kind-30312 but never emitted a kind-10312)
|
|
||||||
// greys out at 50 % alpha, matching nostrnests' web client.
|
|
||||||
val participantGrid =
|
|
||||||
androidx.compose.runtime.remember(event, presences) {
|
|
||||||
buildParticipantGrid(
|
|
||||||
participants = event.participants(),
|
|
||||||
presences = presences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ParticipantsGrid(
|
|
||||||
grid = participantGrid,
|
|
||||||
speakingNow = ui.speakingNow,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
onStageLabel = stringRes(R.string.nest_stage),
|
|
||||||
audienceLabel = stringRes(R.string.nest_audience),
|
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
|
||||||
connectingSpeakers = ui.connectingSpeakers,
|
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
|
||||||
)
|
|
||||||
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
|
||||||
hostMenuTarget?.let { target ->
|
|
||||||
ParticipantHostActionsSheet(
|
|
||||||
target = target,
|
|
||||||
event = event,
|
event = event,
|
||||||
|
viewModel = viewModel,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onDismiss = { hostMenuTarget = null },
|
modifier =
|
||||||
catalog = speakerCatalogs[target],
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHost) {
|
NestTab.Audience -> {
|
||||||
|
AudienceGrid(
|
||||||
|
members = participantGrid.audience,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NestTab.Hands -> {
|
||||||
HandRaiseQueueSection(
|
HandRaiseQueueSection(
|
||||||
event = event,
|
event = event,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
modifier =
|
||||||
}
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
ConnectionRow(viewModel = viewModel, ui = ui)
|
.padding(horizontal = 16.dp),
|
||||||
|
|
||||||
val myPubkey = accountViewModel.account.signer.pubKey
|
|
||||||
if (viewModel.canBroadcast && onStage.any { it.pubKey == myPubkey }) {
|
|
||||||
TalkRow(viewModel = viewModel, ui = ui, speakerPubkeyHex = myPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
FilledTonalIconToggleButton(
|
|
||||||
checked = handRaised,
|
|
||||||
onCheckedChange = onHandRaisedChange,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
symbol = MaterialSymbols.PanTool,
|
|
||||||
contentDescription =
|
|
||||||
stringRes(
|
|
||||||
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { showReactionPicker = true }) {
|
|
||||||
Text(stringRes(R.string.nest_reactions_button))
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
if (isHost) {
|
|
||||||
showHostLeaveConfirm = true
|
|
||||||
} else {
|
|
||||||
onLeave()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringRes(R.string.nest_leave))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showReactionPicker) {
|
|
||||||
RoomReactionPickerSheet(
|
|
||||||
onPick = { emoji ->
|
|
||||||
accountViewModel.reactToOrDelete(roomNote, emoji)
|
|
||||||
},
|
|
||||||
onDismiss = { showReactionPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showHostLeaveConfirm) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showHostLeaveConfirm = false },
|
|
||||||
title = { Text(stringRes(R.string.nest_leave_host_title)) },
|
|
||||||
text = { Text(stringRes(R.string.nest_leave_host_body)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
|
||||||
onClick = {
|
|
||||||
showHostLeaveConfirm = false
|
|
||||||
leaveScope.launch {
|
|
||||||
val ok = closeMeetingSpace(accountViewModel, event)
|
|
||||||
if (!ok) {
|
|
||||||
accountViewModel.toastManager.toast(
|
|
||||||
R.string.nests,
|
|
||||||
R.string.nest_leave_host_close_failed,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onLeave()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringRes(R.string.nest_leave_host_close))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showHostLeaveConfirm = false
|
|
||||||
onLeave()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringRes(R.string.nest_leave_host_just_leave))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NestChatPanel(
|
|
||||||
event = event,
|
|
||||||
viewModel = viewModel,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditNestSheet renders as a ModalBottomSheet — placement in
|
// Sheets and dialogs render alongside the Scaffold so they
|
||||||
// the tree doesn't affect layout, but keeping it adjacent to
|
// cover the room content (including the action bar) when
|
||||||
// the Scaffold makes the dialog/sheet boundary obvious.
|
// open. ParticipantHostActionsSheet stays outside the Column
|
||||||
|
// for the same reason — modal bottom sheets are not affected
|
||||||
|
// by parent layout.
|
||||||
|
hostMenuTarget?.let { target ->
|
||||||
|
ParticipantHostActionsSheet(
|
||||||
|
target = target,
|
||||||
|
event = event,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onDismiss = { hostMenuTarget = null },
|
||||||
|
catalog = speakerCatalogs[target],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showReactionPicker) {
|
||||||
|
RoomReactionPickerSheet(
|
||||||
|
onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) },
|
||||||
|
onDismiss = { showReactionPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showHostLeaveConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showHostLeaveConfirm = false },
|
||||||
|
title = { Text(stringRes(R.string.nest_leave_host_title)) },
|
||||||
|
text = { Text(stringRes(R.string.nest_leave_host_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
onClick = {
|
||||||
|
showHostLeaveConfirm = false
|
||||||
|
leaveScope.launch {
|
||||||
|
val ok = closeMeetingSpace(accountViewModel, event)
|
||||||
|
if (!ok) {
|
||||||
|
accountViewModel.toastManager.toast(
|
||||||
|
R.string.nests,
|
||||||
|
R.string.nest_leave_host_close_failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onLeave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.nest_leave_host_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showHostLeaveConfirm = false
|
||||||
|
onLeave()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.nest_leave_host_just_leave))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showEditSheet) {
|
if (showEditSheet) {
|
||||||
EditNestSheet(
|
EditNestSheet(
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
@@ -323,15 +341,121 @@ internal fun NestFullScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header strip rendered between the TopAppBar and the stage grid.
|
||||||
|
* Carries the LIVE chip + listener count and (when present) a single-
|
||||||
|
* line ellipsised summary. Lives at the screen level rather than in
|
||||||
|
* the TopAppBar so the chip + count have room to breathe and the
|
||||||
|
* summary's typography matches the body, not the title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RoomHeaderStrip(
|
||||||
|
summary: String?,
|
||||||
|
listenerCount: Int,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
LiveChip()
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(R.plurals.nest_listener_count, listenerCount, listenerCount),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!summary.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveChip() {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.nest_live_chip),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab-bar between the stage and the tab content. Audience / Hands
|
||||||
|
* tabs carry a count badge so the user knows whether switching tabs
|
||||||
|
* is worthwhile (e.g. "Hands · 3" tells the host they have a queue
|
||||||
|
* to attend to without ever leaving the chat).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NestTabRow(
|
||||||
|
tabs: List<NestTab>,
|
||||||
|
selectedTab: NestTab,
|
||||||
|
audienceCount: Int,
|
||||||
|
handsCount: Int,
|
||||||
|
onSelect: (NestTab) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0)
|
||||||
|
PrimaryTabRow(
|
||||||
|
selectedTabIndex = selectedIndex,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
) {
|
||||||
|
tabs.forEach { tab ->
|
||||||
|
val (label, count) =
|
||||||
|
when (tab) {
|
||||||
|
NestTab.Chat -> stringRes(R.string.nest_tab_chat) to 0
|
||||||
|
NestTab.Audience -> stringRes(R.string.nest_tab_audience) to audienceCount
|
||||||
|
NestTab.Hands -> stringRes(R.string.nest_tab_hands) to handsCount
|
||||||
|
}
|
||||||
|
Tab(
|
||||||
|
selected = tab == selectedTab,
|
||||||
|
onClick = { onSelect(tab) },
|
||||||
|
text = {
|
||||||
|
if (count > 0) {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
Badge { Text(text = count.toString()) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = label)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(text = label)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class NestTab {
|
||||||
|
Chat,
|
||||||
|
Audience,
|
||||||
|
Hands,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TopAppBar for the room screen — hosts the room name and the
|
* TopAppBar for the room screen — hosts the room name and the
|
||||||
* overflow menu (Share for everyone, Edit for the host). The menu
|
* overflow menu (Share for everyone, Edit for the host). The menu
|
||||||
* state lives at the screen level so the EditNestSheet can be
|
* state lives at the screen level so the EditNestSheet can be
|
||||||
* triggered from here and rendered alongside the Scaffold.
|
* triggered from here and rendered alongside the Scaffold.
|
||||||
*
|
*
|
||||||
* Container color is transparent so the themed background painted
|
* Container color is transparent so the surface beneath the
|
||||||
* by [NestThemedScope]'s Surface (and any optional `bg` image) shows
|
* Scaffold (set by the activity / parent theme) shows through.
|
||||||
* through cleanly.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -384,228 +508,6 @@ private fun NestTopAppBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ConnectionRow(
|
|
||||||
viewModel: NestViewModel,
|
|
||||||
ui: NestUiState,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
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.Connecting -> {
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
label = { Text(connectingLabel(connection)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ConnectionUiState.Reconnecting -> {
|
|
||||||
// The wrapper retries on its own; the user typically
|
|
||||||
// doesn't need to do anything. Keep the mute toggle
|
|
||||||
// hidden during this window — there's no live session
|
|
||||||
// to apply mute against, and showing it implies a
|
|
||||||
// healthier connection than we have.
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
label = { Text(stringRes(R.string.nest_reconnecting)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ConnectionUiState.Connected -> {
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
label = { Text(stringRes(R.string.nest_connected)) },
|
|
||||||
colors =
|
|
||||||
AssistChipDefaults.assistChipColors(
|
|
||||||
disabledLabelColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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 -> {
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.nest_audio_failed, connection.reason),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Button(onClick = { viewModel.connect() }) {
|
|
||||||
Text(stringRes(R.string.nest_connect))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TalkRow(
|
|
||||||
viewModel: NestViewModel,
|
|
||||||
ui: NestUiState,
|
|
||||||
speakerPubkeyHex: String,
|
|
||||||
) {
|
|
||||||
// Render whenever the user can act on broadcast state. The
|
|
||||||
// speaker session is independent of the listener: a transient
|
|
||||||
// listener Reconnecting must NOT hide the mic-mute / Stop
|
|
||||||
// controls if the broadcast is in flight, otherwise the user
|
|
||||||
// can't pause their own mic during a network blip.
|
|
||||||
val canAct =
|
|
||||||
ui.connection is ConnectionUiState.Connected ||
|
|
||||||
ui.broadcast is BroadcastUiState.Broadcasting ||
|
|
||||||
ui.broadcast is BroadcastUiState.Connecting
|
|
||||||
if (!canAct) return
|
|
||||||
val context = LocalContext.current
|
|
||||||
var permissionDenied by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val permissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
permissionDenied = false
|
|
||||||
viewModel.startBroadcast(speakerPubkeyHex)
|
|
||||||
} else {
|
|
||||||
permissionDenied = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If the user grants RECORD_AUDIO via the system Settings deep-link
|
|
||||||
// and returns to the activity, the permissionLauncher callback never
|
|
||||||
// fires and `permissionDenied` would otherwise stay true. Recompute
|
|
||||||
// every time the permission state could have changed (audit round-2
|
|
||||||
// Android #12).
|
|
||||||
val showDenialWarning =
|
|
||||||
permissionDenied &&
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
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.nest_talk))
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
|
|
||||||
Text(stringRes(R.string.nest_leave_stage))
|
|
||||||
}
|
|
||||||
if (showDenialWarning) {
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.nest_record_permission_required),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
// After "Don't ask again" the permission launcher
|
|
||||||
// silently returns false. Give the user a path to
|
|
||||||
// toggle the permission in system settings (audit
|
|
||||||
// Android #14).
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { viewModel.stopBroadcast() }) {
|
|
||||||
Text(stringRes(R.string.nest_stop_talking))
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
viewModel.stopBroadcast()
|
|
||||||
viewModel.setOnStage(false)
|
|
||||||
}) {
|
|
||||||
Text(stringRes(R.string.nest_leave_stage))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is BroadcastUiState.Failed -> {
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.nest_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.nest_talk))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-publish the host's [event] with `status="closed"`, preserving every
|
* Re-publish the host's [event] with `status="closed"`, preserving every
|
||||||
* other field verbatim (room name, summary, image, service, endpoint,
|
* other field verbatim (room name, summary, image, service, endpoint,
|
||||||
|
|||||||
+169
-162
@@ -28,19 +28,20 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
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.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -55,75 +56,127 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid
|
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
|
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
|
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
|
||||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
|
|
||||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
|
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
private val STAGE_CELL_MIN = 88.dp
|
||||||
|
private val STAGE_AVATAR = 48.dp
|
||||||
|
private val AUDIENCE_CELL_MIN = 76.dp
|
||||||
|
private val AUDIENCE_AVATAR = Size40dp
|
||||||
|
private val GRID_SPACING = 6.dp
|
||||||
|
|
||||||
|
// Two rows visible by default. Each cell is roughly avatar + name +
|
||||||
|
// reaction headroom, so this lifts to ~200dp on most phones — tall
|
||||||
|
// enough to show 6-8 speakers without scrolling but small enough to
|
||||||
|
// leave the chat / audience tab the majority of the viewport.
|
||||||
|
private val STAGE_MAX_HEIGHT = 220.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material3 grid for the room participants — replaces the
|
* Vertical adaptive grid for the on-stage section. Used as the
|
||||||
* horizontal LazyRow layout for rooms with many speakers /
|
* always-visible header strip in the room layout: speakers flow into
|
||||||
* audience members. On-stage and audience render as separate
|
* as many columns as the screen width allows, wrapping to a new row
|
||||||
* fixed-height horizontal grids (one row per group, scrolls
|
* once full. Capped at [STAGE_MAX_HEIGHT] so a 30-speaker room can
|
||||||
* horizontally) so the chat panel below stays at a predictable
|
* scroll inside the strip without pushing the tabs / chat below the
|
||||||
* vertical position even when the room fills up.
|
* fold.
|
||||||
*
|
*
|
||||||
* Falls open: when [grid] is empty (room with no presence yet),
|
* Falls open: when [members] is empty, renders nothing.
|
||||||
* neither section renders.
|
|
||||||
*
|
|
||||||
* Absent members (kind-30312 `p`-tag with no kind-10312 presence)
|
|
||||||
* render at 50% alpha — matches nostrnests' grey-out for
|
|
||||||
* "promoted but never joined".
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ParticipantsGrid(
|
internal fun StageGrid(
|
||||||
grid: ParticipantGrid,
|
members: List<RoomMember>,
|
||||||
speakingNow: ImmutableSet<String>,
|
speakingNow: ImmutableSet<String>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onStageLabel: String,
|
|
||||||
audienceLabel: String,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||||
connectingSpeakers: ImmutableSet<String> = kotlinx.collections.immutable.persistentSetOf(),
|
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
|
||||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
if (members.isEmpty()) return
|
||||||
if (grid.onStage.isNotEmpty()) {
|
Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||||
ParticipantsSection(
|
Text(
|
||||||
title = onStageLabel,
|
text = stringRes(R.string.nest_stage),
|
||||||
members = grid.onStage,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
avatarSize = Size40dp,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
speakingNow = speakingNow,
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
connectingSpeakers = connectingSpeakers,
|
)
|
||||||
showMicBadge = true,
|
LazyVerticalGrid(
|
||||||
accountViewModel = accountViewModel,
|
columns = GridCells.Adaptive(STAGE_CELL_MIN),
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT),
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||||
|
) {
|
||||||
|
items(items = members, key = { it.pubkey }) { member ->
|
||||||
|
MemberCell(
|
||||||
|
member = member,
|
||||||
|
avatarSize = STAGE_AVATAR,
|
||||||
|
isSpeaking = member.pubkey in speakingNow,
|
||||||
|
isConnecting = member.pubkey in connectingSpeakers,
|
||||||
|
showMicBadge = true,
|
||||||
|
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical adaptive grid for the audience tab. Sized to fill the
|
||||||
|
* remaining vertical space its parent gives it (`Modifier.weight(1f)`)
|
||||||
|
* so a room with 1,000 listeners scrolls efficiently — the lazy grid
|
||||||
|
* only composes the rows currently visible.
|
||||||
|
*
|
||||||
|
* Audience cells deliberately omit the speaking ring, mic badge and
|
||||||
|
* connecting spinner: only members with a live broadcast subscription
|
||||||
|
* earn those, and audience by definition aren't broadcasting. Hand
|
||||||
|
* badge still renders because the audience is the hand-raise queue.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AudienceGrid(
|
||||||
|
members: List<RoomMember>,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (members.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.nest_audience_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (grid.audience.isNotEmpty()) {
|
return
|
||||||
Spacer(Modifier.height(8.dp))
|
}
|
||||||
ParticipantsSection(
|
LazyVerticalGrid(
|
||||||
title = audienceLabel,
|
columns = GridCells.Adaptive(AUDIENCE_CELL_MIN),
|
||||||
members = grid.audience,
|
modifier = modifier.fillMaxSize().padding(horizontal = 4.dp),
|
||||||
avatarSize = Size35dp,
|
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||||
// Audience rows don't get the speaking-ring, mic-state
|
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||||
// pill, or buffering overlay — only members with a
|
contentPadding =
|
||||||
// live broadcast subscription do. Hand-raise badge
|
androidx.compose.foundation.layout
|
||||||
// still renders (audience is the queue).
|
.PaddingValues(vertical = 8.dp),
|
||||||
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
|
) {
|
||||||
connectingSpeakers = kotlinx.collections.immutable.persistentSetOf(),
|
items(items = members, key = { it.pubkey }) { member ->
|
||||||
|
MemberCell(
|
||||||
|
member = member,
|
||||||
|
avatarSize = AUDIENCE_AVATAR,
|
||||||
|
isSpeaking = false,
|
||||||
|
isConnecting = false,
|
||||||
showMicBadge = false,
|
showMicBadge = false,
|
||||||
|
reactions = emptyList(),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
reactionsByPubkey = emptyMap(),
|
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,126 +184,80 @@ internal fun ParticipantsGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ParticipantsSection(
|
private fun MemberCell(
|
||||||
title: String,
|
member: RoomMember,
|
||||||
members: List<RoomMember>,
|
|
||||||
avatarSize: Dp,
|
avatarSize: Dp,
|
||||||
speakingNow: ImmutableSet<String>,
|
isSpeaking: Boolean,
|
||||||
connectingSpeakers: ImmutableSet<String>,
|
isConnecting: Boolean,
|
||||||
showMicBadge: Boolean,
|
showMicBadge: Boolean,
|
||||||
|
reactions: List<RoomReaction>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
reactionsByPubkey: Map<String, List<RoomReaction>>,
|
|
||||||
onLongPressParticipant: ((String) -> Unit)?,
|
onLongPressParticipant: ((String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val ringColor = MaterialTheme.colorScheme.primary
|
val ringColor = MaterialTheme.colorScheme.primary
|
||||||
// Hoist the size/spacing modifiers — same Dp values for every
|
val avatarModifier =
|
||||||
// cell, so allocating them once per section beats allocating
|
when {
|
||||||
// per-cell-recompose with N speakers.
|
isSpeaking && member.absent -> Modifier.border(2.dp, ringColor, CircleShape).then(Modifier.alpha(0.5f))
|
||||||
val gridModifier =
|
isSpeaking -> Modifier.border(2.dp, ringColor, CircleShape)
|
||||||
remember(avatarSize) {
|
member.absent -> Modifier.alpha(0.5f)
|
||||||
Modifier
|
else -> Modifier
|
||||||
.fillMaxWidth()
|
|
||||||
.height(avatarSize + 48.dp)
|
|
||||||
.padding(top = 4.dp)
|
|
||||||
}
|
}
|
||||||
val cellWidthModifier = remember(avatarSize) { Modifier.width(avatarSize + 16.dp) }
|
val user =
|
||||||
val absentAlphaModifier = remember { Modifier.alpha(0.5f) }
|
remember(member.pubkey) {
|
||||||
val speakingBorderModifier = remember(ringColor) { Modifier.border(2.dp, ringColor, CircleShape) }
|
com.vitorpamplona.amethyst.model.LocalCache
|
||||||
val spinnerModifier = remember(avatarSize) { Modifier.size(avatarSize - 8.dp) }
|
.getOrCreateUser(member.pubkey)
|
||||||
Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = SpacedBy5dp) {
|
}
|
||||||
Text(
|
// Cache the long-click adapter per (pubkey, callback) tuple so a
|
||||||
text = title,
|
// recompose during a connectingSpeakers / speakingNow flip doesn't
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// allocate a fresh lambda for every cell.
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
val onLongClick =
|
||||||
)
|
remember(member.pubkey, onLongPressParticipant) {
|
||||||
// Single-row horizontal grid. Cell height = avatar +
|
onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } }
|
||||||
// reaction overlay headroom + a username line below; the
|
}
|
||||||
// grid auto-fits the avatar + name + (optional) reaction
|
Column(
|
||||||
// bubble vertically without clipping.
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
LazyHorizontalGrid(
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
rows = GridCells.Fixed(1),
|
) {
|
||||||
modifier = gridModifier,
|
Box(contentAlignment = Alignment.Center) {
|
||||||
horizontalArrangement = SpacedBy5dp,
|
ClickableUserPicture(
|
||||||
) {
|
baseUserHex = member.pubkey,
|
||||||
items(items = members, key = { it.pubkey }) { member ->
|
size = avatarSize,
|
||||||
val isSpeaking = member.pubkey in speakingNow
|
accountViewModel = accountViewModel,
|
||||||
val avatarModifier =
|
modifier = avatarModifier,
|
||||||
when {
|
onLongClick = onLongClick,
|
||||||
isSpeaking && member.absent -> speakingBorderModifier.then(absentAlphaModifier)
|
)
|
||||||
isSpeaking -> speakingBorderModifier
|
if (isConnecting) {
|
||||||
member.absent -> absentAlphaModifier
|
CircularProgressIndicator(
|
||||||
else -> Modifier
|
modifier = Modifier.size(avatarSize - 8.dp),
|
||||||
}
|
strokeWidth = 2.dp,
|
||||||
val user =
|
color = MaterialTheme.colorScheme.primary,
|
||||||
remember(member.pubkey) {
|
)
|
||||||
com.vitorpamplona.amethyst.model.LocalCache
|
|
||||||
.getOrCreateUser(member.pubkey)
|
|
||||||
}
|
|
||||||
// Cache the long-click adapter per (pubkey, callback)
|
|
||||||
// tuple — `onLongPressParticipant?.let { cb -> { hex -> cb(hex) } }`
|
|
||||||
// would otherwise allocate a fresh lambda on every
|
|
||||||
// recompose, which adds up across N members during a
|
|
||||||
// connectingSpeakers / speakingNow flip.
|
|
||||||
val onLongClick =
|
|
||||||
remember(member.pubkey, onLongPressParticipant) {
|
|
||||||
onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } }
|
|
||||||
}
|
|
||||||
val isConnecting = member.pubkey in connectingSpeakers
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = SpacedBy10dp,
|
|
||||||
modifier = cellWidthModifier,
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
ClickableUserPicture(
|
|
||||||
baseUserHex = member.pubkey,
|
|
||||||
size = avatarSize,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
modifier = avatarModifier,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
)
|
|
||||||
if (isConnecting) {
|
|
||||||
// Pre-roll buffering overlay — visible
|
|
||||||
// between SUBSCRIBE_OK and the first
|
|
||||||
// decoded frame (typically 0.5-2 s on a
|
|
||||||
// fresh subscription). Sized smaller than
|
|
||||||
// the avatar so the user picture stays
|
|
||||||
// recognisable underneath.
|
|
||||||
androidx.compose.material3.CircularProgressIndicator(
|
|
||||||
modifier = spinnerModifier,
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (member.handRaised) {
|
|
||||||
HandRaiseBadge(
|
|
||||||
avatarSize = avatarSize,
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showMicBadge && member.publishing) {
|
|
||||||
MicStateBadge(
|
|
||||||
isSpeaking = isSpeaking,
|
|
||||||
isMuted = member.muted == true,
|
|
||||||
avatarSize = avatarSize,
|
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UsernameDisplay(
|
|
||||||
baseUser = user,
|
|
||||||
weight = Modifier.fillMaxWidth(),
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
)
|
|
||||||
val reactions = reactionsByPubkey[member.pubkey].orEmpty()
|
|
||||||
if (reactions.isNotEmpty()) {
|
|
||||||
SpeakerReactionOverlay(
|
|
||||||
reactions = reactions,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (member.handRaised) {
|
||||||
|
HandRaiseBadge(
|
||||||
|
avatarSize = avatarSize,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showMicBadge && member.publishing) {
|
||||||
|
MicStateBadge(
|
||||||
|
isSpeaking = isSpeaking,
|
||||||
|
isMuted = member.muted == true,
|
||||||
|
avatarSize = avatarSize,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UsernameDisplay(
|
||||||
|
baseUser = user,
|
||||||
|
weight = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
if (reactions.isNotEmpty()) {
|
||||||
|
SpeakerReactionOverlay(
|
||||||
|
reactions = reactions,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,6 +479,94 @@
|
|||||||
<string name="nest_chat_send_failed_title">无法发送信息</string>
|
<string name="nest_chat_send_failed_title">无法发送信息</string>
|
||||||
<string name="nest_no_app_to_open_link">没有安装打开此链接的应用。</string>
|
<string name="nest_no_app_to_open_link">没有安装打开此链接的应用。</string>
|
||||||
<string name="nest_close_room_confirm_title">关闭这个聊天室吗?</string>
|
<string name="nest_close_room_confirm_title">关闭这个聊天室吗?</string>
|
||||||
|
<string name="nest_close_room_confirm_body">所有参与者将被断开连接。该房间会在源中显示为已关闭的。</string>
|
||||||
|
<string name="nest_close_room_confirm_action">关闭聊天室</string>
|
||||||
|
<string name="nest_create_when_in_past">选择未来的开始时间。</string>
|
||||||
|
<string name="nest_audio_failed">音频失败: %1$s</string>
|
||||||
|
<string name="nest_audio_unavailable">此聊天室没有音频可用</string>
|
||||||
|
<string name="nest_talk">交谈</string>
|
||||||
|
<string name="nest_stop_talking">停止交谈</string>
|
||||||
|
<string name="nest_leave_stage">离开舞台</string>
|
||||||
|
<string name="nest_mic_mute">麦克风静音</string>
|
||||||
|
<string name="nest_mic_unmute">取消麦克风静音</string>
|
||||||
|
<string name="nest_broadcast_connecting">正在开始广播…</string>
|
||||||
|
<string name="nest_broadcasting">直播中</string>
|
||||||
|
<string name="nest_broadcast_failed">广播失败: %1$s</string>
|
||||||
|
<string name="nest_record_permission_required">在这个房间进行交谈需要麦克风权限。</string>
|
||||||
|
<string name="nest_open_settings">打开设置</string>
|
||||||
|
<string name="nest_notification_channel">音频室</string>
|
||||||
|
<string name="nest_notification_channel_description">当聊天室开放时保持音频播放。</string>
|
||||||
|
<string name="nest_notification_listening">已连接音频聊天室</string>
|
||||||
|
<string name="nest_notification_broadcasting">语音聊天室 — 在线</string>
|
||||||
|
<string name="nest_notification_text">轻触返回。</string>
|
||||||
|
<string name="nest_notification_stop">停止</string>
|
||||||
|
<string name="nest_join">加入语音聊天室</string>
|
||||||
|
<string name="nest_lobby_host_label">主持人</string>
|
||||||
|
<string name="nest_presence_raised_hand">举手</string>
|
||||||
|
<string name="nest_presence_on_stage">步入舞台</string>
|
||||||
|
<string name="nest_presence_speaking">正在说话</string>
|
||||||
|
<string name="nest_presence_listening">作为观众加入</string>
|
||||||
|
<string name="nest_presence_left">离开语音聊天室</string>
|
||||||
|
<string name="nest_leave">离开</string>
|
||||||
|
<string name="nest_leave_host_title">结束这个语音聊天室?</string>
|
||||||
|
<string name="nest_leave_host_body">您是主持人。关闭聊天室会断开所有人的连接。 如果您想稍后再回来,请选择\"仅离开\" — 该聊天室将在8小时不活动后自动关闭。</string>
|
||||||
|
<string name="nest_leave_host_close">关闭聊天室</string>
|
||||||
|
<string name="nest_leave_host_just_leave">仅离开</string>
|
||||||
|
<string name="nest_leave_host_close_failed">不能将聊天室标记为已关闭。仍然离开——聊天室将自动关闭。</string>
|
||||||
|
<plurals name="nest_listener_count">
|
||||||
|
<item quantity="other">%1$d 位听众</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="nest_chat_send">发送</string>
|
||||||
|
<string name="nest_chat_placeholder">说些什么…</string>
|
||||||
|
<string name="nest_chat_empty">还没有消息。成为第一个说话的。</string>
|
||||||
|
<string name="nest_reactions_title">回应</string>
|
||||||
|
<string name="nest_reactions_button">回应</string>
|
||||||
|
<string name="nest_edit_title">编辑聊天室</string>
|
||||||
|
<string name="nest_edit_save">保存</string>
|
||||||
|
<string name="nest_close_action">关闭聊天室</string>
|
||||||
|
<string name="nest_overflow_menu">聊天室操作</string>
|
||||||
|
<string name="nest_hand_raise_queue_title">举手</string>
|
||||||
|
<string name="nest_hand_raise_approve">批准</string>
|
||||||
|
<string name="nest_promote_speaker">提升为演讲者</string>
|
||||||
|
<string name="nest_demote_listener">降级为听众</string>
|
||||||
|
<string name="nest_kick_action">踢出</string>
|
||||||
|
<string name="nest_participant_view_profile">查看个人资料</string>
|
||||||
|
<string name="nest_participant_zap">发送打闪</string>
|
||||||
|
<string name="nest_participant_zap_split_unsupported">不支持在聊天内分割打闪。打开个人资料屏发送打闪。</string>
|
||||||
|
<string name="nest_participant_follow">关注</string>
|
||||||
|
<string name="nest_participant_unfollow">取关</string>
|
||||||
|
<string name="nest_participant_mute">静音</string>
|
||||||
|
<string name="nest_participant_unmute">取消静音</string>
|
||||||
|
<string name="nest_share_action">分享聊天室</string>
|
||||||
|
<string name="nest_create_fab">启动空间</string>
|
||||||
|
<string name="nest_no_server_title">设置语音聊天室服务器</string>
|
||||||
|
<string name="nest_no_server_body">您尚未选择语音聊天服务器。添加 %1$s 到您的服务器列表并继续吗?\n\n您可以稍后在设置中更改此设置。</string>
|
||||||
|
<string name="nest_no_server_use_default">使用默认</string>
|
||||||
|
<string name="nest_no_server_cancel">取消</string>
|
||||||
|
<string name="nest_no_server_save_failed">无法保存您的语音聊天服务器列表。请重试。</string>
|
||||||
|
<string name="nest_create_title">启动新的语音聊天室</string>
|
||||||
|
<string name="nest_create_field_room">聊天室名称</string>
|
||||||
|
<string name="nest_create_field_summary">这是什么?</string>
|
||||||
|
<string name="nest_create_field_service">MoQ 服务 URL</string>
|
||||||
|
<string name="nest_create_field_service_hint">Auth sidecar —— 默认 nostrnests.com</string>
|
||||||
|
<string name="nest_create_field_endpoint">MoQ 中继端点</string>
|
||||||
|
<string name="nest_create_field_endpoint_hint">WebTransport URL - 通常是同一个主机</string>
|
||||||
|
<string name="nest_create_field_image">封面图片URL (可选)</string>
|
||||||
|
<string name="nest_create_cancel">取消</string>
|
||||||
|
<string name="nest_create_submit">启动空间</string>
|
||||||
|
<string name="nest_create_schedule_toggle">计划稍后进行</string>
|
||||||
|
<string name="nest_create_when">选择开始时间</string>
|
||||||
|
<string name="nests_servers_title">语音聊天服务器</string>
|
||||||
|
<string name="nests_servers_explainer">选择 Amethyst 将你的语音聊天室发布到哪些 MoQ 主机服务器。启动新空间时第一个条目将用作默认。</string>
|
||||||
|
<string name="nests_servers_my_section">您的服务器</string>
|
||||||
|
<string name="nests_servers_my_explainer">保存为 10112 类型的可替换事件,以便其他客户端可以读取您的首选项。</string>
|
||||||
|
<string name="nests_servers_add_field">添加语音聊天服务器</string>
|
||||||
|
<string name="nests_servers_recommended_section">推荐的服务器</string>
|
||||||
|
<string name="nests_servers_recommended_explainer">内置建议,您可轻触添加到列表中。</string>
|
||||||
|
<string name="nests_servers_use_defaults">使用 Amethyst 默认值</string>
|
||||||
|
<string name="nests_servers_add_recommended">添加服务器</string>
|
||||||
|
<string name="nests_servers_remove">移除服务器</string>
|
||||||
|
<string name="nests_servers_empty">暂无语音聊天服务器。在下方添加一个或选择推荐服务器。</string>
|
||||||
<string name="longs">视频</string>
|
<string name="longs">视频</string>
|
||||||
<string name="articles">文章</string>
|
<string name="articles">文章</string>
|
||||||
<string name="private_bookmarks">私人书签</string>
|
<string name="private_bookmarks">私人书签</string>
|
||||||
@@ -999,6 +1087,8 @@
|
|||||||
<string name="meeting_space_open_tag">开放</string>
|
<string name="meeting_space_open_tag">开放</string>
|
||||||
<string name="meeting_space_private_tag">私密</string>
|
<string name="meeting_space_private_tag">私密</string>
|
||||||
<string name="meeting_space_closed_tag">关闭</string>
|
<string name="meeting_space_closed_tag">关闭</string>
|
||||||
|
<string name="meeting_space_planned_tag">已计划</string>
|
||||||
|
<string name="meeting_space_planned_starts_at">启动 %1$s</string>
|
||||||
<string name="are_you_sure_you_want_to_log_out">登出将删除你的本地信息。 请确保备份你的私钥以避免失去你的帐户。你想要继续吗?</string>
|
<string name="are_you_sure_you_want_to_log_out">登出将删除你的本地信息。 请确保备份你的私钥以避免失去你的帐户。你想要继续吗?</string>
|
||||||
<string name="followed_tags">已关注的标签</string>
|
<string name="followed_tags">已关注的标签</string>
|
||||||
<string name="relay_setup">中继器</string>
|
<string name="relay_setup">中继器</string>
|
||||||
@@ -1797,6 +1887,7 @@
|
|||||||
<string name="kind_profile_badges">个人资料徽章</string>
|
<string name="kind_profile_badges">个人资料徽章</string>
|
||||||
<string name="kind_blocked_relays">中继黑名单</string>
|
<string name="kind_blocked_relays">中继黑名单</string>
|
||||||
<string name="kind_blossom_servers">Blossom 服务器</string>
|
<string name="kind_blossom_servers">Blossom 服务器</string>
|
||||||
|
<string name="kind_nests_servers">语音聊天服务器</string>
|
||||||
<string name="kind_blossom_auth">Blossom 认证</string>
|
<string name="kind_blossom_auth">Blossom 认证</string>
|
||||||
<string name="kind_broadcast_relays">广播中继</string>
|
<string name="kind_broadcast_relays">广播中继</string>
|
||||||
<string name="kind_bookmark_list">书签列表</string>
|
<string name="kind_bookmark_list">书签列表</string>
|
||||||
|
|||||||
@@ -547,6 +547,11 @@
|
|||||||
<item quantity="one">%1$d listener</item>
|
<item quantity="one">%1$d listener</item>
|
||||||
<item quantity="other">%1$d listeners</item>
|
<item quantity="other">%1$d listeners</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="nest_live_chip">LIVE</string>
|
||||||
|
<string name="nest_tab_chat">Chat</string>
|
||||||
|
<string name="nest_tab_audience">Audience</string>
|
||||||
|
<string name="nest_tab_hands">Hands</string>
|
||||||
|
<string name="nest_audience_empty">No one in the audience yet.</string>
|
||||||
<string name="nest_chat_send">Send</string>
|
<string name="nest_chat_send">Send</string>
|
||||||
<string name="nest_chat_placeholder">Say something…</string>
|
<string name="nest_chat_placeholder">Say something…</string>
|
||||||
<string name="nest_chat_empty">No messages yet. Be the first to chat.</string>
|
<string name="nest_chat_empty">No messages yet. Be the first to chat.</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user