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

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 461eadb630
commit 651d548c41
12 changed files with 374 additions and 340 deletions
@@ -3,7 +3,10 @@ package com.subtracks.ui.components
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -17,10 +20,13 @@ import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.35f private const val MAX_STRETCH_FRACTION = 0.30f
@Composable @Composable
fun Modifier.stretchOverscroll(): Modifier { fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val overscroll = remember { Animatable(0f) } val overscroll = remember { Animatable(0f) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -58,26 +64,33 @@ fun Modifier.stretchOverscroll(): Modifier {
consumed: Velocity, consumed: Velocity,
available: Velocity, available: Velocity,
): Velocity { ): Velocity {
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow)) overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMedium))
return available return available
} }
} }
} }
return this CompositionLocalProvider(LocalOverscrollFactory provides null) {
.nestedScroll(connection) Box(
.graphicsLayer { modifier =
val raw = overscroll.value modifier
if (raw == 0f) { .nestedScroll(connection)
scaleY = 1f .graphicsLayer {
translationY = 0f val raw = overscroll.value
} else { if (raw == 0f) {
val limit = size.height * MAX_STRETCH_FRACTION scaleY = 1f
val capped = raw.coerceIn(-limit, limit) translationY = 0f
val resisted = capped / (1f + abs(capped) / (size.height * 0.5f)) } else {
scaleY = 1f + abs(resisted) / size.height val limit = size.height * MAX_STRETCH_FRACTION
translationY = resisted val capped = raw.coerceIn(-limit, limit)
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f) val resisted = capped / (1f + abs(capped) / (size.height * 0.5f))
} scaleY = 1f + abs(resisted) / size.height
translationY = resisted
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
}
},
) {
content()
} }
}
} }
@@ -62,6 +62,7 @@ import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -71,7 +72,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
@@ -82,7 +83,7 @@ import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val DOT = "\u00B7" private const val DOT = "\u00B7"
private const val GRADIENT_SCREENS = 1.5f private const val GRADIENT_SCREENS = 2.0f
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
@Composable @Composable
@@ -129,6 +130,7 @@ fun AlbumDetailScreen(
val density = LocalDensity.current val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density) val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(density) val navBarBottom = WindowInsets.navigationBars.getBottom(density)
val statusBarDp = with(density) { statusBarTop.toDp() }
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() } val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
@@ -167,36 +169,39 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha), .alpha(gradientAlpha),
) )
LazyColumn( StretchOverscroll(modifier = Modifier.fillMaxSize()) {
state = listState, LazyColumn(
contentPadding = state = listState,
PaddingValues( contentPadding =
bottom = 16.dp + with(density) { navBarBottom.toDp() }, PaddingValues(
), bottom = 16.dp + with(density) { navBarBottom.toDp() },
modifier = Modifier.fillMaxSize().stretchOverscroll(), ),
) { modifier = Modifier.fillMaxSize(),
item { ) {
AlbumHeader( item {
album = album, AlbumHeader(
hasSongs = songs.isNotEmpty(), album = album,
coverArt = coverArt, hasSongs = songs.isNotEmpty(),
onPlay = { onSongClick(0) }, coverArt = coverArt,
onShuffle = onShuffle, onPlay = { onSongClick(0) },
onDownload = onDownload, onShuffle = onShuffle,
onMore = onMore, onDownload = onDownload,
titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y }, onMore = onMore,
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() }, topInset = statusBarDp,
) titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y },
} modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
itemsIndexed(songs, key = { _, song -> song.id }) { index, song -> )
SongRow( }
song = song, itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
isPlaying = song.id == playingSongId, SongRow(
modifier = song = song,
Modifier isPlaying = song.id == playingSongId,
.onSizeChanged { rowHeightPx = it.height.toFloat() } modifier =
.clickable { onSongClick(index) }, Modifier
) .onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
}
} }
} }
@@ -204,7 +209,7 @@ fun AlbumDetailScreen(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth()
.height(with(density) { (barHeightPx + 24.dp.toPx()).toDp() }) .height(statusBarDp + 8.dp)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent), listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent),
@@ -216,6 +221,7 @@ fun AlbumDetailScreen(
title = { title = {
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction), modifier = Modifier.alpha(barFraction),
@@ -250,6 +256,7 @@ private fun AlbumHeader(
onShuffle: () -> Unit, onShuffle: () -> Unit,
onDownload: () -> Unit, onDownload: () -> Unit,
onMore: () -> Unit, onMore: () -> Unit,
topInset: Dp,
titleModifier: Modifier = Modifier, titleModifier: Modifier = Modifier,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -257,7 +264,7 @@ private fun AlbumHeader(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp), .padding(start = 24.dp, end = 24.dp, top = topInset + 8.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CoverArt( CoverArt(
@@ -268,7 +275,7 @@ private fun AlbumHeader(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = album?.name.orEmpty(), text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -23,7 +23,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
const val ALBUM_COVER_TAG = "album-cover" const val ALBUM_COVER_TAG = "album-cover"
@@ -45,27 +45,29 @@ fun AlbumsContent(
} }
else -> { else -> {
LazyVerticalGrid( StretchOverscroll(modifier = modifier.fillMaxSize()) {
columns = GridCells.Fixed(3), LazyVerticalGrid(
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp), columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.fillMaxSize().stretchOverscroll(), verticalArrangement = Arrangement.spacedBy(4.dp),
) { modifier = Modifier.fillMaxSize(),
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> ) {
val album = items[index] items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
if (album != null) { val album = items[index]
CoverArt( if (album != null) {
ref = coverArt(album.coverArt, true), CoverArt(
name = album.name, ref = coverArt(album.coverArt, true),
modifier = name = album.name,
Modifier modifier =
.fillMaxWidth() Modifier
.aspectRatio(1f) .fillMaxWidth()
.clip(RoundedCornerShape(2.dp)) .aspectRatio(1f)
.clickable { onAlbumClick(album) } .clip(RoundedCornerShape(2.dp))
.testTag(ALBUM_COVER_TAG), .clickable { onAlbumClick(album) }
) .testTag(ALBUM_COVER_TAG),
)
}
} }
} }
} }
@@ -50,7 +50,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -123,58 +123,60 @@ fun ArtistDetailScreen(
) )
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
LazyVerticalGrid( StretchOverscroll(modifier = Modifier.fillMaxSize()) {
state = listState, LazyVerticalGrid(
columns = GridCells.Fixed(2), state = listState,
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom), columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().stretchOverscroll(), verticalArrangement = Arrangement.spacedBy(16.dp),
) { modifier = Modifier.fillMaxSize(),
item(span = { GridItemSpan(maxLineSpan) }) { ) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { item(span = { GridItemSpan(maxLineSpan) }) {
CoverArt( Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
ref = art, CoverArt(
name = artist?.name.orEmpty(), ref = art,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), name = artist?.name.orEmpty(),
) modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
Text( )
text = artist?.name.orEmpty(), Text(
style = imageNameStyle, text = artist?.name.orEmpty(),
color = Color.White, style = imageNameStyle,
maxLines = 1, color = Color.White,
overflow = TextOverflow.Ellipsis, maxLines = 1,
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET), overflow = TextOverflow.Ellipsis,
) modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
)
}
} }
} itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
itemsIndexed(albums, key = { _, album -> album.id }) { index, album -> Column(
Column( modifier =
modifier = Modifier
Modifier .padding(
.padding( start = if (index % 2 == 0) 16.dp else 0.dp,
start = if (index % 2 == 0) 16.dp else 0.dp, end = if (index % 2 == 1) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp, ).clickable { onAlbumClick(album) },
).clickable { onAlbumClick(album) }, ) {
) { CoverArt(
CoverArt( ref = coverArt(album.coverArt, false),
ref = coverArt(album.coverArt, false), name = album.name,
name = album.name, modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), )
) Text(
Text( text = album.name,
text = album.name, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1,
maxLines = 1, overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 6.dp),
modifier = Modifier.padding(top = 6.dp), )
) Text(
Text( text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0", style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) }
} }
} }
} }
@@ -24,7 +24,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun ArtistsContent( fun ArtistsContent(
@@ -44,38 +44,40 @@ fun ArtistsContent(
} }
else -> { else -> {
LazyColumn( StretchOverscroll(modifier = modifier.fillMaxSize()) {
modifier = modifier.fillMaxSize().stretchOverscroll(), LazyColumn(
contentPadding = PaddingValues(bottom = bottomInset), modifier = Modifier.fillMaxSize(),
) { contentPadding = PaddingValues(bottom = bottomInset),
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> ) {
val artist = items[index] items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
if (artist != null) { val artist = items[index]
ListItem( if (artist != null) {
headlineContent = { ListItem(
Text( headlineContent = {
text = artist.name, Text(
maxLines = 1, text = artist.name,
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
}, )
supportingContent = { },
Text( supportingContent = {
text = "${artist.albumCount} albums", Text(
maxLines = 1, text = "${artist.albumCount} albums",
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
}, )
leadingContent = { },
CoverArt( leadingContent = {
ref = coverArt(artist.coverArt, true), CoverArt(
name = artist.name, ref = coverArt(artist.coverArt, true),
modifier = Modifier.size(48.dp).clip(CircleShape), name = artist.name,
) modifier = Modifier.size(48.dp).clip(CircleShape),
}, )
modifier = Modifier.clickable { onArtistClick(artist) }, },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable { onArtistClick(artist) },
) colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
} }
} }
} }
@@ -35,7 +35,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -84,38 +84,40 @@ fun PlaylistDetailScreen(
) )
}, },
) { padding -> ) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) { StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
item { LazyColumn(Modifier.fillMaxSize()) {
Row( item {
modifier = Modifier.fillMaxWidth().padding(16.dp), Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp),
) { verticalAlignment = Alignment.CenterVertically,
CoverArt( ) {
ref = coverArt(playlist?.coverArt, false), CoverArt(
name = playlist?.name.orEmpty(), ref = coverArt(playlist?.coverArt, false),
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)), 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,
) )
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 ->
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> val item = songs[index]
val item = songs[index] if (item != null) {
if (item != null) { SongRow(
SongRow( song = item.song,
song = item.song, coverArtId = item.coverArt,
coverArtId = item.coverArt, coverArt = coverArt,
coverArt = coverArt, isPlaying = item.song.id == playingSongId,
isPlaying = item.song.id == playingSongId, modifier = Modifier.clickable { onSongClick(index) },
modifier = Modifier.clickable { onSongClick(index) }, )
) }
} }
} }
} }
@@ -24,7 +24,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun PlaylistsContent( fun PlaylistsContent(
@@ -44,38 +44,40 @@ fun PlaylistsContent(
} }
else -> { else -> {
LazyColumn( StretchOverscroll(modifier = modifier.fillMaxSize()) {
modifier = modifier.fillMaxSize().stretchOverscroll(), LazyColumn(
contentPadding = PaddingValues(bottom = bottomInset), modifier = Modifier.fillMaxSize(),
) { contentPadding = PaddingValues(bottom = bottomInset),
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> ) {
val playlist = items[index] items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
if (playlist != null) { val playlist = items[index]
ListItem( if (playlist != null) {
headlineContent = { ListItem(
Text( headlineContent = {
text = playlist.name, Text(
maxLines = 1, text = playlist.name,
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
}, )
supportingContent = { },
Text( supportingContent = {
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs", Text(
maxLines = 1, text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
}, )
leadingContent = { },
CoverArt( leadingContent = {
ref = coverArt(playlist.coverArt, true), CoverArt(
name = playlist.name, ref = coverArt(playlist.coverArt, true),
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), name = playlist.name,
) modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
}, )
modifier = Modifier.clickable { onPlaylistClick(playlist) }, },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable { onPlaylistClick(playlist) },
) colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
} }
} }
} }
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SongListItem
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
@Composable @Composable
fun SongsContent( fun SongsContent(
@@ -53,20 +53,22 @@ fun SongsContent(
} }
else -> { else -> {
LazyColumn( StretchOverscroll(modifier = modifier.fillMaxSize()) {
modifier = modifier.fillMaxSize().stretchOverscroll(), LazyColumn(
contentPadding = PaddingValues(bottom = bottomInset), modifier = Modifier.fillMaxSize(),
) { contentPadding = PaddingValues(bottom = bottomInset),
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index -> ) {
val item = items[index] items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
if (item != null) { val item = items[index]
SongRow( if (item != null) {
song = item.song, SongRow(
coverArtId = item.coverArt, song = item.song,
coverArt = coverArt, coverArtId = item.coverArt,
isPlaying = item.song.id == playingSongId, coverArt = coverArt,
modifier = Modifier.clickable { onSongClick(index) }, isPlaying = item.song.id == playingSongId,
) modifier = Modifier.clickable { onSongClick(index) },
)
}
} }
} }
} }
@@ -51,7 +51,7 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -339,55 +339,57 @@ fun QueueScreen(
} }
else -> { else -> {
LazyColumn( StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
state = listState, LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize().stretchOverscroll(), state = listState,
contentPadding = PaddingValues(bottom = 16.dp), modifier = Modifier.fillMaxSize(),
) { contentPadding = PaddingValues(bottom = 16.dp),
items(count = rows.size, key = { rows[it].id }) { index -> ) {
val row = rows[index] items(count = rows.size, key = { rows[it].id }) { index ->
ReorderableItem( val row = rows[index]
state = reorderState, ReorderableItem(
key = row.id, state = reorderState,
animateItemModifier = key = row.id,
Modifier.animateItem( animateItemModifier =
fadeInSpec = tween(150), Modifier.animateItem(
fadeOutSpec = tween(150), fadeInSpec = tween(150),
placementSpec = tween(200), 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) }, ) { isDragging ->
onRemove = { QueueRowItem(
onRemove(row.position) row = row,
showUndo("Removed from queue") 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")
},
)
}
} }
} }
} }
@@ -32,7 +32,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
@@ -106,7 +105,6 @@ fun AddSourceScreen(
Modifier Modifier
.padding(padding) .padding(padding)
.fillMaxSize() .fillMaxSize()
.stretchOverscroll()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -40,7 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.ui.components.stretchOverscroll import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320) private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -102,57 +102,59 @@ fun SettingsScreen(
) )
}, },
) { padding -> ) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) { StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
item { SectionHeader("Servers") } LazyColumn(Modifier.fillMaxSize()) {
items(sources.size, key = { sources[it].id }) { index -> item { SectionHeader("Servers") }
val source = sources[index] items(sources.size, key = { sources[it].id }) { index ->
ListItem( val source = sources[index]
headlineContent = { Text(source.name) }, ListItem(
supportingContent = { headlineContent = { Text(source.name) },
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis) supportingContent = {
}, Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
leadingContent = { },
RadioButton( leadingContent = {
selected = source.id == activeSourceId, RadioButton(
onClick = { onSelectSource(source.id) }, selected = source.id == activeSourceId,
) onClick = { onSelectSource(source.id) },
}, )
trailingContent = { },
IconButton(onClick = { onDeleteSource(source.id) }) { trailingContent = {
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}") 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")
} }
},
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 { SectionHeader("Network") } item {
item { ListItem(
ListItem( headlineContent = { Text("Maximum bitrate") },
headlineContent = { Text("Maximum bitrate") }, supportingContent = { Text(bitrateLabel(maxBitrate)) },
supportingContent = { Text(bitrateLabel(maxBitrate)) }, modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate }, colors = ListItemDefaults.colors(containerColor = Color.Transparent),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), )
) }
} item {
item { ListItem(
ListItem( headlineContent = { Text("Preferred stream format") },
headlineContent = { Text("Preferred stream format") }, supportingContent = { Text(streamFormat ?: "Use server default") },
supportingContent = { Text(streamFormat ?: "Use server default") }, modifier = Modifier.clickable { dialog = SettingsDialog.Format },
modifier = Modifier.clickable { dialog = SettingsDialog.Format }, colors = ListItemDefaults.colors(containerColor = Color.Transparent),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), )
) }
} }
} }
} }
@@ -27,7 +27,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val SEED_ART_SIZE_PX = 128 private const val SEED_ART_SIZE_PX = 128
private const val GRADIENT_FADE_START = 0.8f / 1.5f private const val GRADIENT_FADE_START = 1.3f / 2.0f
data class ArtworkColors( data class ArtworkColors(
val scheme: ColorScheme, val scheme: ColorScheme,
@@ -84,7 +84,7 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
scheme = scheme, scheme = scheme,
gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f), gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f),
gradientLow = tone(hue, s * 0.25f, 0.04f), gradientLow = tone(hue, s * 0.25f, 0.04f),
accents = listOf(tone(hue, s, 0.70f), tone(hue, s * 0.85f, 0.38f)), accents = listOf(tone(hue, s, 0.78f), tone(hue, s * 0.9f, 0.20f)),
) )
} }