randomise blob layout per cover, pick a distinct accent, gradient title bar

This commit is contained in:
austinried
2026-10-04 00:29:53 +00:00
parent f9bb38af74
commit 139e12af75
2 changed files with 80 additions and 56 deletions
@@ -54,6 +54,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -129,6 +130,7 @@ fun AlbumDetailScreen(
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) { BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val gradientHeight = maxHeight * GRADIENT_SCREENS
val density = LocalDensity.current val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density) val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(density) val navBarBottom = WindowInsets.navigationBars.getBottom(density)
@@ -166,7 +168,7 @@ fun AlbumDetailScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.requiredHeight(maxHeight * GRADIENT_SCREENS) .requiredHeight(gradientHeight)
.offset { IntOffset(0, -scrollPx.roundToInt()) } .offset { IntOffset(0, -scrollPx.roundToInt()) }
.alpha(gradientAlpha), .alpha(gradientAlpha),
) )
@@ -216,7 +218,7 @@ fun AlbumDetailScreen(
.height(statusBarDp + 8.dp) .height(statusBarDp + 8.dp)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent), listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
), ),
).alpha(1f - barFraction), ).alpha(1f - barFraction),
) )
@@ -225,19 +227,25 @@ fun AlbumDetailScreen(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth()
.height(statusBarDp) .height(with(density) { barHeightPx.toDp() })
.background( .clipToBounds()
Brush.verticalGradient( .alpha(barFraction),
listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), ) {
), HeroGradient(
).alpha(1f - barFraction), colors = artwork,
) modifier =
Modifier
.fillMaxWidth()
.requiredHeight(gradientHeight)
.offset { IntOffset(0, -scrollPx.roundToInt()) },
)
}
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction), modifier = Modifier.alpha(barFraction),
@@ -252,7 +260,6 @@ fun AlbumDetailScreen(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.background((artwork?.gradientHigh ?: Color.Black).copy(alpha = barFraction))
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -291,7 +298,7 @@ private fun AlbumHeader(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
@@ -25,15 +26,19 @@ import com.subtracks.data.model.CoverArtRef
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.random.Random
private const val SEED_ART_SIZE_PX = 128 private const val SEED_ART_SIZE_PX = 128
private const val GRADIENT_FADE_START = 1.3f / 2.0f private const val GRADIENT_FADE_START = 1.3f / 2.0f
private const val BLOB_ZONE = 0.62f
data class ArtworkColors( data class ArtworkColors(
val scheme: ColorScheme, val scheme: ColorScheme,
val gradientHigh: Color, val gradientHigh: Color,
val gradientLow: Color, val gradientLow: Color,
val accents: List<Color>, val accents: List<Color>,
val blobSeed: Int,
) )
fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null) fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null)
@@ -44,13 +49,13 @@ fun artworkColorsFromSeeds(
): ArtworkColors { ): ArtworkColors {
val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl() val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl()
val s = saturation.coerceAtMost(0.85f) val s = saturation.coerceAtMost(0.85f)
val secondary = secondarySeed?.let { Color(it or 0xFF000000.toInt()) }
fun tone( fun tone(
h: Float, h: Float,
sat: Float, sat: Float,
l: Float, l: Float,
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f)) ) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
val primary = tone(hue, s, 0.62f) val primary = tone(hue, s, 0.62f)
val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f) val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f)
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f) val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
@@ -85,11 +90,19 @@ fun artworkColorsFromSeeds(
outlineVariant = tone(hue, s * 0.18f, 0.28f), outlineVariant = tone(hue, s * 0.18f, 0.28f),
) )
val secondary =
secondarySeed?.let { seed ->
val color = Color(seed or 0xFF000000.toInt())
val (sh, ss, sl) = color.toHsl()
if (sl > 0.55f) Color.hsl(sh, ss, 0.30f) else color
} ?: tone(hue, s, 0.08f)
return ArtworkColors( return ArtworkColors(
scheme = scheme, scheme = scheme,
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f), gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
gradientLow = tone(hue, s * 0.30f, 0.03f), gradientLow = tone(hue, s * 0.30f, 0.03f),
accents = listOf(tone(hue, s * 0.8f, 0.90f), secondary ?: tone(hue, s, 0.10f)), accents = listOf(tone(hue, s * 0.8f, 0.90f), secondary),
blobSeed = primarySeed,
) )
} }
@@ -137,12 +150,31 @@ private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
?: palette.mutedSwatch ?: palette.mutedSwatch
?: palette.dominantSwatch ?: palette.dominantSwatch
?: return null ?: return null
val primaryColor = Color(primary.rgb or 0xFF000000.toInt())
val secondary = val secondary =
palette.darkVibrantSwatch listOfNotNull(
?: palette.mutedSwatch palette.vibrantSwatch,
?: palette.darkMutedSwatch palette.lightVibrantSwatch,
?: palette.dominantSwatch palette.darkVibrantSwatch,
return primary.rgb to secondary?.takeIf { it.rgb != primary.rgb }?.rgb palette.mutedSwatch,
palette.lightMutedSwatch,
palette.darkMutedSwatch,
palette.dominantSwatch,
).map { it.rgb }
.distinct()
.filter { it != primary.rgb }
.maxByOrNull { distinctness(primaryColor, Color(it or 0xFF000000.toInt())) }
return primary.rgb to secondary
}
private fun distinctness(
a: Color,
b: Color,
): Float {
val ha = a.toHsl().first
val hb = b.toHsl().first
val hueDistance = minOf(abs(ha - hb), 360f - abs(ha - hb)) / 180f
return hueDistance + abs(a.luminance() - b.luminance()) * 2f
} }
@Composable @Composable
@@ -166,6 +198,7 @@ fun HeroGradient(
val high = colors?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh val high = colors?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh
val low = colors?.gradientLow ?: MaterialTheme.colorScheme.background val low = colors?.gradientLow ?: MaterialTheme.colorScheme.background
val accents = colors?.accents.orEmpty() val accents = colors?.accents.orEmpty()
val seed = colors?.blobSeed ?: 0
Canvas(modifier) { Canvas(modifier) {
drawRect( drawRect(
@@ -176,44 +209,28 @@ fun HeroGradient(
end = Offset(size.width * 0.8f, size.height), end = Offset(size.width * 0.8f, size.height),
), ),
) )
accents.getOrNull(0)?.let { accent -> if (accents.isNotEmpty()) {
drawRect( val random = Random(seed)
brush = val zone = size.height * BLOB_ZONE
Brush.radialGradient( repeat(4) { index ->
colors = listOf(accent.copy(alpha = 0.85f), accent.copy(alpha = 0f)), val center =
center = Offset(size.width * 0.20f, size.height * 0.00f), Offset(
radius = size.width * 0.60f, size.width * (0.10f + random.nextFloat() * 0.80f),
), zone * random.nextFloat(),
) )
val radius = size.width * (0.45f + random.nextFloat() * 0.35f)
val alpha = 0.50f + random.nextFloat() * 0.32f
val accent = accents[index % accents.size]
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = alpha), accent.copy(alpha = 0f)),
center = center,
radius = radius,
),
)
}
} }
accents.getOrNull(1)?.let { accent ->
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.80f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.90f, size.height * 0.26f),
radius = size.width * 0.70f,
),
)
}
accents.getOrNull(0)?.let { accent ->
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.55f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.82f, size.height * 0.00f),
radius = size.width * 0.45f,
),
)
}
drawRect(
brush =
Brush.radialGradient(
colors = listOf(high.copy(alpha = 0.55f), high.copy(alpha = 0f)),
center = Offset(size.width * 0.40f, size.height * 0.50f),
radius = size.width * 0.80f,
),
)
drawRect( drawRect(
brush = brush =
Brush.verticalGradient( Brush.verticalGradient(