fix(nests): UI polish — Stop button, unjoinable state, hand queue, DST
Audit-driven UI fixes that don't fit the perf bucket: * NestActionBar — wire StopBroadcastButton between MicMuteToggle and LeaveStageButton while broadcasting. The doc table at the top of the file already advertised [Mute] [Stop] [Leave the Stage] but the button was defined and never used; speakers had to leave the stage to stop sending audio. * NestActivityContent — render an explicit loading spinner while the kind-30312 resolves and a clear "this room can't be opened" page with a Back button when the event is missing service/endpoint. Replaces the previous black-screen dead end. * HandRaiseQueueSection — wrap filter+sort in remember so a heartbeat doesn't rebuild the queue on every recompose, swap to LazyColumn so a long queue doesn't render every row every frame, and show UsernameDisplay instead of the raw 8-char hex prefix. * CreateNestSheet schedule picker — compute the timezone offset at the picked instant rather than at Instant.now(); the previous variant was off by one hour for rooms scheduled across a DST transition. * NestViewModelFactory — pass OkHttpNestsClient's httpClient via named parameter so the (String) -> OkHttpClient function reference doesn't get bound to the Long callTimeoutMs slot. Fixes the pre-existing :amethyst:compilePlayDebugKotlin failure on the branch base. Adds three strings (loading, unjoinable title/body, back). https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
This commit is contained in:
+19
-17
@@ -331,23 +331,25 @@ private fun ScheduleStartPicker(
|
||||
TextButton(onClick = {
|
||||
val dayMillisUtc = datePickerState.selectedDateMillis
|
||||
if (dayMillisUtc != null) {
|
||||
// DatePicker hands back UTC midnight of the selected
|
||||
// calendar date. Add the picked hour:minute (local)
|
||||
// to its seconds, then subtract the local offset to
|
||||
// land on the correct UTC unix second — same shape
|
||||
// as ExpirationDatePicker.
|
||||
val localSeconds =
|
||||
dayMillisUtc / 1000L +
|
||||
timePickerState.hour * 3600L +
|
||||
timePickerState.minute * 60L
|
||||
val offsetSec =
|
||||
java.time.ZoneId
|
||||
.systemDefault()
|
||||
.rules
|
||||
.getOffset(java.time.Instant.now())
|
||||
.totalSeconds
|
||||
.toLong()
|
||||
onChange(localSeconds - offsetSec)
|
||||
// DatePicker hands back UTC midnight of the
|
||||
// selected calendar date. Reinterpret that
|
||||
// calendar date as local + the picked
|
||||
// hour:minute, then convert to UTC seconds
|
||||
// using the zone offset AT THE PICKED INSTANT.
|
||||
// The previous code used `Instant.now()`'s
|
||||
// offset, which was off by one hour for rooms
|
||||
// scheduled across a DST transition.
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val localDate =
|
||||
java.time.Instant
|
||||
.ofEpochMilli(dayMillisUtc)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
val pickedZdt =
|
||||
localDate
|
||||
.atTime(timePickerState.hour, timePickerState.minute)
|
||||
.atZone(zone)
|
||||
onChange(pickedZdt.toEpochSecond())
|
||||
}
|
||||
showTime = false
|
||||
}) { Text(stringRes(R.string.nest_create_submit)) }
|
||||
|
||||
+119
-32
@@ -21,6 +21,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -28,6 +37,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
@@ -46,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.PipBri
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.rememberNestViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestPipScreen
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -71,45 +86,117 @@ internal fun NestActivityContent(
|
||||
) {
|
||||
val parsedAddress = remember(addressValue) { Address.parse(addressValue) }
|
||||
if (parsedAddress == null) {
|
||||
// Malformed address — bail. The Activity will receive a Closed
|
||||
// signal through the VM teardown when the user finishes; until
|
||||
// then we just render nothing.
|
||||
// Malformed address — show the unjoinable state so the user
|
||||
// gets a clear path back instead of staring at a black screen
|
||||
// while the Activity sits behind a useless extra.
|
||||
UnjoinableRoomState(onLeave = onLeave)
|
||||
return
|
||||
}
|
||||
|
||||
LoadAddressableNote(parsedAddress, accountViewModel) { addressableNote ->
|
||||
addressableNote ?: return@LoadAddressableNote
|
||||
if (addressableNote == null) {
|
||||
// Note hasn't resolved yet — relay subscription is in
|
||||
// flight. Render a centered spinner so the user sees that
|
||||
// we're working on it (audit Android #6).
|
||||
LoadingRoomState()
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val event by observeNoteEvent<MeetingSpaceEvent>(addressableNote, accountViewModel)
|
||||
|
||||
event?.let {
|
||||
val service = it.service()
|
||||
val endPoint = it.endpoint()
|
||||
if (service != null && endPoint != null) {
|
||||
val viewModel =
|
||||
rememberNestViewModel(
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = service,
|
||||
endpoint = endPoint,
|
||||
hostPubkey = it.pubKey,
|
||||
roomId = it.dTag(),
|
||||
kind = it.kind,
|
||||
),
|
||||
accountViewModel.account.signer,
|
||||
)
|
||||
val current = event
|
||||
if (current == null) {
|
||||
LoadingRoomState()
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val service = current.service()
|
||||
val endPoint = current.endpoint()
|
||||
if (service.isNullOrBlank() || endPoint.isNullOrBlank()) {
|
||||
UnjoinableRoomState(onLeave = onLeave)
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val viewModel =
|
||||
rememberNestViewModel(
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = service,
|
||||
endpoint = endPoint,
|
||||
hostPubkey = current.pubKey,
|
||||
roomId = current.dTag(),
|
||||
kind = current.kind,
|
||||
),
|
||||
accountViewModel.account.signer,
|
||||
)
|
||||
|
||||
NestActivityBody(
|
||||
event = it,
|
||||
roomNote = addressableNote,
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
isInPipMode = isInPipMode,
|
||||
isPipSupported = isPipSupported,
|
||||
onMuteState = onMuteState,
|
||||
onConnectedChange = onConnectedChange,
|
||||
pipMuteSignal = pipMuteSignal,
|
||||
onLeave = onLeave,
|
||||
onMinimize = onMinimize,
|
||||
)
|
||||
NestActivityBody(
|
||||
event = current,
|
||||
roomNote = addressableNote,
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
isInPipMode = isInPipMode,
|
||||
isPipSupported = isPipSupported,
|
||||
onMuteState = onMuteState,
|
||||
onConnectedChange = onConnectedChange,
|
||||
pipMuteSignal = pipMuteSignal,
|
||||
onLeave = onLeave,
|
||||
onMinimize = onMinimize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered spinner shown between activity launch and the first
|
||||
* resolved kind-30312 event. Replaces the previous black screen so
|
||||
* the user knows the room is being loaded, not stuck.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingRoomState() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringRes(R.string.nest_loading_room),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal state for rooms whose kind-30312 doesn't carry the
|
||||
* service/endpoint pair the audio plane needs (or whose address is
|
||||
* malformed). Renders a clear explanation + a Back button so the
|
||||
* user can leave instead of force-killing the activity.
|
||||
*/
|
||||
@Composable
|
||||
private fun UnjoinableRoomState(onLeave: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_unjoinable_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.nest_unjoinable_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
OutlinedButton(onClick = onLeave) {
|
||||
Text(stringRes(R.string.nest_unjoinable_back))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -47,7 +47,11 @@ internal class NestViewModelFactory(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
NestViewModel(
|
||||
httpClient = OkHttpNestsClient(Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo),
|
||||
// Named parameter — `OkHttpNestsClient`'s primary constructor
|
||||
// declares `callTimeoutMs` first with a default, so the
|
||||
// function reference must be passed explicitly to the
|
||||
// `httpClient` slot rather than as the first positional arg.
|
||||
httpClient = OkHttpNestsClient(httpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo),
|
||||
transport = QuicWebTransportFactory(),
|
||||
decoderFactory = { MediaCodecOpusDecoder() },
|
||||
playerFactory = { AudioTrackPlayer() },
|
||||
|
||||
+4
@@ -242,6 +242,10 @@ private fun OnStageControls(
|
||||
|
||||
is BroadcastUiState.Broadcasting -> {
|
||||
MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted)
|
||||
// Stop sending audio without leaving the stage — viewer
|
||||
// can pause the mic and resume later via Talk. Distinct
|
||||
// from [LeaveStageButton], which also vacates the slot.
|
||||
StopBroadcastButton(onClick = viewModel::stopBroadcast)
|
||||
LeaveStageButton(onClick = leaveStage)
|
||||
}
|
||||
|
||||
|
||||
+62
-30
@@ -24,22 +24,29 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -68,54 +75,79 @@ internal fun HandRaiseQueueSection(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val presences by viewModel.presences.collectAsState()
|
||||
// Memoize the on-stage gate so a heartbeat that doesn't change
|
||||
// the kind-30312 doesn't re-build the set every recompose. The
|
||||
// event reference is stable across recompositions until the host
|
||||
// republishes; presence updates run through the second remember.
|
||||
val onStageKeys =
|
||||
event
|
||||
.participants()
|
||||
.filter { it.canSpeak() }
|
||||
.map { it.pubKey }
|
||||
.toSet()
|
||||
remember(event) {
|
||||
event
|
||||
.participants()
|
||||
.filter { it.canSpeak() }
|
||||
.map { it.pubKey }
|
||||
.toSet()
|
||||
}
|
||||
val hands =
|
||||
presences.values
|
||||
.filter { it.handRaised && it.pubkey !in onStageKeys }
|
||||
.sortedBy { it.updatedAtSec }
|
||||
remember(presences, onStageKeys) {
|
||||
presences.values
|
||||
.filter { it.handRaised && it.pubkey !in onStageKeys }
|
||||
.sortedBy { it.updatedAtSec }
|
||||
}
|
||||
|
||||
if (hands.isEmpty()) return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(modifier = modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_hand_raise_queue_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
hands.forEach { hand ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hand.pubkey,
|
||||
size = Size35dp,
|
||||
// LazyColumn so a flood of raised hands doesn't render every
|
||||
// row every recompose. Stable key by pubkey lets Compose
|
||||
// animate row entry/exit instead of teardown-rebuild.
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 6.dp)) {
|
||||
items(items = hands, key = { it.pubkey }) { hand ->
|
||||
HandRaiseRow(
|
||||
hand = hand,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Text(
|
||||
text = hand.pubkey.take(8),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onApprove = {
|
||||
scope.launch {
|
||||
val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER)
|
||||
template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.nest_hand_raise_approve))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRaiseRow(
|
||||
hand: RoomPresence,
|
||||
accountViewModel: AccountViewModel,
|
||||
onApprove: () -> Unit,
|
||||
) {
|
||||
val user = remember(hand.pubkey) { LocalCache.getOrCreateUser(hand.pubkey) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hand.pubkey,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(onClick = onApprove) {
|
||||
Text(stringRes(R.string.nest_hand_raise_approve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +591,10 @@
|
||||
<string name="nest_minimize">Minimize</string>
|
||||
<string name="nest_minimize_description">Minimize to keep listening</string>
|
||||
<string name="nest_audio_dropped">Audio dropped</string>
|
||||
<string name="nest_loading_room">Loading room…</string>
|
||||
<string name="nest_unjoinable_title">This room can\'t be opened</string>
|
||||
<string name="nest_unjoinable_body">The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect.</string>
|
||||
<string name="nest_unjoinable_back">Back</string>
|
||||
<string name="nest_create_fab">Start space</string>
|
||||
<string name="nest_no_server_title">Set up a nest server</string>
|
||||
<string name="nest_no_server_body">You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings.</string>
|
||||
|
||||
Reference in New Issue
Block a user