mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.material.icons.rounded.Shuffle
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
@@ -33,13 +42,21 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.data.model.Album
|
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.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val ART_SIZE = 240.dp
|
|
||||||
private const val DOT = "\u00B7"
|
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
|
@Composable
|
||||||
fun AlbumDetailRoute(
|
fun AlbumDetailRoute(
|
||||||
@@ -98,23 +118,73 @@ fun AlbumDetailScreen(
|
|||||||
) {
|
) {
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(artwork) {
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
Box(modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||||
HeroGradient(artwork, Modifier.fillMaxSize())
|
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(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {
|
||||||
|
Text(
|
||||||
|
text = album?.name.orEmpty(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.alpha(barFraction),
|
||||||
|
)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack, modifier = Modifier.alpha(barFraction)) {
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.background(Color.Black.copy(alpha = barFraction)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
LazyColumn(state = listState, modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
item {
|
item {
|
||||||
AlbumHeader(
|
AlbumHeader(
|
||||||
album = album,
|
album = album,
|
||||||
@@ -124,13 +194,17 @@ fun AlbumDetailScreen(
|
|||||||
onShuffle = onShuffle,
|
onShuffle = onShuffle,
|
||||||
onDownload = onDownload,
|
onDownload = onDownload,
|
||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
|
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||||
SongRow(
|
SongRow(
|
||||||
song = song,
|
song = song,
|
||||||
isPlaying = song.id == playingSongId,
|
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,
|
onShuffle: () -> Unit,
|
||||||
onDownload: () -> Unit,
|
onDownload: () -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
CoverArt(
|
||||||
ref = coverArt(album?.coverArt, false),
|
ref = coverArt(album?.coverArt, false),
|
||||||
name = album?.name.orEmpty(),
|
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))
|
Spacer(Modifier.height(20.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -196,7 +271,7 @@ private fun AlbumHeader(
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Play")
|
Text("Play")
|
||||||
}
|
}
|
||||||
IconButton(onClick = onShuffle, enabled = hasSongs) {
|
FilledIconButton(onClick = onShuffle, enabled = hasSongs) {
|
||||||
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
|
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
|
||||||
}
|
}
|
||||||
IconButton(onClick = onMore) {
|
IconButton(onClick = onMore) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val SEED_ART_SIZE_PX = 128
|
private const val SEED_ART_SIZE_PX = 128
|
||||||
|
private const val GRADIENT_FADE_START = 0.8f / 1.5f
|
||||||
|
|
||||||
data class ArtworkColors(
|
data class ArtworkColors(
|
||||||
val scheme: ColorScheme,
|
val scheme: ColorScheme,
|
||||||
@@ -37,54 +38,53 @@ data class ArtworkColors(
|
|||||||
|
|
||||||
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
||||||
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
|
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
|
||||||
val chroma = saturation.coerceIn(0.30f, 0.72f)
|
val s = saturation.coerceAtMost(0.85f)
|
||||||
val secondaryHue = (hue + 60f) % 360f
|
|
||||||
|
|
||||||
fun tone(
|
fun tone(
|
||||||
h: Float,
|
h: Float,
|
||||||
s: Float,
|
sat: Float,
|
||||||
l: 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 primary = tone(hue, s, 0.62f)
|
||||||
val background = tone(hue, (saturation * 0.7f).coerceIn(0.06f, 0.22f), 0.06f)
|
val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f)
|
||||||
val onBackground = tone(hue, 0.08f, 0.95f)
|
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
|
||||||
|
|
||||||
val scheme =
|
val scheme =
|
||||||
darkColorScheme(
|
darkColorScheme(
|
||||||
primary = primary,
|
primary = primary,
|
||||||
onPrimary = tone(hue, chroma * 0.6f, 0.14f),
|
onPrimary = tone(hue, s * 0.6f, 0.12f),
|
||||||
primaryContainer = tone(hue, chroma, 0.32f),
|
primaryContainer = tone(hue, s, 0.28f),
|
||||||
onPrimaryContainer = tone(hue, chroma * 0.5f, 0.92f),
|
onPrimaryContainer = tone(hue, s * 0.7f, 0.92f),
|
||||||
secondary = tone(hue, chroma * 0.5f, 0.78f),
|
secondary = tone(hue, s * 0.6f, 0.66f),
|
||||||
onSecondary = tone(hue, chroma * 0.4f, 0.14f),
|
onSecondary = tone(hue, s * 0.5f, 0.12f),
|
||||||
secondaryContainer = tone(hue, chroma * 0.45f, 0.30f),
|
secondaryContainer = tone(hue, s * 0.55f, 0.26f),
|
||||||
onSecondaryContainer = tone(hue, chroma * 0.4f, 0.90f),
|
onSecondaryContainer = tone(hue, s * 0.5f, 0.90f),
|
||||||
tertiary = tone(secondaryHue, chroma, 0.80f),
|
tertiary = tone(hue, s * 0.9f, 0.72f),
|
||||||
onTertiary = tone(secondaryHue, chroma * 0.6f, 0.14f),
|
onTertiary = tone(hue, s * 0.6f, 0.12f),
|
||||||
tertiaryContainer = tone(secondaryHue, chroma, 0.30f),
|
tertiaryContainer = tone(hue, s * 0.8f, 0.28f),
|
||||||
onTertiaryContainer = tone(secondaryHue, chroma * 0.5f, 0.90f),
|
onTertiaryContainer = tone(hue, s * 0.6f, 0.90f),
|
||||||
background = background,
|
background = background,
|
||||||
onBackground = onBackground,
|
onBackground = onBackground,
|
||||||
surface = background,
|
surface = background,
|
||||||
onSurface = onBackground,
|
onSurface = onBackground,
|
||||||
surfaceTint = primary,
|
surfaceTint = primary,
|
||||||
surfaceVariant = tone(hue, 0.15f, 0.17f),
|
surfaceVariant = tone(hue, s * 0.25f, 0.17f),
|
||||||
onSurfaceVariant = tone(hue, 0.12f, 0.78f),
|
onSurfaceVariant = tone(hue, s * 0.18f, 0.78f),
|
||||||
surfaceContainerLowest = tone(hue, 0.16f, 0.04f),
|
surfaceContainerLowest = tone(hue, s * 0.25f, 0.04f),
|
||||||
surfaceContainerLow = tone(hue, 0.16f, 0.08f),
|
surfaceContainerLow = tone(hue, s * 0.25f, 0.08f),
|
||||||
surfaceContainer = tone(hue, 0.16f, 0.10f),
|
surfaceContainer = tone(hue, s * 0.25f, 0.10f),
|
||||||
surfaceContainerHigh = tone(hue, 0.15f, 0.14f),
|
surfaceContainerHigh = tone(hue, s * 0.22f, 0.14f),
|
||||||
surfaceContainerHighest = tone(hue, 0.14f, 0.18f),
|
surfaceContainerHighest = tone(hue, s * 0.20f, 0.18f),
|
||||||
outline = tone(hue, 0.10f, 0.55f),
|
outline = tone(hue, s * 0.15f, 0.55f),
|
||||||
outlineVariant = tone(hue, 0.12f, 0.28f),
|
outlineVariant = tone(hue, s * 0.18f, 0.28f),
|
||||||
)
|
)
|
||||||
|
|
||||||
return ArtworkColors(
|
return ArtworkColors(
|
||||||
scheme = scheme,
|
scheme = scheme,
|
||||||
gradientHigh = tone(hue, (saturation * 0.9f).coerceIn(0.30f, 0.60f), 0.24f),
|
gradientHigh = tone(hue, (s * 0.7f).coerceAtMost(0.55f), 0.25f),
|
||||||
gradientLow = tone(hue, 0.12f, 0.04f),
|
gradientLow = tone(hue, s * 0.20f, 0.04f),
|
||||||
accents = listOf(tone(hue, chroma, 0.60f), tone(secondaryHue, chroma, 0.55f)),
|
accents = listOf(tone(hue, s, 0.55f), tone(hue, s * 0.7f, 0.42f)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +169,9 @@ fun HeroGradient(
|
|||||||
drawRect(
|
drawRect(
|
||||||
brush =
|
brush =
|
||||||
Brush.radialGradient(
|
Brush.radialGradient(
|
||||||
colors = listOf(accent.copy(alpha = 0.35f), accent.copy(alpha = 0f)),
|
colors = listOf(accent.copy(alpha = 0.40f), accent.copy(alpha = 0f)),
|
||||||
center = Offset(size.width * 0.15f, size.height * 0.08f),
|
center = Offset(size.width * 0.15f, size.height * 0.06f),
|
||||||
radius = size.width * 0.95f,
|
radius = size.width * 0.85f,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,9 +179,9 @@ fun HeroGradient(
|
|||||||
drawRect(
|
drawRect(
|
||||||
brush =
|
brush =
|
||||||
Brush.radialGradient(
|
Brush.radialGradient(
|
||||||
colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)),
|
colors = listOf(accent.copy(alpha = 0.32f), accent.copy(alpha = 0f)),
|
||||||
center = Offset(size.width * 0.95f, size.height * 0.35f),
|
center = Offset(size.width * 0.90f, size.height * 0.24f),
|
||||||
radius = size.width * 0.9f,
|
radius = size.width * 0.80f,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -189,8 +189,19 @@ fun HeroGradient(
|
|||||||
brush =
|
brush =
|
||||||
Brush.radialGradient(
|
Brush.radialGradient(
|
||||||
colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)),
|
colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)),
|
||||||
center = Offset(size.width * 0.55f, size.height * 0.85f),
|
center = Offset(size.width * 0.55f, size.height * 0.45f),
|
||||||
radius = size.width * 1.1f,
|
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