feat(audio-rooms): AudioRoomThemedScope renderer (T3 #1 finale)

Compose wrapper that consumes RoomTheme and applies it to the
audio-room body:

  AudioRoomThemedScope(theme, content):
    * Overrides Material3 colorScheme — background, onBackground,
      primary, surface, onSurface — with the theme's argb values.
      Each color override is independent: a room shipping a
      primary tint but no background gets the primary swap and
      default surface, no all-or-nothing.
    * Optional background image via Coil's AsyncImage rendered
      behind the content. backgroundMode=COVER → ContentScale.Crop;
      TILE → FillBounds (true repeat-tile would need a custom
      Modifier — FillBounds is a safe v1 fallback that doesn't
      crop and visually approximates a tile when the image is
      small + repeating).
    * Fails OPEN — RoomTheme.Empty / all-null fields produces a
      passthrough Box with no overrides.

  AudioRoomFullScreen — wraps the existing scrolling Column in
  AudioRoomThemedScope. The theme is materialised once via
  RoomTheme.from(event) inside `remember(event)` so a recomposition
  doesn't rebuild the colorScheme.

The font tag is still deferred — needs a per-pack FontFamily
loader; the v1 theme runs on the system default and renders
unchanged for un-themed rooms.
This commit is contained in:
Claude
2026-04-26 23:29:09 +00:00
parent 2e38aa6c77
commit e28ff87e8e
2 changed files with 264 additions and 173 deletions
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomUiState
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -89,196 +90,199 @@ internal fun AudioRoomFullScreen(
onHandRaisedChange: (Boolean) -> Unit,
onLeave: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) }
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) }
AudioRoomThemedScope(theme = roomTheme) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
event.room()?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
// Overflow menu — visible to everyone (Share); host-only
// rows (Edit) are gated inside.
Box {
androidx.compose.material3.IconButton(onClick = { showHostMenu = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.audio_room_overflow_menu),
var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) }
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
event.room()?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.material3.DropdownMenu(
expanded = showHostMenu,
onDismissRequest = { showHostMenu = false },
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.audio_room_share_action)) },
onClick = {
showHostMenu = false
shareRoomNaddr(context, event)
},
)
if (isHost) {
// Overflow menu — visible to everyone (Share); host-only
// rows (Edit) are gated inside.
Box {
androidx.compose.material3.IconButton(onClick = { showHostMenu = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.audio_room_overflow_menu),
)
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.material3.DropdownMenu(
expanded = showHostMenu,
onDismissRequest = { showHostMenu = false },
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.audio_room_edit_title)) },
text = { Text(stringRes(R.string.audio_room_share_action)) },
onClick = {
showHostMenu = false
showEditSheet = true
shareRoomNaddr(context, event)
},
)
if (isHost) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.audio_room_edit_title)) },
onClick = {
showHostMenu = false
showEditSheet = true
},
)
}
}
}
}
}
if (showEditSheet) {
EditAudioRoomSheet(
accountViewModel = accountViewModel,
event = event,
onDismiss = { showEditSheet = false },
)
}
event.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
// Listener counter — counts every active kind-10312 presence
// in the room. Hidden until the aggregator has at least one
// entry so the placeholder doesn't flash on entry.
val presences by viewModel.presences.collectAsState()
val listenerCount = presences.size
if (listenerCount > 0) {
Text(
text =
androidx.compose.ui.res.pluralStringResource(
R.plurals.audio_room_listener_count,
listenerCount,
listenerCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Long-press opens the participant context sheet for ANYONE
// (T2 #2). The sheet's own gating decides which rows to show
// (follow/mute always; promote/demote/kick host-only).
val onLongPressParticipant: ((String) -> Unit) = { target ->
if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target
}
if (onStage.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
people = onStage,
avatarSize = Size40dp,
speakingNow = ui.speakingNow,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
onLongPressParticipant = onLongPressParticipant,
)
}
if (audience.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_audience),
people = audience,
avatarSize = Size35dp,
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
)
}
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
)
}
if (isHost) {
HandRaiseQueueSection(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
)
}
ConnectionRow(viewModel = viewModel, ui = ui)
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.audio_room_lower_hand else R.string.audio_room_raise_hand,
),
if (showEditSheet) {
EditAudioRoomSheet(
accountViewModel = accountViewModel,
event = event,
onDismiss = { showEditSheet = false },
)
}
OutlinedButton(onClick = { showReactionPicker = true }) {
Text(stringRes(R.string.audio_room_reactions_button))
event.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
OutlinedButton(onClick = onLeave) {
Text(stringRes(R.string.audio_room_leave))
// Listener counter — counts every active kind-10312 presence
// in the room. Hidden until the aggregator has at least one
// entry so the placeholder doesn't flash on entry.
val presences by viewModel.presences.collectAsState()
val listenerCount = presences.size
if (listenerCount > 0) {
Text(
text =
androidx.compose.ui.res.pluralStringResource(
R.plurals.audio_room_listener_count,
listenerCount,
listenerCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji ->
accountViewModel.reactToOrDelete(roomNote, emoji)
},
onDismiss = { showReactionPicker = false },
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Long-press opens the participant context sheet for ANYONE
// (T2 #2). The sheet's own gating decides which rows to show
// (follow/mute always; promote/demote/kick host-only).
val onLongPressParticipant: ((String) -> Unit) = { target ->
if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target
}
if (onStage.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
people = onStage,
avatarSize = Size40dp,
speakingNow = ui.speakingNow,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
onLongPressParticipant = onLongPressParticipant,
)
}
if (audience.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_audience),
people = audience,
avatarSize = Size35dp,
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
)
}
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
)
}
if (isHost) {
HandRaiseQueueSection(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
)
}
ConnectionRow(viewModel = viewModel, ui = ui)
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.audio_room_lower_hand else R.string.audio_room_raise_hand,
),
)
}
OutlinedButton(onClick = { showReactionPicker = true }) {
Text(stringRes(R.string.audio_room_reactions_button))
}
OutlinedButton(onClick = onLeave) {
Text(stringRes(R.string.audio_room_leave))
}
}
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji ->
accountViewModel.reactToOrDelete(roomNote, emoji)
},
onDismiss = { showReactionPicker = false },
)
}
AudioRoomChatPanel(
roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
),
viewModel = viewModel,
accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp),
)
}
AudioRoomChatPanel(
roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
),
viewModel = viewModel,
accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@@ -0,0 +1,87 @@
/*
* 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.audiorooms.room
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
/**
* Apply a [RoomTheme] (NIP-53 `c` / `bg` tags) to the room body
* — overrides [MaterialTheme.colorScheme] for the inner content
* and renders the optional background image behind it.
*
* Each color override is independent: a room that ships a primary
* tint but no background gets the primary swap, default surface,
* etc. The renderer fails OPEN — when [theme] is
* [RoomTheme.Empty] or all fields are null, the wrapper is just a
* passthrough Box.
*/
@Composable
internal fun AudioRoomThemedScope(
theme: RoomTheme,
content: @Composable () -> Unit,
) {
val original = MaterialTheme.colorScheme
val themed =
remember(theme, original) {
// RoomTheme stores `0xFFRRGGBB` directly — Compose's
// Color constructor accepts that exact packing.
fun argbToColor(argb: Long?): Color? = argb?.let { Color(it) }
original.copy(
background = argbToColor(theme.backgroundArgb) ?: original.background,
onBackground = argbToColor(theme.textArgb) ?: original.onBackground,
primary = argbToColor(theme.primaryArgb) ?: original.primary,
surface = argbToColor(theme.backgroundArgb) ?: original.surface,
onSurface = argbToColor(theme.textArgb) ?: original.onSurface,
)
}
MaterialTheme(colorScheme = themed) {
Box(modifier = Modifier.fillMaxSize()) {
theme.backgroundImageUrl?.let { url ->
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale =
when (theme.backgroundMode) {
RoomTheme.BackgroundMode.COVER -> ContentScale.Crop
// True repeat-tile would need a custom Modifier;
// FillBounds is a safe v1 fallback that doesn't
// crop and visually approximates a tile when the
// image is small + repeating.
RoomTheme.BackgroundMode.TILE -> ContentScale.FillBounds
},
)
}
content()
}
}
}