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.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.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@@ -17,10 +20,13 @@ import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.35f
private const val MAX_STRETCH_FRACTION = 0.30f
@Composable
fun Modifier.stretchOverscroll(): Modifier {
fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val overscroll = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
@@ -58,26 +64,33 @@ fun Modifier.stretchOverscroll(): Modifier {
consumed: Velocity,
available: Velocity,
): Velocity {
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMedium))
return available
}
}
}
return this
.nestedScroll(connection)
.graphicsLayer {
val raw = overscroll.value
if (raw == 0f) {
scaleY = 1f
translationY = 0f
} else {
val limit = size.height * MAX_STRETCH_FRACTION
val capped = raw.coerceIn(-limit, limit)
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)
}
CompositionLocalProvider(LocalOverscrollFactory provides null) {
Box(
modifier =
modifier
.nestedScroll(connection)
.graphicsLayer {
val raw = overscroll.value
if (raw == 0f) {
scaleY = 1f
translationY = 0f
} else {
val limit = size.height * MAX_STRETCH_FRACTION
val capped = raw.coerceIn(-limit, limit)
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -71,7 +72,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.StretchOverscroll
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
@@ -82,7 +83,7 @@ import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
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
@Composable
@@ -129,6 +130,7 @@ fun AlbumDetailScreen(
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
val statusBarDp = with(density) { statusBarTop.toDp() }
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
@@ -167,36 +169,39 @@ fun AlbumDetailScreen(
.alpha(gradientAlpha),
)
LazyColumn(
state = listState,
contentPadding =
PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() },
),
modifier = Modifier.fillMaxSize().stretchOverscroll(),
) {
item {
AlbumHeader(
album = album,
hasSongs = songs.isNotEmpty(),
coverArt = coverArt,
onPlay = { onSongClick(0) },
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
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,
isPlaying = song.id == playingSongId,
modifier =
Modifier
.onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
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,
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,
isPlaying = song.id == playingSongId,
modifier =
Modifier
.onSizeChanged { rowHeightPx = it.height.toFloat() }
.clickable { onSongClick(index) },
)
}
}
}
@@ -204,7 +209,7 @@ fun AlbumDetailScreen(
Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(with(density) { (barHeightPx + 24.dp.toPx()).toDp() })
.height(statusBarDp + 8.dp)
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent),
@@ -216,6 +221,7 @@ fun AlbumDetailScreen(
title = {
Text(
text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(barFraction),
@@ -250,6 +256,7 @@ private fun AlbumHeader(
onShuffle: () -> Unit,
onDownload: () -> Unit,
onMore: () -> Unit,
topInset: Dp,
titleModifier: Modifier = Modifier,
modifier: Modifier = Modifier,
) {
@@ -257,7 +264,7 @@ private fun AlbumHeader(
modifier =
modifier
.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,
) {
CoverArt(
@@ -268,7 +275,7 @@ private fun AlbumHeader(
Spacer(Modifier.height(20.dp))
Text(
text = album?.name.orEmpty(),
style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -23,7 +23,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.StretchOverscroll
const val ALBUM_COVER_TAG = "album-cover"
@@ -45,27 +45,29 @@ fun AlbumsContent(
}
else -> {
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().stretchOverscroll(),
) {
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),
)
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),
)
}
}
}
}
@@ -50,7 +50,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.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -123,58 +123,60 @@ fun ArtistDetailScreen(
)
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
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().stretchOverscroll(),
) {
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),
)
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),
)
}
}
}
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,
)
}
}
}
}
@@ -24,7 +24,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.StretchOverscroll
@Composable
fun ArtistsContent(
@@ -44,38 +44,40 @@ fun ArtistsContent(
}
else -> {
LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(),
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),
)
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),
)
}
}
}
}
@@ -35,7 +35,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.StretchOverscroll
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -84,38 +84,40 @@ fun PlaylistDetailScreen(
)
},
) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) {
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,
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) },
)
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) },
)
}
}
}
}
@@ -24,7 +24,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.StretchOverscroll
@Composable
fun PlaylistsContent(
@@ -44,38 +44,40 @@ fun PlaylistsContent(
}
else -> {
LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(),
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),
)
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),
)
}
}
}
}
@@ -32,7 +32,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.StretchOverscroll
@Composable
fun SongsContent(
@@ -53,20 +53,22 @@ fun SongsContent(
}
else -> {
LazyColumn(
modifier = modifier.fillMaxSize().stretchOverscroll(),
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) },
)
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) },
)
}
}
}
}
@@ -51,7 +51,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.StretchOverscroll
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -339,55 +339,57 @@ fun QueueScreen(
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize().stretchOverscroll(),
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
},
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),
),
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
showUndo("Removed from queue")
},
)
) { 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")
},
)
}
}
}
}
@@ -32,7 +32,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.ui.components.stretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
@Composable
@@ -106,7 +105,6 @@ fun AddSourceScreen(
Modifier
.padding(padding)
.fillMaxSize()
.stretchOverscroll()
.verticalScroll(rememberScrollState())
.padding(16.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.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source
import com.subtracks.ui.components.stretchOverscroll
import com.subtracks.ui.components.StretchOverscroll
import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -102,57 +102,59 @@ fun SettingsScreen(
)
},
) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize().stretchOverscroll()) {
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}")
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")
}
},
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),
)
}
}
}
}
@@ -27,7 +27,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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(
val scheme: ColorScheme,
@@ -84,7 +84,7 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
scheme = scheme,
gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f),
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)),
)
}