stretch overscroll everywhere, overlay the album bar, richer gradient

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent b9519f1a7f
commit 461eadb630
12 changed files with 208 additions and 73 deletions
@@ -0,0 +1,83 @@
package com.subtracks.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.35f
@Composable
fun Modifier.stretchOverscroll(): Modifier {
val overscroll = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val connection =
remember(overscroll) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
val current = overscroll.value
if (current == 0f || available.y == 0f) return Offset.Zero
val next = current + available.y
scope.launch {
overscroll.snapTo(if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f))
}
return available
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val current = overscroll.value
if (available.y == 0f) return Offset.Zero
val next = current + available.y
scope.launch {
overscroll.snapTo(if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f))
}
return available
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity,
): Velocity {
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
return available
}
}
}
return this
.nestedScroll(connection)
.graphicsLayer {
val raw = overscroll.value
if (raw == 0f) {
scaleY = 1f
translationY = 0f
} else {
val limit = size.height * MAX_STRETCH_FRACTION
val capped = raw.coerceIn(-limit, limit)
val resisted = capped / (1f + abs(capped) / (size.height * 0.5f))
scaleY = 1f + abs(resisted) / size.height
translationY = resisted
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
}
}
}
@@ -4,10 +4,12 @@ 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.interaction.MutableInteractionSource
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -15,6 +17,7 @@ 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.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
@@ -36,7 +39,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -51,8 +53,12 @@ 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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -65,6 +71,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
@@ -76,8 +83,7 @@ import kotlin.math.roundToInt
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
private const val FADE_DISTANCE_DP = 64
@Composable
fun AlbumDetailRoute(
@@ -122,12 +128,13 @@ fun AlbumDetailScreen(
val listState = rememberLazyListState()
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(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) }
var titleTopPx by remember { mutableFloatStateOf(0f) }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
@@ -137,12 +144,10 @@ fun AlbumDetailScreen(
}
val barFraction by remember {
derivedStateOf {
if (headerHeightPx == 0f) {
if (titleTopPx <= 0f || fadeDistancePx <= 0f) {
0f
} else {
val end = headerHeightPx - controlsBlockPx - barHeightPx
val start = end - fadeDistancePx
((scrollPx - start) / (end - start)).coerceIn(0f, 1f)
((scrollPx - (titleTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
}
}
}
@@ -162,53 +167,75 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha),
)
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
text = album?.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction),
)
},
navigationIcon = {
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)),
LazyColumn(
state = listState,
contentPadding =
PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() },
),
modifier = Modifier.fillMaxSize().stretchOverscroll(),
) {
item {
AlbumHeader(
album = album,
hasSongs = songs.isNotEmpty(),
coverArt = coverArt,
onPlay = { onSongClick(0) },
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y },
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
)
}
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
SongRow(
song = song,
isPlaying = song.id == playingSongId,
modifier =
Modifier
.onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
},
) { padding ->
LazyColumn(state = listState, modifier = Modifier.padding(padding).fillMaxSize()) {
item {
AlbumHeader(
album = album,
hasSongs = songs.isNotEmpty(),
coverArt = coverArt,
onPlay = { onSongClick(0) },
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
.onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
}
}
}
Box(
Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(with(density) { (barHeightPx + 24.dp.toPx()).toDp() })
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent),
),
).alpha(1f - barFraction),
)
TopAppBar(
title = {
Text(
text = album?.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction),
)
},
navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.alpha(barFraction)) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier =
Modifier
.align(Alignment.TopStart)
.background(Color.Black.copy(alpha = barFraction))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
)
}
}
}
@@ -223,16 +250,20 @@ private fun AlbumHeader(
onShuffle: () -> Unit,
onDownload: () -> Unit,
onMore: () -> Unit,
titleModifier: Modifier = Modifier,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
modifier =
modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CoverArt(
ref = coverArt(album?.coverArt, false),
name = album?.name.orEmpty(),
modifier = Modifier.fillMaxWidth(0.78f).aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
modifier = Modifier.fillMaxWidth(0.86f).aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
Text(
@@ -241,6 +272,7 @@ private fun AlbumHeader(
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = titleModifier,
)
val subtitle =
listOfNotNull(
@@ -23,6 +23,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll
const val ALBUM_COVER_TAG = "album-cover"
@@ -49,7 +50,7 @@ fun AlbumsContent(
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().stretchOverscroll(),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index]
@@ -50,6 +50,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -128,7 +129,7 @@ fun ArtistDetailScreen(
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().stretchOverscroll(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
@@ -24,6 +24,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll
@Composable
fun ArtistsContent(
@@ -44,7 +45,7 @@ fun ArtistsContent(
else -> {
LazyColumn(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().stretchOverscroll(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
@@ -35,6 +35,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -83,7 +84,7 @@ fun PlaylistDetailScreen(
)
},
) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -24,6 +24,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll
@Composable
fun PlaylistsContent(
@@ -44,7 +45,7 @@ fun PlaylistsContent(
else -> {
LazyColumn(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().stretchOverscroll(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
@@ -32,6 +32,7 @@ import com.subtracks.data.model.SongListItem
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll
@Composable
fun SongsContent(
@@ -53,7 +54,7 @@ fun SongsContent(
else -> {
LazyColumn(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().stretchOverscroll(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
@@ -51,6 +51,7 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -340,7 +341,7 @@ fun QueueScreen(
else -> {
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
modifier = Modifier.padding(padding).fillMaxSize().stretchOverscroll(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(count = rows.size, key = { rows[it].id }) { index ->
@@ -32,6 +32,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
@Composable
@@ -105,6 +106,7 @@ fun AddSourceScreen(
Modifier
.padding(padding)
.fillMaxSize()
.stretchOverscroll()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -101,7 +102,7 @@ fun SettingsScreen(
)
},
) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) {
item { SectionHeader("Servers") }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
@@ -82,9 +82,9 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
return ArtworkColors(
scheme = scheme,
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)),
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.70f), tone(hue, s * 0.85f, 0.38f)),
)
}
@@ -169,9 +169,9 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
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,
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,
),
)
}
@@ -179,9 +179,19 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
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,
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,
),
)
}
accents.getOrNull(0)?.let { accent ->
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,
),
)
}
@@ -189,8 +199,8 @@ 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.45f),
radius = size.width * 0.90f,
center = Offset(size.width * 0.35f, size.height * 0.52f),
radius = size.width * 0.95f,
),
)
drawRect(