diff --git a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt b/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt index d8442a9b..38d6c6d3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt @@ -10,51 +10,26 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.node.DelegatableNode -import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.unit.Velocity import kotlin.math.abs -private const val MAX_STRETCH_FRACTION = 0.30f - -private object StretchOverscrollFactory : OverscrollFactory { - override fun createOverscrollEffect(): OverscrollEffect = StretchOverscrollEffect() - - override fun hashCode(): Int = 0x5EED_5EED - - override fun equals(other: Any?): Boolean = other is StretchOverscrollFactory -} +private const val MAX_STRETCH_FRACTION = 0.14f private class StretchOverscrollEffect : OverscrollEffect { - private val overscroll = mutableFloatStateOf(0f) + val overscroll = mutableFloatStateOf(0f) + var limitPx: Float = 1f - private val drawNode = - object : Modifier.Node(), DrawModifierNode { - override fun ContentDrawScope.draw() { - val raw = overscroll.floatValue - if (raw == 0f) { - drawContent() - return - } - val limit = size.height * MAX_STRETCH_FRACTION - val capped = raw.coerceIn(-limit, limit) - val resisted = capped / (1f + abs(capped) / (size.height * 0.5f)) - val pivot = Offset(size.width / 2f, if (resisted > 0f) 0f else size.height) - scale(scaleX = 1f, scaleY = 1f + abs(resisted) / size.height, pivot = pivot) { - translate(left = 0f, top = resisted) { - this@draw.drawContent() - } - } - } - } + private val emptyNode = object : Modifier.Node() {} - override val node: DelegatableNode get() = drawNode + override val node: DelegatableNode get() = emptyNode override val isInProgress: Boolean get() = overscroll.floatValue != 0f @@ -76,7 +51,7 @@ private class StretchOverscrollEffect : OverscrollEffect { val leftover = remainingY - consumed.y if (leftover != 0f) { val current = overscroll.floatValue - val next = current + leftover + val next = (current + leftover).coerceIn(-limitPx, limitPx) overscroll.floatValue = when { current > 0f -> next.coerceAtLeast(0f) @@ -92,9 +67,10 @@ private class StretchOverscrollEffect : OverscrollEffect { performFling: suspend (Velocity) -> Velocity, ) { if (overscroll.floatValue != 0f) { - Animatable(overscroll.floatValue).animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) { - overscroll.floatValue = value - } + Animatable(overscroll.floatValue) + .animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) { + overscroll.floatValue = value + } } performFling(velocity) } @@ -105,7 +81,34 @@ fun StretchOverscroll( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - CompositionLocalProvider(LocalOverscrollFactory provides StretchOverscrollFactory) { - Box(modifier) { content() } + val effect = remember { StretchOverscrollEffect() } + val factory = + remember { + object : OverscrollFactory { + override fun createOverscrollEffect(): OverscrollEffect = effect + + override fun hashCode(): Int = 0x5EED_1234 + + override fun equals(other: Any?): Boolean = other === this + } + } + + CompositionLocalProvider(LocalOverscrollFactory provides factory) { + Box( + modifier = + modifier + .onSizeChanged { effect.limitPx = (it.height * MAX_STRETCH_FRACTION).coerceAtLeast(1f) } + .graphicsLayer { + val value = effect.overscroll.floatValue + if (value == 0f) return@graphicsLayer + val height = size.height + val resisted = value / (1f + abs(value) / (height * 0.35f)) + scaleY = 1f + abs(resisted) / height + translationY = resisted + transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f) + }, + ) { + content() + } } } 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 a9513f6d..acca34a7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -137,7 +137,7 @@ fun AlbumDetailScreen( var headerHeightPx by remember { mutableFloatStateOf(0f) } var rowHeightPx by remember { mutableFloatStateOf(0f) } - var titleBottomPx by remember { mutableFloatStateOf(0f) } + var controlsTopPx by remember { mutableFloatStateOf(0f) } val scrollPx by remember { derivedStateOf { val index = listState.firstVisibleItemIndex @@ -147,10 +147,10 @@ fun AlbumDetailScreen( } val barFraction by remember { derivedStateOf { - if (titleBottomPx <= 0f || fadeDistancePx <= 0f) { + if (controlsTopPx <= 0f || fadeDistancePx <= 0f) { 0f } else { - ((scrollPx - (titleBottomPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f) + ((scrollPx - (controlsTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f) } } } @@ -189,9 +189,9 @@ fun AlbumDetailScreen( onDownload = onDownload, onMore = onMore, topInset = statusBarDp, - titleModifier = + controlsModifier = Modifier.onGloballyPositioned { - titleBottomPx = it.positionInParent().y + it.size.height + controlsTopPx = it.positionInParent().y }, modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() }, ) @@ -272,7 +272,7 @@ private fun AlbumHeader( onDownload: () -> Unit, onMore: () -> Unit, topInset: Dp, - titleModifier: Modifier = Modifier, + controlsModifier: Modifier = Modifier, modifier: Modifier = Modifier, ) { Column( @@ -294,7 +294,6 @@ private fun AlbumHeader( textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = titleModifier, ) val subtitle = listOfNotNull( @@ -314,6 +313,7 @@ private fun AlbumHeader( } Spacer(Modifier.height(20.dp)) Row( + modifier = controlsModifier, horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { 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 22cabb29..a7a14a49 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.palette.graphics.Palette import coil3.SingletonImageLoader import coil3.compose.LocalPlatformContext @@ -45,8 +46,7 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors { sat: Float, l: Float, ) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f)) - - val primary = tone(hue, s, 0.62f) + val primary = toneMaxLuminance(hue, s, 0.62f, 0.45f) val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f) val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f) @@ -82,12 +82,28 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors { return ArtworkColors( scheme = scheme, - gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f), - gradientLow = tone(hue, s * 0.25f, 0.04f), - accents = listOf(tone(hue, s, 0.78f), tone(hue, s * 0.9f, 0.20f)), + gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f), + gradientLow = tone(hue, s * 0.30f, 0.03f), + accents = listOf(tone(hue, s * 0.75f, 0.95f), tone(hue, s, 0.08f)), ) } +private fun toneMaxLuminance( + h: Float, + s: Float, + lightness: Float, + maxLuminance: Float, +): Color { + var l = lightness + var color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f)) + var guard = 0 + while (color.luminance() > maxLuminance && guard++ < 30) { + l = (l - 0.02f).coerceAtLeast(0.05f) + color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l) + } + return color +} + @Composable fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? { val seed by rememberArtworkSeed(ref) @@ -169,9 +185,9 @@ fun HeroGradient( drawRect( brush = Brush.radialGradient( - colors = listOf(accent.copy(alpha = 0.55f), accent.copy(alpha = 0f)), - center = Offset(size.width * 0.18f, size.height * 0.03f), - radius = size.width * 0.95f, + colors = listOf(accent.copy(alpha = 0.85f), accent.copy(alpha = 0f)), + center = Offset(size.width * 0.20f, size.height * 0.00f), + radius = size.width * 0.60f, ), ) } @@ -179,9 +195,9 @@ fun HeroGradient( drawRect( brush = Brush.radialGradient( - colors = listOf(accent.copy(alpha = 0.50f), accent.copy(alpha = 0f)), - center = Offset(size.width * 0.92f, size.height * 0.28f), - radius = size.width * 0.85f, + 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, ), ) } @@ -189,18 +205,18 @@ fun HeroGradient( drawRect( brush = Brush.radialGradient( - colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)), - center = Offset(size.width * 0.85f, size.height * 0.02f), - radius = size.width * 0.70f, + 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.45f), high.copy(alpha = 0f)), - center = Offset(size.width * 0.35f, size.height * 0.52f), - radius = size.width * 0.95f, + 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(