feat(app,android,ios): full-bleed hero carousel with ambient colour

The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
This commit is contained in:
nvrl committed 2026-10-30 21:19:37 +01:00
1 parent 255e463fc8
commit c6d303f269
20 files changed
+1358 -327

No files matched your search

@@ -1,6 +1,28 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -199,72 +221,83 @@ internal fun LibraryScreen(
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
// The banner turns through these. Worked out here rather than in the grid,
// because whether there is one decides the shape of the whole page.
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
// With a banner, the bar lies over it, clear, and fills in as the banner
// scrolls away; without one, the large title folds into the bar as the
// grid scrolls and comes back only at the top.
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
val grid = rememberLazyGridState()
val actions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else if (!overBanner) {
LargeTopAppBar(
title = { Text("Library") },
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
featured = if (overBanner) featured else emptyList(),
grid = grid,
)
}
}
}
if (overBanner) BarOverBanner(grid, actions)
}
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
@@ -458,6 +491,8 @@ private fun LibraryGrid(
onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -476,10 +511,24 @@ private fun LibraryGrid(
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
state = grid,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
// No room above a banner: it starts under the status bar, and the bar
// lies over it.
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
) {
if (featured.isNotEmpty()) {
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
featured,
resumable,
onPlay = onResume,
onOpen = onTitle,
modifier = Modifier.bleed(16.dp),
)
}
}
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
@@ -487,17 +536,6 @@ private fun LibraryGrid(
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
}
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
@@ -611,44 +649,173 @@ private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/** How many titles the banner turns through: the desktop's `FEATURED`. */
private const val FEATURED_MAX = 5
/** How long each title holds the banner: the desktop's `HOLD`. */
private const val HOLD_MS = 8_000L
/** How long one title takes to give way to the next: the desktop's `TURN`. */
private const val TURN_MS = 600
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
* once a day — the desktop's `featured`.
* The titles the banner turns through: the one watched last first, then titles
* the provider has a backdrop for, starting from a different one each day —
* the desktop's `featured`.
*/
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
resumable.firstOrNull()?.let { return it.title }
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
if (pictured.isEmpty()) return picked
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
for (offset in pictured.indices) {
if (picked.size == FEATURED_MAX) break
val title = pictured[(start + offset) % pictured.size]
if (picked.none { it.key == title.key }) picked += title
}
return picked
}
/** Large art behind the name, rating, genres and three lines of synopsis. */
/**
* Wider than the space it is given by [horizontal] on each side: for the
* banner, which runs edge to edge inside a grid with a margin.
*/
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
val extra = horizontal.roundToPx() * 2
val placeable = measurable.measure(
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
)
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/**
* The bar over the banner: clear at the top, with white icons over the
* banner's own scrim, and filling in to the page's bar as the banner scrolls
* away — at which point it is the page's bar, title and all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
val solid by remember(grid) {
derivedStateOf {
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
}
}
val scheme = MaterialTheme.colorScheme
val ink = lerp(Color.White, scheme.onSurface, solid)
TopAppBar(
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = scheme.surfaceContainer.copy(alpha = solid),
titleContentColor = ink,
actionIconContentColor = ink,
),
)
}
/** How far the banner scrolls before the bar over it is solid. */
private val BAR_REVEAL = 280.dp
/**
* The banner at the top of the library: one title at a time, edge to edge
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
* on it. Each title's veil is tinted with its own picture's colour.
*/
@Composable
private fun FeaturedBanner(
titles: List<TitleRecord>,
resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
modifier: Modifier = Modifier,
) {
val pager = rememberPagerState { titles.size }
val dragged by pager.interactionSource.collectIsDraggedAsState()
if (titles.size > 1 && !dragged) {
LaunchedEffect(pager.currentPage, titles.size) {
delay(HOLD_MS)
pager.animateScrollToPage((pager.currentPage + 1) % titles.size, animationSpec = tween(TURN_MS))
}
}
// Most of a phone's first screen, but never so much of a tablet's that
// nothing under it shows.
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp.coerceIn(360.dp, 620.dp)
Box(modifier.fillMaxWidth().height(height)) {
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
val title = titles[page]
FeaturedPage(
title,
resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) },
)
}
if (titles.size > 1) {
val scope = rememberCoroutineScope()
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
titles.forEachIndexed { index, title ->
val shown = index == pager.currentPage
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
Box(
Modifier
.size(width, 6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
.clickable(onClickLabel = "Show ${title.displayName}") {
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
}
.semantics { selected = shown },
)
}
}
}
}
}
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable
private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
Backdrop(title, Modifier.fillMaxSize())
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background
val veil = moodVeil(mood)
val ink = MaterialTheme.colorScheme.onBackground
// A logo is lettering made for dark art, white as often as not; over a
// light palette's veil it is gone, so the light palette keeps the name.
val dark = background.luminance() < 0.5f
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
Brush.verticalGradient(
// Dark under the status bar and the bar's white icons…
0f to Color.Black.copy(alpha = 0.5f),
0.22f to Color.Transparent,
// …and the picture's own colour under the text, ending in
// the page it hands over to.
0.45f to veil.copy(alpha = 0.35f),
0.78f to veil.copy(alpha = 0.92f),
1f to background,
),
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
// Always over the dark scrim above, so a logo made for dark art
// reads here whatever the theme.
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.6f).heightIn(max = 72.dp).padding(bottom = 6.dp),
) {
Text(
Column(
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
) {
if (dark) {
RemoteLogo(
title.logoUrl,
title.displayName,
style = MaterialTheme.typography.displayLarge,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
) { HeroName(title.displayName, ink) }
} else {
HeroName(title.displayName, ink)
}
Text(
listOfNotNull(
@@ -656,14 +823,14 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
color = ink.copy(alpha = 0.8f),
)
title.overview?.let {
Text(
it.trim(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 3,
color = ink.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
@@ -672,14 +839,48 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
}
TonalButton(onClick = onOpen) {
Icon(Icons.Default.Info, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("More info")
}
}
val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
if (progress != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.width(120.dp),
)
episode.timeLeft()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = ink.copy(alpha = 0.8f),
modifier = Modifier.padding(start = 8.dp),
)
}
}
TonalButton(onClick = onOpen) { Text("More info") }
}
}
}
}
@Composable
private fun HeroName(name: String, ink: Color) {
Text(
name,
style = MaterialTheme.typography.displayLarge,
color = ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 4.dp),
)
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
@@ -7,6 +7,7 @@ import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -33,6 +34,8 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -45,6 +48,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.moodVeil as sharedMoodVeil
import uniffi.pstr_android.pictureMood as sharedPictureMood
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
@@ -98,6 +103,7 @@ internal fun RemoteArtwork(
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
maxEdge: Dp? = null,
onMood: ((Color) -> Unit)? = null,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
@@ -110,9 +116,11 @@ internal fun RemoteArtwork(
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
if (bitmap != null && onMood != null) {
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
}
}
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
when (loaded) {
@@ -151,7 +159,7 @@ private const val SHIMMER_MS = 1_400
* stretched, is what softens it.
*/
@Composable
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier) {
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
val wash = title.backdropUrl == null && title.posterUrl != null
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
@@ -160,9 +168,52 @@ internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier) {
fallback = title.thumbnailSource,
labelled = false,
maxEdge = if (wash) WASH_EDGE else null,
onMood = onMood,
)
}
/**
* The colour a picture leaves behind — the shared `appearance::mood`, over the
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
* banner paging back to a title does not work it out again.
*/
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
moods.get(key)?.let { return Color(it) }
val argb = withContext(Dispatchers.Default) {
runCatching {
val small = Bitmap.createScaledBitmap(bitmap, MOOD_EDGE, MOOD_EDGE, true)
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
}.getOrNull()
} ?: return null
moods.put(key, argb)
return Color(argb)
}
private val moods = LruCache<String, Int>(64)
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
private const val MOOD_EDGE = 24
/**
* [background] tinted by a picture's [mood], for the veils over that picture —
* the shared `appearance::mood_veil`. [background] itself until the mood is
* known.
*/
@Composable
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
val light = background.luminance() > 0.5f
val target = remember(mood, background) {
mood?.let {
runCatching {
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
}.getOrNull()
} ?: background
}
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
}
private val WASH_EDGE = 48.dp
private val WASH_BLUR = 24.dp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB