diff --git a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt b/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt deleted file mode 100644 index 38d6c6d3..00000000 --- a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt +++ /dev/null @@ -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() - } - } -} diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt new file mode 100644 index 00000000..b01c09f3 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt @@ -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() } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index acca34a7..c5e99eae 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index df6901d8..0c3f37f6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -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)) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 67c0c17e..e239880c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 47d8067f..0af9850b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -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)) } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 24b4ec95..a8251dc3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -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)) } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 2511cf96..a95f68f6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -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)) } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index eb53e15a..60aee7df 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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), diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index c6a14eda..a14dbda5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -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(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)) } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index d2a04547..776c1bd1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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(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)) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index a7a14a49..fef73f3e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -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, ) -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 { +private fun rememberArtworkSeed(ref: CoverArtRef?): State?> { val context = LocalPlatformContext.current - return produceState(initialValue = null, ref?.cacheKey) { + return produceState?>(initialValue = null, ref?.cacheKey) { value = null val art = ref ?: return@produceState value = @@ -129,7 +118,7 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State { .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 { } } -private fun seedFrom(bitmap: Bitmap): Int? { +private fun seedsFrom(bitmap: Bitmap): Pair? { 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