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:
+177
-173
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+87
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user