polish the album hero: bigger art, fading title bar, art-faithful colours

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 3d59379907
commit b9519f1a7f
2 changed files with 135 additions and 49 deletions
@@ -1,19 +1,27 @@
package com.subtracks.ui.library
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -23,6 +31,7 @@ import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
@@ -33,13 +42,21 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album
@@ -55,9 +72,12 @@ import com.subtracks.ui.theme.rememberArtworkColors
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
private val ART_SIZE = 240.dp
private const val DOT = "\u00B7"
private const val GRADIENT_SCREENS = 1.5f
private const val FADE_DISTANCE_DP = 96
private const val CONTROLS_BLOCK_DP = 64
@Composable
fun AlbumDetailRoute(
@@ -98,23 +118,73 @@ fun AlbumDetailScreen(
) {
ArtworkTheme(artwork) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
Box(modifier.fillMaxSize()) {
HeroGradient(artwork, Modifier.fillMaxSize())
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val controlsBlockPx = with(density) { CONTROLS_BLOCK_DP.dp.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
var headerHeightPx by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableFloatStateOf(0f) }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
val before = if (index <= 0) 0f else headerHeightPx + rowHeightPx * (index - 1)
(before + listState.firstVisibleItemScrollOffset).coerceAtLeast(0f)
}
}
val barFraction by remember {
derivedStateOf {
if (headerHeightPx == 0f) {
0f
} else {
val end = headerHeightPx - controlsBlockPx - barHeightPx
val start = end - fadeDistancePx
((scrollPx - start) / (end - start)).coerceIn(0f, 1f)
}
}
}
val gradientAlpha by animateFloatAsState(
targetValue = if (artwork != null) 1f else 0f,
animationSpec = tween(durationMillis = 600),
label = "gradientAlpha",
)
HeroGradient(
colors = artwork,
modifier =
Modifier
.fillMaxWidth()
.height(maxHeight * GRADIENT_SCREENS)
.offset { IntOffset(0, -scrollPx.roundToInt()) }
.alpha(gradientAlpha),
)
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {},
title = {
Text(
text = album?.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = onBack, modifier = Modifier.alpha(barFraction)) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier.background(Color.Black.copy(alpha = barFraction)),
)
},
) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.padding(padding).fillMaxSize()) {
item {
AlbumHeader(
album = album,
@@ -124,13 +194,17 @@ fun AlbumDetailScreen(
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
)
}
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
SongRow(
song = song,
isPlaying = song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
modifier =
Modifier
.onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
}
}
@@ -149,15 +223,16 @@ private fun AlbumHeader(
onShuffle: () -> Unit,
onDownload: () -> Unit,
onMore: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CoverArt(
ref = coverArt(album?.coverArt, false),
name = album?.name.orEmpty(),
modifier = Modifier.size(ART_SIZE).clip(RoundedCornerShape(8.dp)),
modifier = Modifier.fillMaxWidth(0.78f).aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
Text(
@@ -196,7 +271,7 @@ private fun AlbumHeader(
Spacer(Modifier.width(8.dp))
Text("Play")
}
IconButton(onClick = onShuffle, enabled = hasSongs) {
FilledIconButton(onClick = onShuffle, enabled = hasSongs) {
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
}
IconButton(onClick = onMore) {
@@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val SEED_ART_SIZE_PX = 128
private const val GRADIENT_FADE_START = 0.8f / 1.5f
data class ArtworkColors(
val scheme: ColorScheme,
@@ -37,54 +38,53 @@ data class ArtworkColors(
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
val chroma = saturation.coerceIn(0.30f, 0.72f)
val secondaryHue = (hue + 60f) % 360f
val s = saturation.coerceAtMost(0.85f)
fun tone(
h: Float,
s: Float,
sat: Float,
l: Float,
) = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
val primary = tone(hue, chroma, 0.80f)
val background = tone(hue, (saturation * 0.7f).coerceIn(0.06f, 0.22f), 0.06f)
val onBackground = tone(hue, 0.08f, 0.95f)
val primary = tone(hue, s, 0.62f)
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 scheme =
darkColorScheme(
primary = primary,
onPrimary = tone(hue, chroma * 0.6f, 0.14f),
primaryContainer = tone(hue, chroma, 0.32f),
onPrimaryContainer = tone(hue, chroma * 0.5f, 0.92f),
secondary = tone(hue, chroma * 0.5f, 0.78f),
onSecondary = tone(hue, chroma * 0.4f, 0.14f),
secondaryContainer = tone(hue, chroma * 0.45f, 0.30f),
onSecondaryContainer = tone(hue, chroma * 0.4f, 0.90f),
tertiary = tone(secondaryHue, chroma, 0.80f),
onTertiary = tone(secondaryHue, chroma * 0.6f, 0.14f),
tertiaryContainer = tone(secondaryHue, chroma, 0.30f),
onTertiaryContainer = tone(secondaryHue, chroma * 0.5f, 0.90f),
onPrimary = tone(hue, s * 0.6f, 0.12f),
primaryContainer = tone(hue, s, 0.28f),
onPrimaryContainer = tone(hue, s * 0.7f, 0.92f),
secondary = tone(hue, s * 0.6f, 0.66f),
onSecondary = tone(hue, s * 0.5f, 0.12f),
secondaryContainer = tone(hue, s * 0.55f, 0.26f),
onSecondaryContainer = tone(hue, s * 0.5f, 0.90f),
tertiary = tone(hue, s * 0.9f, 0.72f),
onTertiary = tone(hue, s * 0.6f, 0.12f),
tertiaryContainer = tone(hue, s * 0.8f, 0.28f),
onTertiaryContainer = tone(hue, s * 0.6f, 0.90f),
background = background,
onBackground = onBackground,
surface = background,
onSurface = onBackground,
surfaceTint = primary,
surfaceVariant = tone(hue, 0.15f, 0.17f),
onSurfaceVariant = tone(hue, 0.12f, 0.78f),
surfaceContainerLowest = tone(hue, 0.16f, 0.04f),
surfaceContainerLow = tone(hue, 0.16f, 0.08f),
surfaceContainer = tone(hue, 0.16f, 0.10f),
surfaceContainerHigh = tone(hue, 0.15f, 0.14f),
surfaceContainerHighest = tone(hue, 0.14f, 0.18f),
outline = tone(hue, 0.10f, 0.55f),
outlineVariant = tone(hue, 0.12f, 0.28f),
surfaceVariant = tone(hue, s * 0.25f, 0.17f),
onSurfaceVariant = tone(hue, s * 0.18f, 0.78f),
surfaceContainerLowest = tone(hue, s * 0.25f, 0.04f),
surfaceContainerLow = tone(hue, s * 0.25f, 0.08f),
surfaceContainer = tone(hue, s * 0.25f, 0.10f),
surfaceContainerHigh = tone(hue, s * 0.22f, 0.14f),
surfaceContainerHighest = tone(hue, s * 0.20f, 0.18f),
outline = tone(hue, s * 0.15f, 0.55f),
outlineVariant = tone(hue, s * 0.18f, 0.28f),
)
return ArtworkColors(
scheme = scheme,
gradientHigh = tone(hue, (saturation * 0.9f).coerceIn(0.30f, 0.60f), 0.24f),
gradientLow = tone(hue, 0.12f, 0.04f),
accents = listOf(tone(hue, chroma, 0.60f), tone(secondaryHue, chroma, 0.55f)),
gradientHigh = tone(hue, (s * 0.7f).coerceAtMost(0.55f), 0.25f),
gradientLow = tone(hue, s * 0.20f, 0.04f),
accents = listOf(tone(hue, s, 0.55f), tone(hue, s * 0.7f, 0.42f)),
)
}
@@ -169,9 +169,9 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.35f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.15f, size.height * 0.08f),
radius = size.width * 0.95f,
colors = listOf(accent.copy(alpha = 0.40f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.15f, size.height * 0.06f),
radius = size.width * 0.85f,
),
)
}
@@ -179,9 +179,9 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.95f, size.height * 0.35f),
radius = size.width * 0.9f,
colors = listOf(accent.copy(alpha = 0.32f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.90f, size.height * 0.24f),
radius = size.width * 0.80f,
),
)
}
@@ -189,8 +189,19 @@ fun HeroGradient(
brush =
Brush.radialGradient(
colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)),
center = Offset(size.width * 0.55f, size.height * 0.85f),
radius = size.width * 1.1f,
center = Offset(size.width * 0.55f, size.height * 0.45f),
radius = size.width * 0.90f,
),
)
drawRect(
brush =
Brush.verticalGradient(
colorStops =
arrayOf(
0f to Color.Transparent,
GRADIENT_FADE_START to Color.Transparent,
1f to Color.Black,
),
),
)
}