drop the custom overscroll, keep lists a hair scrollable, restore colours

This commit is contained in:
austinried
2026-10-04 00:29:53 +00:00
parent c787335a1e
commit f9bb38af74
12 changed files with 462 additions and 480 deletions
@@ -1,114 +0,0 @@
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.foundation.LocalOverscrollFactory
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.OverscrollFactory
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.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.unit.Velocity
import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.14f
private class StretchOverscrollEffect : OverscrollEffect {
val overscroll = mutableFloatStateOf(0f)
var limitPx: Float = 1f
private val emptyNode = object : Modifier.Node() {}
override val node: DelegatableNode get() = emptyNode
override val isInProgress: Boolean get() = overscroll.floatValue != 0f
override fun applyToScroll(
delta: Offset,
source: NestedScrollSource,
performScroll: (Offset) -> Offset,
): Offset {
if (delta.y == 0f) return performScroll(delta)
var remainingY = delta.y
if (overscroll.floatValue != 0f) {
val current = overscroll.floatValue
val next = current + remainingY
val clamped = if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f)
remainingY -= clamped - current
overscroll.floatValue = clamped
}
val consumed = performScroll(Offset(delta.x, remainingY))
val leftover = remainingY - consumed.y
if (leftover != 0f) {
val current = overscroll.floatValue
val next = (current + leftover).coerceIn(-limitPx, limitPx)
overscroll.floatValue =
when {
current > 0f -> next.coerceAtLeast(0f)
current < 0f -> next.coerceAtMost(0f)
else -> next
}
}
return Offset(consumed.x, delta.y)
}
override suspend fun applyToFling(
velocity: Velocity,
performFling: suspend (Velocity) -> Velocity,
) {
if (overscroll.floatValue != 0f) {
Animatable(overscroll.floatValue)
.animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) {
overscroll.floatValue = value
}
}
performFling(velocity)
}
}
@Composable
fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
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()
}
}
}
@@ -0,0 +1,56 @@
package com.subtracks.ui.components
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
private const val FILL_EPSILON_PX = 1
@Composable
fun rememberViewportFill(state: LazyListState): Dp {
val density = LocalDensity.current
val fillPx by remember {
derivedStateOf {
val info = state.layoutInfo
val realCount = info.totalItemsCount - 1
if (realCount <= 0) {
FILL_EPSILON_PX
} else {
val visibleReal = info.visibleItemsInfo.filter { it.index < realCount }
if (visibleReal.size < realCount) {
FILL_EPSILON_PX
} else {
(info.viewportSize.height - visibleReal.sumOf { it.size }).coerceAtLeast(0) + FILL_EPSILON_PX
}
}
}
}
return with(density) { fillPx.toDp() }
}
@Composable
fun rememberViewportFill(state: LazyGridState): Dp {
val density = LocalDensity.current
val fillPx by remember {
derivedStateOf {
val info = state.layoutInfo
val realCount = info.totalItemsCount - 1
if (realCount <= 0) {
FILL_EPSILON_PX
} else {
val visibleReal = info.visibleItemsInfo.filter { it.index < realCount }
if (visibleReal.size < realCount) {
FILL_EPSILON_PX
} else {
(info.viewportSize.height - visibleReal.sumOf { it.size.height }).coerceAtLeast(0) + FILL_EPSILON_PX
}
}
}
}
return with(density) { fillPx.toDp() }
}
@@ -73,7 +73,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.rememberViewportFill
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
@@ -128,6 +128,7 @@ fun AlbumDetailScreen(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) { BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
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)
@@ -170,7 +171,6 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha), .alpha(gradientAlpha),
) )
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = contentPadding =
@@ -206,7 +206,7 @@ fun AlbumDetailScreen(
.clickable { onSongClick(index) }, .clickable { onSongClick(index) },
) )
} }
} item { Spacer(Modifier.height(fill)) }
} }
Box( Box(
@@ -252,6 +252,7 @@ fun AlbumDetailScreen(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.background((artwork?.gradientHigh ?: Color.Black).copy(alpha = barFraction))
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -3,11 +3,15 @@ package com.subtracks.ui.library
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.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -23,7 +27,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.rememberViewportFill
const val ALBUM_COVER_TAG = "album-cover" const val ALBUM_COVER_TAG = "album-cover"
@@ -45,13 +49,15 @@ fun AlbumsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) { val gridState = rememberLazyGridState()
val fill = rememberViewportFill(gridState)
LazyVerticalGrid( LazyVerticalGrid(
state = gridState,
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(), 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]
@@ -69,6 +75,8 @@ fun AlbumsContent(
) )
} }
} }
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(fill))
} }
} }
} }
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -50,7 +52,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.rememberViewportFill
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -90,6 +92,7 @@ fun ArtistDetailScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyGridState() val listState = rememberLazyGridState()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val lineHeight = val lineHeight =
with(density) { with(density) {
@@ -123,7 +126,6 @@ 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),
@@ -178,6 +180,8 @@ fun ArtistDetailScreen(
) )
} }
} }
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(fill))
} }
} }
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
@@ -24,7 +27,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.rememberViewportFill
@Composable @Composable
fun ArtistsContent( fun ArtistsContent(
@@ -44,9 +47,11 @@ fun ArtistsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) { val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), state = listState,
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 ->
@@ -79,7 +84,7 @@ fun ArtistsContent(
) )
} }
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
} }
@@ -4,11 +4,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
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
@@ -35,7 +38,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.rememberViewportFill
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
@@ -71,6 +74,8 @@ fun PlaylistDetailScreen(
playingSongId: String? = null, playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -84,8 +89,10 @@ fun PlaylistDetailScreen(
) )
}, },
) { padding -> ) { padding ->
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) { LazyColumn(
LazyColumn(Modifier.fillMaxSize()) { state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -119,7 +126,7 @@ fun PlaylistDetailScreen(
) )
} }
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
} }
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
@@ -24,7 +27,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.rememberViewportFill
@Composable @Composable
fun PlaylistsContent( fun PlaylistsContent(
@@ -44,9 +47,11 @@ fun PlaylistsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) { val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), state = listState,
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 ->
@@ -79,7 +84,7 @@ fun PlaylistsContent(
) )
} }
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
} }
@@ -4,9 +4,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
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.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
@@ -20,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.text.font.FontWeight
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.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -32,7 +36,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.rememberViewportFill
@Composable @Composable
fun SongsContent( fun SongsContent(
@@ -53,9 +57,11 @@ fun SongsContent(
} }
else -> { else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) { val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), state = listState,
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 ->
@@ -70,7 +76,7 @@ fun SongsContent(
) )
} }
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
} }
@@ -116,6 +122,7 @@ fun SongRow(
Text( Text(
text = song.title, text = song.title,
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
@@ -6,7 +6,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -51,7 +53,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.rememberViewportFill
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
@@ -284,6 +286,7 @@ fun QueueScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) } var dragId by remember { mutableStateOf<Long?>(null) }
@@ -339,10 +342,9 @@ fun QueueScreen(
} }
else -> { else -> {
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.padding(padding).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 ->
@@ -391,7 +393,7 @@ fun QueueScreen(
) )
} }
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
} }
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
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.rememberLazyListState
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
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
@@ -40,7 +42,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.rememberViewportFill
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)
@@ -88,6 +90,8 @@ fun SettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) } var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
@@ -102,8 +106,10 @@ fun SettingsScreen(
) )
}, },
) { padding -> ) { padding ->
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) { LazyColumn(
LazyColumn(Modifier.fillMaxSize()) { state = listState,
modifier = Modifier.padding(padding).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]
@@ -155,7 +161,7 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
} item { Spacer(Modifier.height(fill)) }
} }
} }
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
@@ -37,16 +36,22 @@ data class ArtworkColors(
val accents: List<Color>, val accents: List<Color>,
) )
fun artworkColorsFromSeed(seed: Int): ArtworkColors { fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null)
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
fun artworkColorsFromSeeds(
primarySeed: Int,
secondarySeed: Int?,
): ArtworkColors {
val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl()
val s = saturation.coerceAtMost(0.85f) val s = saturation.coerceAtMost(0.85f)
val secondary = secondarySeed?.let { Color(it or 0xFF000000.toInt()) }
fun tone( fun tone(
h: Float, h: Float,
sat: Float, sat: Float,
l: Float, l: Float,
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f)) ) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
val primary = toneMaxLuminance(hue, s, 0.62f, 0.45f) val primary = tone(hue, s, 0.62f)
val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f) 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 onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
@@ -84,36 +89,20 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
scheme = scheme, scheme = scheme,
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f), gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
gradientLow = tone(hue, s * 0.30f, 0.03f), gradientLow = tone(hue, s * 0.30f, 0.03f),
accents = listOf(tone(hue, s * 0.75f, 0.95f), tone(hue, s, 0.08f)), accents = listOf(tone(hue, s * 0.8f, 0.90f), secondary ?: tone(hue, s, 0.10f)),
) )
} }
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 @Composable
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? { fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
val seed by rememberArtworkSeed(ref) val seeds by rememberArtworkSeed(ref)
return remember(seed) { seed?.let(::artworkColorsFromSeed) } return remember(seeds) { seeds?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
} }
@Composable @Composable
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> { private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
return produceState<Int?>(initialValue = null, ref?.cacheKey) { return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
value = null value = null
val art = ref ?: return@produceState val art = ref ?: return@produceState
value = value =
@@ -129,7 +118,7 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
.allowHardware(false) .allowHardware(false)
.build() .build()
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
image?.let { seedFrom(it.toBitmap()) } image?.let { seedsFrom(it.toBitmap()) }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
@@ -139,15 +128,21 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
} }
} }
private fun seedFrom(bitmap: Bitmap): Int? { private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
val palette = Palette.from(bitmap).generate() val palette = Palette.from(bitmap).generate()
return ( val primary =
palette.vibrantSwatch palette.vibrantSwatch
?: palette.lightVibrantSwatch ?: palette.lightVibrantSwatch
?: palette.darkVibrantSwatch ?: palette.darkVibrantSwatch
?: palette.mutedSwatch ?: palette.mutedSwatch
?: palette.dominantSwatch ?: palette.dominantSwatch
)?.rgb ?: return null
val secondary =
palette.darkVibrantSwatch
?: palette.mutedSwatch
?: palette.darkMutedSwatch
?: palette.dominantSwatch
return primary.rgb to secondary?.takeIf { it.rgb != primary.rgb }?.rgb
} }
@Composable @Composable