fix overscroll stretch, pad the album hero, push the gradient fade down

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 461eadb630
commit 651d548c41
12 changed files with 374 additions and 340 deletions
@@ -3,7 +3,10 @@ package com.subtracks.ui.components
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -17,10 +20,13 @@ import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.35f private const val MAX_STRETCH_FRACTION = 0.30f
@Composable @Composable
fun Modifier.stretchOverscroll(): Modifier { fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val overscroll = remember { Animatable(0f) } val overscroll = remember { Animatable(0f) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -58,13 +64,16 @@ fun Modifier.stretchOverscroll(): Modifier {
consumed: Velocity, consumed: Velocity,
available: Velocity, available: Velocity,
): Velocity { ): Velocity {
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow)) overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMedium))
return available return available
} }
} }
} }
return this CompositionLocalProvider(LocalOverscrollFactory provides null) {
Box(
modifier =
modifier
.nestedScroll(connection) .nestedScroll(connection)
.graphicsLayer { .graphicsLayer {
val raw = overscroll.value val raw = overscroll.value
@@ -79,5 +88,9 @@ fun Modifier.stretchOverscroll(): Modifier {
translationY = resisted translationY = resisted
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f) transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
} }
},
) {
content()
}
} }
} }
@@ -62,6 +62,7 @@ import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity 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.Dp
import androidx.compose.ui.unit.IntOffset 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
@@ -71,7 +72,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
@@ -82,7 +83,7 @@ import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val DOT = "\u00B7" private const val DOT = "\u00B7"
private const val GRADIENT_SCREENS = 1.5f private const val GRADIENT_SCREENS = 2.0f
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
@Composable @Composable
@@ -129,6 +130,7 @@ fun AlbumDetailScreen(
val density = LocalDensity.current val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density) val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(density) val navBarBottom = WindowInsets.navigationBars.getBottom(density)
val statusBarDp = with(density) { statusBarTop.toDp() }
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() } val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
@@ -167,13 +169,14 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha), .alpha(gradientAlpha),
) )
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = contentPadding =
PaddingValues( PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() }, bottom = 16.dp + with(density) { navBarBottom.toDp() },
), ),
modifier = Modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {
AlbumHeader( AlbumHeader(
@@ -184,6 +187,7 @@ fun AlbumDetailScreen(
onShuffle = onShuffle, onShuffle = onShuffle,
onDownload = onDownload, onDownload = onDownload,
onMore = onMore, onMore = onMore,
topInset = statusBarDp,
titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y }, titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y },
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() }, modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
) )
@@ -199,12 +203,13 @@ fun AlbumDetailScreen(
) )
} }
} }
}
Box( Box(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth()
.height(with(density) { (barHeightPx + 24.dp.toPx()).toDp() }) .height(statusBarDp + 8.dp)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent), listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent),
@@ -216,6 +221,7 @@ fun AlbumDetailScreen(
title = { title = {
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction), modifier = Modifier.alpha(barFraction),
@@ -250,6 +256,7 @@ private fun AlbumHeader(
onShuffle: () -> Unit, onShuffle: () -> Unit,
onDownload: () -> Unit, onDownload: () -> Unit,
onMore: () -> Unit, onMore: () -> Unit,
topInset: Dp,
titleModifier: Modifier = Modifier, titleModifier: Modifier = Modifier,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -257,7 +264,7 @@ private fun AlbumHeader(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp), .padding(start = 24.dp, end = 24.dp, top = topInset + 8.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CoverArt( CoverArt(
@@ -268,7 +275,7 @@ private fun AlbumHeader(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -23,7 +23,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
const val ALBUM_COVER_TAG = "album-cover" const val ALBUM_COVER_TAG = "album-cover"
@@ -45,12 +45,13 @@ fun AlbumsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp), contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
) { ) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index] val album = items[index]
@@ -71,4 +72,5 @@ fun AlbumsContent(
} }
} }
} }
}
} }
@@ -50,7 +50,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -123,13 +123,14 @@ fun ArtistDetailScreen(
) )
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid( LazyVerticalGrid(
state = listState, state = listState,
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom), contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
@@ -178,6 +179,7 @@ fun ArtistDetailScreen(
} }
} }
} }
}
Box( Box(
modifier = modifier =
@@ -24,7 +24,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun ArtistsContent( fun ArtistsContent(
@@ -44,8 +44,9 @@ fun ArtistsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset), contentPadding = PaddingValues(bottom = bottomInset),
) { ) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
@@ -81,4 +82,5 @@ fun ArtistsContent(
} }
} }
} }
}
} }
@@ -35,7 +35,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
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
@@ -84,7 +84,8 @@ fun PlaylistDetailScreen(
) )
}, },
) { padding -> ) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) { StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize()) {
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -120,4 +121,5 @@ fun PlaylistDetailScreen(
} }
} }
} }
}
} }
@@ -24,7 +24,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun PlaylistsContent( fun PlaylistsContent(
@@ -44,8 +44,9 @@ fun PlaylistsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset), contentPadding = PaddingValues(bottom = bottomInset),
) { ) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
@@ -81,4 +82,5 @@ fun PlaylistsContent(
} }
} }
} }
}
} }
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SongListItem
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun SongsContent( fun SongsContent(
@@ -53,8 +53,9 @@ fun SongsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset), contentPadding = PaddingValues(bottom = bottomInset),
) { ) {
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
@@ -72,6 +73,7 @@ fun SongsContent(
} }
} }
} }
}
} }
@Composable @Composable
@@ -51,7 +51,7 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -339,9 +339,10 @@ fun QueueScreen(
} }
else -> { else -> {
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.padding(padding).fillMaxSize().stretchOverscroll(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp), contentPadding = PaddingValues(bottom = 16.dp),
) { ) {
items(count = rows.size, key = { rows[it].id }) { index -> items(count = rows.size, key = { rows[it].id }) { index ->
@@ -394,6 +395,7 @@ fun QueueScreen(
} }
} }
} }
}
} }
internal fun dropTarget( internal fun dropTarget(
@@ -32,7 +32,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
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.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
@@ -106,7 +105,6 @@ fun AddSourceScreen(
Modifier Modifier
.padding(padding) .padding(padding)
.fillMaxSize() .fillMaxSize()
.stretchOverscroll()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -40,7 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
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.Source import com.subtracks.data.model.Source
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320) private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -102,7 +102,8 @@ fun SettingsScreen(
) )
}, },
) { padding -> ) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) { StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize()) {
item { SectionHeader("Servers") } item { SectionHeader("Servers") }
items(sources.size, key = { sources[it].id }) { index -> items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index] val source = sources[index]
@@ -156,6 +157,7 @@ fun SettingsScreen(
} }
} }
} }
}
when (dialog) { when (dialog) {
SettingsDialog.Bitrate -> { SettingsDialog.Bitrate -> {
@@ -27,7 +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 private const val GRADIENT_FADE_START = 1.3f / 2.0f
data class ArtworkColors( data class ArtworkColors(
val scheme: ColorScheme, val scheme: ColorScheme,
@@ -84,7 +84,7 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
scheme = scheme, scheme = scheme,
gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f), gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f),
gradientLow = tone(hue, s * 0.25f, 0.04f), gradientLow = tone(hue, s * 0.25f, 0.04f),
accents = listOf(tone(hue, s, 0.70f), tone(hue, s * 0.85f, 0.38f)), accents = listOf(tone(hue, s, 0.78f), tone(hue, s * 0.9f, 0.20f)),
) )
} }