diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 41d48374..5f82eef0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index ad96a413..95309104 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -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, + ), ), ) }