mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
drop the custom overscroll, keep lists a hair scrollable, restore colours
This commit is contained in:
@@ -1,114 +0,0 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.LocalOverscrollFactory
|
||||
import androidx.compose.foundation.OverscrollEffect
|
||||
import androidx.compose.foundation.OverscrollFactory
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.math.abs
|
||||
|
||||
private const val MAX_STRETCH_FRACTION = 0.14f
|
||||
|
||||
private class StretchOverscrollEffect : OverscrollEffect {
|
||||
val overscroll = mutableFloatStateOf(0f)
|
||||
var limitPx: Float = 1f
|
||||
|
||||
private val emptyNode = object : Modifier.Node() {}
|
||||
|
||||
override val node: DelegatableNode get() = emptyNode
|
||||
|
||||
override val isInProgress: Boolean get() = overscroll.floatValue != 0f
|
||||
|
||||
override fun applyToScroll(
|
||||
delta: Offset,
|
||||
source: NestedScrollSource,
|
||||
performScroll: (Offset) -> Offset,
|
||||
): Offset {
|
||||
if (delta.y == 0f) return performScroll(delta)
|
||||
var remainingY = delta.y
|
||||
if (overscroll.floatValue != 0f) {
|
||||
val current = overscroll.floatValue
|
||||
val next = current + remainingY
|
||||
val clamped = if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f)
|
||||
remainingY -= clamped - current
|
||||
overscroll.floatValue = clamped
|
||||
}
|
||||
val consumed = performScroll(Offset(delta.x, remainingY))
|
||||
val leftover = remainingY - consumed.y
|
||||
if (leftover != 0f) {
|
||||
val current = overscroll.floatValue
|
||||
val next = (current + leftover).coerceIn(-limitPx, limitPx)
|
||||
overscroll.floatValue =
|
||||
when {
|
||||
current > 0f -> next.coerceAtLeast(0f)
|
||||
current < 0f -> next.coerceAtMost(0f)
|
||||
else -> next
|
||||
}
|
||||
}
|
||||
return Offset(consumed.x, delta.y)
|
||||
}
|
||||
|
||||
override suspend fun applyToFling(
|
||||
velocity: Velocity,
|
||||
performFling: suspend (Velocity) -> Velocity,
|
||||
) {
|
||||
if (overscroll.floatValue != 0f) {
|
||||
Animatable(overscroll.floatValue)
|
||||
.animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) {
|
||||
overscroll.floatValue = value
|
||||
}
|
||||
}
|
||||
performFling(velocity)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StretchOverscroll(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val effect = remember { StretchOverscrollEffect() }
|
||||
val factory =
|
||||
remember {
|
||||
object : OverscrollFactory {
|
||||
override fun createOverscrollEffect(): OverscrollEffect = effect
|
||||
|
||||
override fun hashCode(): Int = 0x5EED_1234
|
||||
|
||||
override fun equals(other: Any?): Boolean = other === this
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalOverscrollFactory provides factory) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.onSizeChanged { effect.limitPx = (it.height * MAX_STRETCH_FRACTION).coerceAtLeast(1f) }
|
||||
.graphicsLayer {
|
||||
val value = effect.overscroll.floatValue
|
||||
if (value == 0f) return@graphicsLayer
|
||||
val height = size.height
|
||||
val resisted = value / (1f + abs(value) / (height * 0.35f))
|
||||
scaleY = 1f + abs(resisted) / height
|
||||
translationY = resisted
|
||||
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
private const val FILL_EPSILON_PX = 1
|
||||
|
||||
@Composable
|
||||
fun rememberViewportFill(state: LazyListState): Dp {
|
||||
val density = LocalDensity.current
|
||||
val fillPx by remember {
|
||||
derivedStateOf {
|
||||
val info = state.layoutInfo
|
||||
val realCount = info.totalItemsCount - 1
|
||||
if (realCount <= 0) {
|
||||
FILL_EPSILON_PX
|
||||
} else {
|
||||
val visibleReal = info.visibleItemsInfo.filter { it.index < realCount }
|
||||
if (visibleReal.size < realCount) {
|
||||
FILL_EPSILON_PX
|
||||
} else {
|
||||
(info.viewportSize.height - visibleReal.sumOf { it.size }).coerceAtLeast(0) + FILL_EPSILON_PX
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return with(density) { fillPx.toDp() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberViewportFill(state: LazyGridState): Dp {
|
||||
val density = LocalDensity.current
|
||||
val fillPx by remember {
|
||||
derivedStateOf {
|
||||
val info = state.layoutInfo
|
||||
val realCount = info.totalItemsCount - 1
|
||||
if (realCount <= 0) {
|
||||
FILL_EPSILON_PX
|
||||
} else {
|
||||
val visibleReal = info.visibleItemsInfo.filter { it.index < realCount }
|
||||
if (visibleReal.size < realCount) {
|
||||
FILL_EPSILON_PX
|
||||
} else {
|
||||
(info.viewportSize.height - visibleReal.sumOf { it.size.height }).coerceAtLeast(0) + FILL_EPSILON_PX
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return with(density) { fillPx.toDp() }
|
||||
}
|
||||
@@ -73,7 +73,7 @@ import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.HeroGradient
|
||||
@@ -128,6 +128,7 @@ fun AlbumDetailScreen(
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
||||
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
||||
@@ -170,43 +171,42 @@ fun AlbumDetailScreen(
|
||||
.alpha(gradientAlpha),
|
||||
)
|
||||
|
||||
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
AlbumHeader(
|
||||
album = album,
|
||||
hasSongs = songs.isNotEmpty(),
|
||||
coverArt = coverArt,
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
onMore = onMore,
|
||||
topInset = statusBarDp,
|
||||
controlsModifier =
|
||||
Modifier.onGloballyPositioned {
|
||||
controlsTopPx = it.positionInParent().y
|
||||
},
|
||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
||||
)
|
||||
}
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier =
|
||||
Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height.toFloat() }
|
||||
.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
AlbumHeader(
|
||||
album = album,
|
||||
hasSongs = songs.isNotEmpty(),
|
||||
coverArt = coverArt,
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
onMore = onMore,
|
||||
topInset = statusBarDp,
|
||||
controlsModifier =
|
||||
Modifier.onGloballyPositioned {
|
||||
controlsTopPx = it.positionInParent().y
|
||||
},
|
||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
||||
)
|
||||
}
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier =
|
||||
Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height.toFloat() }
|
||||
.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -252,6 +252,7 @@ fun AlbumDetailScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.background((artwork?.gradientHigh ?: Color.Black).copy(alpha = barFraction))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.subtracks.ui.library
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -23,7 +27,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
const val ALBUM_COVER_TAG = "album-cover"
|
||||
|
||||
@@ -45,31 +49,35 @@ fun AlbumsContent(
|
||||
}
|
||||
|
||||
else -> {
|
||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val album = items[index]
|
||||
if (album != null) {
|
||||
CoverArt(
|
||||
ref = coverArt(album.coverArt, true),
|
||||
name = album.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.clickable { onAlbumClick(album) }
|
||||
.testTag(ALBUM_COVER_TAG),
|
||||
)
|
||||
}
|
||||
val gridState = rememberLazyGridState()
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val album = items[index]
|
||||
if (album != null) {
|
||||
CoverArt(
|
||||
ref = coverArt(album.coverArt, true),
|
||||
name = album.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.clickable { onAlbumClick(album) }
|
||||
.testTag(ALBUM_COVER_TAG),
|
||||
)
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(fill))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -50,7 +52,7 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@@ -90,6 +92,7 @@ fun ArtistDetailScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyGridState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val lineHeight =
|
||||
with(density) {
|
||||
@@ -123,62 +126,63 @@ fun ArtistDetailScreen(
|
||||
)
|
||||
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
state = listState,
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = artist?.name.orEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
Text(
|
||||
text = artist?.name.orEmpty(),
|
||||
style = imageNameStyle,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
|
||||
)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
state = listState,
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = artist?.name.orEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
Text(
|
||||
text = artist?.name.orEmpty(),
|
||||
style = imageNameStyle,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
|
||||
)
|
||||
}
|
||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||
).clickable { onAlbumClick(album) },
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(album.coverArt, false),
|
||||
name = album.name,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Text(
|
||||
text = album.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||
).clickable { onAlbumClick(album) },
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(album.coverArt, false),
|
||||
name = album.name,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Text(
|
||||
text = album.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(fill))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
||||
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -24,7 +27,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
fun ArtistsContent(
|
||||
@@ -44,42 +47,44 @@ fun ArtistsContent(
|
||||
}
|
||||
|
||||
else -> {
|
||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = artist.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "${artist.albumCount} albums",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = artist.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "${artist.albumCount} albums",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
@@ -35,7 +38,7 @@ import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -71,6 +74,8 @@ fun PlaylistDetailScreen(
|
||||
playingSongId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -84,42 +89,44 @@ fun PlaylistDetailScreen(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist?.coverArt, false),
|
||||
name = playlist?.name.orEmpty(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = "${playlist?.songCount ?: 0} songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
if (item != null) {
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist?.coverArt, false),
|
||||
name = playlist?.name.orEmpty(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = "${playlist?.songCount ?: 0} songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
if (item != null) {
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -24,7 +27,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
fun PlaylistsContent(
|
||||
@@ -44,42 +47,44 @@ fun PlaylistsContent(
|
||||
}
|
||||
|
||||
else -> {
|
||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val playlist = items[index]
|
||||
if (playlist != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = playlist.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist.coverArt, true),
|
||||
name = playlist.name,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onPlaylistClick(playlist) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val playlist = items[index]
|
||||
if (playlist != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = playlist.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(playlist.coverArt, true),
|
||||
name = playlist.name,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onPlaylistClick(playlist) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
@@ -20,6 +23,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -32,7 +36,7 @@ import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
fun SongsContent(
|
||||
@@ -53,24 +57,26 @@ fun SongsContent(
|
||||
}
|
||||
|
||||
else -> {
|
||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
||||
val item = items[index]
|
||||
if (item != null) {
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
||||
val item = items[index]
|
||||
if (item != null) {
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +122,7 @@ fun SongRow(
|
||||
Text(
|
||||
text = song.title,
|
||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
|
||||
@@ -6,7 +6,9 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -51,7 +53,7 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -284,6 +286,7 @@ fun QueueScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||
@@ -339,59 +342,58 @@ fun QueueScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(count = rows.size, key = { rows[it].id }) { index ->
|
||||
val row = rows[index]
|
||||
ReorderableItem(
|
||||
state = reorderState,
|
||||
key = row.id,
|
||||
animateItemModifier =
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = tween(150),
|
||||
fadeOutSpec = tween(150),
|
||||
placementSpec = tween(200),
|
||||
),
|
||||
) { isDragging ->
|
||||
QueueRowItem(
|
||||
row = row,
|
||||
isPlaying = row.position == currentPosition,
|
||||
floating = isDragging,
|
||||
coverArt = coverArt,
|
||||
dragHandle =
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
dragId = row.id
|
||||
dragFrom = row.position
|
||||
},
|
||||
onDragStopped = {
|
||||
val id = dragId
|
||||
val from = dragFrom
|
||||
if (id != null && from != null) {
|
||||
val index = rows.indexOfFirst { it.id == id }
|
||||
val to = if (index < 0) from else dropTarget(rows, index, from)
|
||||
if (to != from) {
|
||||
onMove(from, to)
|
||||
showUndo("Queue reordered")
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(count = rows.size, key = { rows[it].id }) { index ->
|
||||
val row = rows[index]
|
||||
ReorderableItem(
|
||||
state = reorderState,
|
||||
key = row.id,
|
||||
animateItemModifier =
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = tween(150),
|
||||
fadeOutSpec = tween(150),
|
||||
placementSpec = tween(200),
|
||||
),
|
||||
) { isDragging ->
|
||||
QueueRowItem(
|
||||
row = row,
|
||||
isPlaying = row.position == currentPosition,
|
||||
floating = isDragging,
|
||||
coverArt = coverArt,
|
||||
dragHandle =
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
dragId = row.id
|
||||
dragFrom = row.position
|
||||
},
|
||||
onDragStopped = {
|
||||
val id = dragId
|
||||
val from = dragFrom
|
||||
if (id != null && from != null) {
|
||||
val index = rows.indexOfFirst { it.id == id }
|
||||
val to = if (index < 0) from else dropTarget(rows, index, from)
|
||||
if (to != from) {
|
||||
onMove(from, to)
|
||||
showUndo("Queue reordered")
|
||||
}
|
||||
dragId = null
|
||||
dragFrom = null
|
||||
},
|
||||
),
|
||||
onClick = { onPlay(row.position) },
|
||||
onRemove = {
|
||||
onRemove(row.position)
|
||||
showUndo("Removed from queue")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
dragId = null
|
||||
dragFrom = null
|
||||
},
|
||||
),
|
||||
onClick = { onPlay(row.position) },
|
||||
onRemove = {
|
||||
onRemove(row.position)
|
||||
showUndo("Removed from queue")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
@@ -40,7 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.ui.components.StretchOverscroll
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||
@@ -88,6 +90,8 @@ fun SettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
@@ -102,60 +106,62 @@ fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item { SectionHeader("Servers") }
|
||||
items(sources.size, key = { sources[it].id }) { index ->
|
||||
val source = sources[index]
|
||||
ListItem(
|
||||
headlineContent = { Text(source.name) },
|
||||
supportingContent = {
|
||||
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = source.id == activeSourceId,
|
||||
onClick = { onSelectSource(source.id) },
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { onDeleteSource(source.id) }) {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = onAddServer) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add server")
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
) {
|
||||
item { SectionHeader("Servers") }
|
||||
items(sources.size, key = { sources[it].id }) { index ->
|
||||
val source = sources[index]
|
||||
ListItem(
|
||||
headlineContent = { Text(source.name) },
|
||||
supportingContent = {
|
||||
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = source.id == activeSourceId,
|
||||
onClick = { onSelectSource(source.id) },
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { onDeleteSource(source.id) }) {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = onAddServer) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add server")
|
||||
}
|
||||
}
|
||||
item { SectionHeader("Network") }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Maximum bitrate") },
|
||||
supportingContent = { Text(bitrateLabel(maxBitrate)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Preferred stream format") },
|
||||
supportingContent = { Text(streamFormat ?: "Use server default") },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.Format },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { SectionHeader("Network") }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Maximum bitrate") },
|
||||
supportingContent = { Text(bitrateLabel(maxBitrate)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Preferred stream format") },
|
||||
supportingContent = { Text(streamFormat ?: "Use server default") },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.Format },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.compose.LocalPlatformContext
|
||||
@@ -37,16 +36,22 @@ data class ArtworkColors(
|
||||
val accents: List<Color>,
|
||||
)
|
||||
|
||||
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
||||
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
|
||||
fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null)
|
||||
|
||||
fun artworkColorsFromSeeds(
|
||||
primarySeed: Int,
|
||||
secondarySeed: Int?,
|
||||
): ArtworkColors {
|
||||
val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl()
|
||||
val s = saturation.coerceAtMost(0.85f)
|
||||
val secondary = secondarySeed?.let { Color(it or 0xFF000000.toInt()) }
|
||||
|
||||
fun tone(
|
||||
h: Float,
|
||||
sat: Float,
|
||||
l: Float,
|
||||
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
|
||||
val primary = toneMaxLuminance(hue, s, 0.62f, 0.45f)
|
||||
val primary = tone(hue, s, 0.62f)
|
||||
val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f)
|
||||
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
|
||||
|
||||
@@ -84,36 +89,20 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
||||
scheme = scheme,
|
||||
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
|
||||
gradientLow = tone(hue, s * 0.30f, 0.03f),
|
||||
accents = listOf(tone(hue, s * 0.75f, 0.95f), tone(hue, s, 0.08f)),
|
||||
accents = listOf(tone(hue, s * 0.8f, 0.90f), secondary ?: tone(hue, s, 0.10f)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun toneMaxLuminance(
|
||||
h: Float,
|
||||
s: Float,
|
||||
lightness: Float,
|
||||
maxLuminance: Float,
|
||||
): Color {
|
||||
var l = lightness
|
||||
var color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
|
||||
var guard = 0
|
||||
while (color.luminance() > maxLuminance && guard++ < 30) {
|
||||
l = (l - 0.02f).coerceAtLeast(0.05f)
|
||||
color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l)
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
|
||||
val seed by rememberArtworkSeed(ref)
|
||||
return remember(seed) { seed?.let(::artworkColorsFromSeed) }
|
||||
val seeds by rememberArtworkSeed(ref)
|
||||
return remember(seeds) { seeds?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
||||
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
|
||||
val context = LocalPlatformContext.current
|
||||
return produceState<Int?>(initialValue = null, ref?.cacheKey) {
|
||||
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
|
||||
value = null
|
||||
val art = ref ?: return@produceState
|
||||
value =
|
||||
@@ -129,7 +118,7 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
|
||||
image?.let { seedFrom(it.toBitmap()) }
|
||||
image?.let { seedsFrom(it.toBitmap()) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
@@ -139,15 +128,21 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedFrom(bitmap: Bitmap): Int? {
|
||||
private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
|
||||
val palette = Palette.from(bitmap).generate()
|
||||
return (
|
||||
val primary =
|
||||
palette.vibrantSwatch
|
||||
?: palette.lightVibrantSwatch
|
||||
?: palette.darkVibrantSwatch
|
||||
?: palette.mutedSwatch
|
||||
?: palette.dominantSwatch
|
||||
)?.rgb
|
||||
?: return null
|
||||
val secondary =
|
||||
palette.darkVibrantSwatch
|
||||
?: palette.mutedSwatch
|
||||
?: palette.darkMutedSwatch
|
||||
?: palette.dominantSwatch
|
||||
return primary.rgb to secondary?.takeIf { it.rgb != primary.rgb }?.rgb
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user