feat(audio-rooms): true repeating-tile background mode

Replace the FillBounds fallback for RoomTheme.BackgroundMode.TILE
with a real ImageShader+ShaderBrush pipeline. Compose's AsyncImage
has no tile-mode option; the canonical path is to fetch the bitmap
through Coil into an ImageBitmap and wrap it as a ShaderBrush with
TileMode.Repeated on both axes, then paint a Box-sized background.

While the load is in flight (or on failure) the scope paints
nothing so the underlying material surface shows through — same
fail-open behavior as the rest of the theming layer.
This commit is contained in:
Claude
2026-04-27 01:15:44 +00:00
parent caab21dde6
commit a7766d6369
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
@@ -30,12 +31,17 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import coil3.asDrawable
import coil3.compose.AsyncImage
import coil3.imageLoader
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Apply a [RoomTheme] (NIP-53 `c` / `bg` tags) to the room body
@@ -102,27 +108,71 @@ internal fun AudioRoomThemedScope(
MaterialTheme(colorScheme = themed, typography = themedTypography) {
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
when (theme.backgroundMode) {
RoomTheme.BackgroundMode.COVER -> {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = 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
},
)
RoomTheme.BackgroundMode.TILE -> {
TiledRoomBackground(url = url)
}
}
}
content()
}
}
}
/**
* Repeating-tile background. Compose's [AsyncImage] doesn't have a
* tile-mode option, so we fetch the bitmap via Coil into a
* [ImageBitmap] and wrap it in an [ImageShader]+[ShaderBrush] —
* that's the canonical way to get the platform's native repeat
* sampler. While the load is in flight (or on failure) the scope
* paints nothing so the underlying material surface shows through.
*/
@Composable
private fun TiledRoomBackground(url: String) {
val context = LocalContext.current
val bitmap by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, url) {
value =
withContext(Dispatchers.IO) {
runCatching {
val request =
coil3.request.ImageRequest
.Builder(context)
.data(url)
.build()
val drawable =
context
.imageLoader
.execute(request)
.image
?.asDrawable(context.resources) as? android.graphics.drawable.BitmapDrawable
drawable?.bitmap?.asImageBitmap()
}.getOrNull()
}
}
bitmap?.let { bmp ->
val brush =
remember(bmp) {
androidx.compose.ui.graphics.ShaderBrush(
androidx.compose.ui.graphics.ImageShader(
image = bmp,
tileModeX = androidx.compose.ui.graphics.TileMode.Repeated,
tileModeY = androidx.compose.ui.graphics.TileMode.Repeated,
),
)
}
Box(modifier = Modifier.fillMaxSize().background(brush))
}
}
/**
* Map a kind-30312 `["f", family]` value to a Compose [FontFamily].
* Only the four CSS-style generic-family names are honoured —