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.playback.PlaybackController
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.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
@@ -128,6 +128,7 @@ fun AlbumDetailScreen(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
@@ -170,43 +171,42 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha),
)
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
contentPadding =
PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() },
),
modifier = Modifier.fillMaxSize(),
) {
item {
AlbumHeader(
album = album,
hasSongs = songs.isNotEmpty(),
coverArt = coverArt,
onPlay = { onSongClick(0) },
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
topInset = statusBarDp,
controlsModifier =
Modifier.onGloballyPositioned {
controlsTopPx = 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) },
)
}
LazyColumn(
state = listState,
contentPadding =
PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() },
),
modifier = Modifier.fillMaxSize(),
) {
item {
AlbumHeader(
album = album,
hasSongs = songs.isNotEmpty(),
coverArt = coverArt,
onPlay = { onSongClick(0) },
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
topInset = statusBarDp,
controlsModifier =
Modifier.onGloballyPositioned {
controlsTopPx = 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) },
)
}
item { Spacer(Modifier.height(fill)) }
}
Box(
@@ -252,6 +252,7 @@ fun AlbumDetailScreen(
modifier =
Modifier
.align(Alignment.TopStart)
.background((artwork?.gradientHigh ?: Color.Black).copy(alpha = barFraction))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -3,11 +3,15 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.EmptyState
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"
@@ -45,31 +49,35 @@ fun AlbumsContent(
}
else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
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(),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index]
if (album != null) {
CoverArt(
ref = coverArt(album.coverArt, true),
name = album.name,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.clickable { onAlbumClick(album) }
.testTag(ALBUM_COVER_TAG),
)
}
val gridState = rememberLazyGridState()
val fill = rememberViewportFill(gridState)
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(3),
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(),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index]
if (album != null) {
CoverArt(
ref = coverArt(album.coverArt, true),
name = album.name,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.clickable { onAlbumClick(album) }
.testTag(ALBUM_COVER_TAG),
)
}
}
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.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
import androidx.compose.foundation.layout.aspectRatio
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.CoverArtRef
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.core.parameter.parametersOf
@@ -90,6 +92,7 @@ fun ArtistDetailScreen(
modifier: Modifier = Modifier,
) {
val listState = rememberLazyGridState()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
val lineHeight =
with(density) {
@@ -123,62 +126,63 @@ fun ArtistDetailScreen(
)
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid(
state = listState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
CoverArt(
ref = art,
name = artist?.name.orEmpty(),
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
Text(
text = artist?.name.orEmpty(),
style = imageNameStyle,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
)
}
LazyVerticalGrid(
state = listState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
CoverArt(
ref = art,
name = artist?.name.orEmpty(),
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
Text(
text = artist?.name.orEmpty(),
style = imageNameStyle,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
)
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).clickable { onAlbumClick(album) },
) {
CoverArt(
ref = coverArt(album.coverArt, false),
name = album.name,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).clickable { onAlbumClick(album) },
) {
CoverArt(
ref = coverArt(album.coverArt, false),
name = album.name,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(fill))
}
}
Box(
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ListItem
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.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.components.rememberViewportFill
@Composable
fun ArtistsContent(
@@ -44,42 +47,44 @@ fun ArtistsContent(
}
else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index]
if (artist != null) {
ListItem(
headlineContent = {
Text(
text = artist.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = "${artist.albumCount} albums",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
modifier = Modifier.clickable { onArtistClick(artist) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index]
if (artist != null) {
ListItem(
headlineContent = {
Text(
text = artist.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = "${artist.albumCount} albums",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
modifier = Modifier.clickable { onArtistClick(artist) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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
@@ -35,7 +38,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 com.subtracks.ui.components.rememberViewportFill
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -71,6 +74,8 @@ fun PlaylistDetailScreen(
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
Scaffold(
modifier = modifier,
topBar = {
@@ -84,42 +89,44 @@ fun PlaylistDetailScreen(
)
},
) { padding ->
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize()) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = coverArt(playlist?.coverArt, false),
name = playlist?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
)
Column {
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
Text(
text = "${playlist?.songCount ?: 0} songs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index]
if (item != null) {
SongRow(
song = item.song,
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = coverArt(playlist?.coverArt, false),
name = playlist?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
)
Column {
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
Text(
text = "${playlist?.songCount ?: 0} songs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index]
if (item != null) {
SongRow(
song = item.song,
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
)
}
}
item { Spacer(Modifier.height(fill)) }
}
}
}
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
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.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.components.rememberViewportFill
@Composable
fun PlaylistsContent(
@@ -44,42 +47,44 @@ fun PlaylistsContent(
}
else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val playlist = items[index]
if (playlist != null) {
ListItem(
headlineContent = {
Text(
text = playlist.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
CoverArt(
ref = coverArt(playlist.coverArt, true),
name = playlist.name,
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
)
},
modifier = Modifier.clickable { onPlaylistClick(playlist) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val playlist = items[index]
if (playlist != null) {
ListItem(
headlineContent = {
Text(
text = playlist.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
CoverArt(
ref = coverArt(playlist.coverArt, true),
name = playlist.name,
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
)
},
modifier = Modifier.clickable { onPlaylistClick(playlist) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.components.rememberViewportFill
@Composable
fun SongsContent(
@@ -53,24 +57,26 @@ fun SongsContent(
}
else -> {
StretchOverscroll(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
val item = items[index]
if (item != null) {
SongRow(
song = item.song,
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
)
}
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
val item = items[index]
if (item != null) {
SongRow(
song = item.song,
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
)
}
}
item { Spacer(Modifier.height(fill)) }
}
}
}
@@ -116,6 +122,7 @@ fun SongRow(
Text(
text = song.title,
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -51,7 +53,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.components.rememberViewportFill
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -284,6 +286,7 @@ fun QueueScreen(
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) }
@@ -339,59 +342,58 @@ fun QueueScreen(
}
else -> {
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(count = rows.size, key = { rows[it].id }) { index ->
val row = rows[index]
ReorderableItem(
state = reorderState,
key = row.id,
animateItemModifier =
Modifier.animateItem(
fadeInSpec = tween(150),
fadeOutSpec = tween(150),
placementSpec = tween(200),
),
) { isDragging ->
QueueRowItem(
row = row,
isPlaying = row.position == currentPosition,
floating = isDragging,
coverArt = coverArt,
dragHandle =
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from)
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
}
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(count = rows.size, key = { rows[it].id }) { index ->
val row = rows[index]
ReorderableItem(
state = reorderState,
key = row.id,
animateItemModifier =
Modifier.animateItem(
fadeInSpec = tween(150),
fadeOutSpec = tween(150),
placementSpec = tween(200),
),
) { isDragging ->
QueueRowItem(
row = row,
isPlaying = row.position == currentPosition,
floating = isDragging,
coverArt = coverArt,
dragHandle =
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from)
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
}
dragId = null
dragFrom = null
},
),
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
showUndo("Removed from queue")
},
)
}
}
dragId = null
dragFrom = null
},
),
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
showUndo("Removed from queue")
},
)
}
}
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
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.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source
import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.components.rememberViewportFill
import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -88,6 +90,8 @@ fun SettingsScreen(
modifier: Modifier = Modifier,
) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
Scaffold(
modifier = modifier,
@@ -102,60 +106,62 @@ fun SettingsScreen(
)
},
) { padding ->
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize()) {
item { SectionHeader("Servers") }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
ListItem(
headlineContent = { Text(source.name) },
supportingContent = {
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = {
RadioButton(
selected = source.id == activeSourceId,
onClick = { onSelectSource(source.id) },
)
},
trailingContent = {
IconButton(onClick = { onDeleteSource(source.id) }) {
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add server")
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item { SectionHeader("Servers") }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
ListItem(
headlineContent = { Text(source.name) },
supportingContent = {
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = {
RadioButton(
selected = source.id == activeSourceId,
onClick = { onSelectSource(source.id) },
)
},
trailingContent = {
IconButton(onClick = { onDeleteSource(source.id) }) {
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add server")
}
}
item { SectionHeader("Network") }
item {
ListItem(
headlineContent = { Text("Maximum bitrate") },
supportingContent = { Text(bitrateLabel(maxBitrate)) },
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Preferred stream format") },
supportingContent = { Text(streamFormat ?: "Use server default") },
modifier = Modifier.clickable { dialog = SettingsDialog.Format },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
item { SectionHeader("Network") }
item {
ListItem(
headlineContent = { Text("Maximum bitrate") },
supportingContent = { Text(bitrateLabel(maxBitrate)) },
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Preferred stream format") },
supportingContent = { Text(streamFormat ?: "Use server default") },
modifier = Modifier.clickable { dialog = SettingsDialog.Format },
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.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
@@ -37,16 +36,22 @@ data class ArtworkColors(
val accents: List<Color>,
)
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null)
fun artworkColorsFromSeeds(
primarySeed: Int,
secondarySeed: Int?,
): ArtworkColors {
val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl()
val s = saturation.coerceAtMost(0.85f)
val secondary = secondarySeed?.let { Color(it or 0xFF000000.toInt()) }
fun tone(
h: Float,
sat: Float,
l: Float,
) = 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 onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
@@ -84,36 +89,20 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
scheme = scheme,
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)),
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
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
val seed by rememberArtworkSeed(ref)
return remember(seed) { seed?.let(::artworkColorsFromSeed) }
val seeds by rememberArtworkSeed(ref)
return remember(seeds) { seeds?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
}
@Composable
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
val context = LocalPlatformContext.current
return produceState<Int?>(initialValue = null, ref?.cacheKey) {
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
value = null
val art = ref ?: return@produceState
value =
@@ -129,7 +118,7 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
.allowHardware(false)
.build()
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
image?.let { seedFrom(it.toBitmap()) }
image?.let { seedsFrom(it.toBitmap()) }
} catch (e: CancellationException) {
throw e
} 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()
return (
val primary =
palette.vibrantSwatch
?: palette.lightVibrantSwatch
?: palette.darkVibrantSwatch
?: palette.mutedSwatch
?: 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