mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
polish the album hero: bigger art, fading title bar, art-faithful colours
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user