mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
fix overscroll stretch, pad the album hero, push the gradient fade down
This commit is contained in:
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user