mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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.data.model.Song
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
@@ -128,6 +128,7 @@ fun AlbumDetailScreen(
|
|||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
||||||
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
||||||
@@ -170,43 +171,42 @@ fun AlbumDetailScreen(
|
|||||||
.alpha(gradientAlpha),
|
.alpha(gradientAlpha),
|
||||||
)
|
)
|
||||||
|
|
||||||
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(
|
||||||
LazyColumn(
|
state = listState,
|
||||||
state = listState,
|
contentPadding =
|
||||||
contentPadding =
|
PaddingValues(
|
||||||
PaddingValues(
|
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
||||||
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
),
|
||||||
),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
) {
|
||||||
) {
|
item {
|
||||||
item {
|
AlbumHeader(
|
||||||
AlbumHeader(
|
album = album,
|
||||||
album = album,
|
hasSongs = songs.isNotEmpty(),
|
||||||
hasSongs = songs.isNotEmpty(),
|
coverArt = coverArt,
|
||||||
coverArt = coverArt,
|
onPlay = { onSongClick(0) },
|
||||||
onPlay = { onSongClick(0) },
|
onShuffle = onShuffle,
|
||||||
onShuffle = onShuffle,
|
onDownload = onDownload,
|
||||||
onDownload = onDownload,
|
onMore = onMore,
|
||||||
onMore = onMore,
|
topInset = statusBarDp,
|
||||||
topInset = statusBarDp,
|
controlsModifier =
|
||||||
controlsModifier =
|
Modifier.onGloballyPositioned {
|
||||||
Modifier.onGloballyPositioned {
|
controlsTopPx = it.positionInParent().y
|
||||||
controlsTopPx = it.positionInParent().y
|
},
|
||||||
},
|
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
||||||
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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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(
|
Box(
|
||||||
@@ -252,6 +252,7 @@ fun AlbumDetailScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
|
.background((artwork?.gradientHigh ?: Color.Black).copy(alpha = barFraction))
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package com.subtracks.ui.library
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
const val ALBUM_COVER_TAG = "album-cover"
|
const val ALBUM_COVER_TAG = "album-cover"
|
||||||
|
|
||||||
@@ -45,31 +49,35 @@ fun AlbumsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
val gridState = rememberLazyGridState()
|
||||||
LazyVerticalGrid(
|
val fill = rememberViewportFill(gridState)
|
||||||
columns = GridCells.Fixed(3),
|
LazyVerticalGrid(
|
||||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
state = gridState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
columns = GridCells.Fixed(3),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
modifier = modifier.fillMaxSize(),
|
||||||
val album = items[index]
|
) {
|
||||||
if (album != null) {
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
CoverArt(
|
val album = items[index]
|
||||||
ref = coverArt(album.coverArt, true),
|
if (album != null) {
|
||||||
name = album.name,
|
CoverArt(
|
||||||
modifier =
|
ref = coverArt(album.coverArt, true),
|
||||||
Modifier
|
name = album.name,
|
||||||
.fillMaxWidth()
|
modifier =
|
||||||
.aspectRatio(1f)
|
Modifier
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.fillMaxWidth()
|
||||||
.clickable { onAlbumClick(album) }
|
.aspectRatio(1f)
|
||||||
.testTag(ALBUM_COVER_TAG),
|
.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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ fun ArtistDetailScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberLazyGridState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
with(density) {
|
with(density) {
|
||||||
@@ -123,62 +126,63 @@ fun ArtistDetailScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
StretchOverscroll(modifier = Modifier.fillMaxSize()) {
|
LazyVerticalGrid(
|
||||||
LazyVerticalGrid(
|
state = listState,
|
||||||
state = listState,
|
columns = GridCells.Fixed(2),
|
||||||
columns = GridCells.Fixed(2),
|
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
) {
|
||||||
) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||||
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
CoverArt(
|
||||||
CoverArt(
|
ref = art,
|
||||||
ref = art,
|
name = artist?.name.orEmpty(),
|
||||||
name = artist?.name.orEmpty(),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
)
|
||||||
)
|
Text(
|
||||||
Text(
|
text = artist?.name.orEmpty(),
|
||||||
text = artist?.name.orEmpty(),
|
style = imageNameStyle,
|
||||||
style = imageNameStyle,
|
color = Color.White,
|
||||||
color = Color.White,
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis,
|
||||||
overflow = TextOverflow.Ellipsis,
|
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
}
|
||||||
Column(
|
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
||||||
modifier =
|
Column(
|
||||||
Modifier
|
modifier =
|
||||||
.padding(
|
Modifier
|
||||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
.padding(
|
||||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||||
).clickable { onAlbumClick(album) },
|
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||||
) {
|
).clickable { onAlbumClick(album) },
|
||||||
CoverArt(
|
) {
|
||||||
ref = coverArt(album.coverArt, false),
|
CoverArt(
|
||||||
name = album.name,
|
ref = coverArt(album.coverArt, false),
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
name = album.name,
|
||||||
)
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||||
Text(
|
)
|
||||||
text = album.name,
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = album.name,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = 1,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
Text(
|
)
|
||||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Spacer(Modifier.height(fill))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
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.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistsContent(
|
fun ArtistsContent(
|
||||||
@@ -44,42 +47,44 @@ fun ArtistsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
LazyColumn(
|
val fill = rememberViewportFill(listState)
|
||||||
modifier = Modifier.fillMaxSize(),
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(bottom = bottomInset),
|
state = listState,
|
||||||
) {
|
modifier = modifier.fillMaxSize(),
|
||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
val artist = items[index]
|
) {
|
||||||
if (artist != null) {
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
ListItem(
|
val artist = items[index]
|
||||||
headlineContent = {
|
if (artist != null) {
|
||||||
Text(
|
ListItem(
|
||||||
text = artist.name,
|
headlineContent = {
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = artist.name,
|
||||||
)
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis,
|
||||||
supportingContent = {
|
)
|
||||||
Text(
|
},
|
||||||
text = "${artist.albumCount} albums",
|
supportingContent = {
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = "${artist.albumCount} albums",
|
||||||
)
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis,
|
||||||
leadingContent = {
|
)
|
||||||
CoverArt(
|
},
|
||||||
ref = coverArt(artist.coverArt, true),
|
leadingContent = {
|
||||||
name = artist.name,
|
CoverArt(
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
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),
|
},
|
||||||
)
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.data.model.SongListItem
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -71,6 +74,8 @@ fun PlaylistDetailScreen(
|
|||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -84,42 +89,44 @@ fun PlaylistDetailScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
LazyColumn(
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
state = listState,
|
||||||
item {
|
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||||
Row(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
item {
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
CoverArt(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
ref = coverArt(playlist?.coverArt, false),
|
) {
|
||||||
name = playlist?.name.orEmpty(),
|
CoverArt(
|
||||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
ref = coverArt(playlist?.coverArt, false),
|
||||||
)
|
name = playlist?.name.orEmpty(),
|
||||||
Column {
|
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
||||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
)
|
||||||
Text(
|
Column {
|
||||||
text = "${playlist?.songCount ?: 0} songs",
|
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(fill)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package com.subtracks.ui.library
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
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.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistsContent(
|
fun PlaylistsContent(
|
||||||
@@ -44,42 +47,44 @@ fun PlaylistsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
LazyColumn(
|
val fill = rememberViewportFill(listState)
|
||||||
modifier = Modifier.fillMaxSize(),
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(bottom = bottomInset),
|
state = listState,
|
||||||
) {
|
modifier = modifier.fillMaxSize(),
|
||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
val playlist = items[index]
|
) {
|
||||||
if (playlist != null) {
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
ListItem(
|
val playlist = items[index]
|
||||||
headlineContent = {
|
if (playlist != null) {
|
||||||
Text(
|
ListItem(
|
||||||
text = playlist.name,
|
headlineContent = {
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = playlist.name,
|
||||||
)
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis,
|
||||||
supportingContent = {
|
)
|
||||||
Text(
|
},
|
||||||
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
|
supportingContent = {
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs",
|
||||||
)
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis,
|
||||||
leadingContent = {
|
)
|
||||||
CoverArt(
|
},
|
||||||
ref = coverArt(playlist.coverArt, true),
|
leadingContent = {
|
||||||
name = playlist.name,
|
CoverArt(
|
||||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
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),
|
},
|
||||||
)
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SongsContent(
|
fun SongsContent(
|
||||||
@@ -53,24 +57,26 @@ fun SongsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
StretchOverscroll(modifier = modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
LazyColumn(
|
val fill = rememberViewportFill(listState)
|
||||||
modifier = Modifier.fillMaxSize(),
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(bottom = bottomInset),
|
state = listState,
|
||||||
) {
|
modifier = modifier.fillMaxSize(),
|
||||||
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
val item = items[index]
|
) {
|
||||||
if (item != null) {
|
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
||||||
SongRow(
|
val item = items[index]
|
||||||
song = item.song,
|
if (item != null) {
|
||||||
coverArtId = item.coverArt,
|
SongRow(
|
||||||
coverArt = coverArt,
|
song = item.song,
|
||||||
isPlaying = item.song.id == playingSongId,
|
coverArtId = item.coverArt,
|
||||||
modifier = Modifier.clickable { onSongClick(index) },
|
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(
|
||||||
text = song.title,
|
text = song.title,
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||||
|
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
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.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -51,7 +53,7 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -284,6 +286,7 @@ fun QueueScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||||
@@ -339,59 +342,58 @@ fun QueueScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
LazyColumn(
|
||||||
LazyColumn(
|
state = listState,
|
||||||
state = listState,
|
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
) {
|
||||||
) {
|
items(count = rows.size, key = { rows[it].id }) { index ->
|
||||||
items(count = rows.size, key = { rows[it].id }) { index ->
|
val row = rows[index]
|
||||||
val row = rows[index]
|
ReorderableItem(
|
||||||
ReorderableItem(
|
state = reorderState,
|
||||||
state = reorderState,
|
key = row.id,
|
||||||
key = row.id,
|
animateItemModifier =
|
||||||
animateItemModifier =
|
Modifier.animateItem(
|
||||||
Modifier.animateItem(
|
fadeInSpec = tween(150),
|
||||||
fadeInSpec = tween(150),
|
fadeOutSpec = tween(150),
|
||||||
fadeOutSpec = tween(150),
|
placementSpec = tween(200),
|
||||||
placementSpec = tween(200),
|
),
|
||||||
),
|
) { isDragging ->
|
||||||
) { isDragging ->
|
QueueRowItem(
|
||||||
QueueRowItem(
|
row = row,
|
||||||
row = row,
|
isPlaying = row.position == currentPosition,
|
||||||
isPlaying = row.position == currentPosition,
|
floating = isDragging,
|
||||||
floating = isDragging,
|
coverArt = coverArt,
|
||||||
coverArt = coverArt,
|
dragHandle =
|
||||||
dragHandle =
|
Modifier.draggableHandle(
|
||||||
Modifier.draggableHandle(
|
onDragStarted = {
|
||||||
onDragStarted = {
|
dragId = row.id
|
||||||
dragId = row.id
|
dragFrom = row.position
|
||||||
dragFrom = row.position
|
},
|
||||||
},
|
onDragStopped = {
|
||||||
onDragStopped = {
|
val id = dragId
|
||||||
val id = dragId
|
val from = dragFrom
|
||||||
val from = dragFrom
|
if (id != null && from != null) {
|
||||||
if (id != null && from != null) {
|
val index = rows.indexOfFirst { it.id == id }
|
||||||
val index = rows.indexOfFirst { it.id == id }
|
val to = if (index < 0) from else dropTarget(rows, index, from)
|
||||||
val to = if (index < 0) from else dropTarget(rows, index, from)
|
if (to != from) {
|
||||||
if (to != from) {
|
onMove(from, to)
|
||||||
onMove(from, to)
|
showUndo("Queue reordered")
|
||||||
showUndo("Queue reordered")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dragId = null
|
}
|
||||||
dragFrom = null
|
dragId = null
|
||||||
},
|
dragFrom = null
|
||||||
),
|
},
|
||||||
onClick = { onPlay(row.position) },
|
),
|
||||||
onRemove = {
|
onClick = { onPlay(row.position) },
|
||||||
onRemove(row.position)
|
onRemove = {
|
||||||
showUndo("Removed from queue")
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.material.icons.rounded.Add
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.ui.components.StretchOverscroll
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||||
@@ -88,6 +90,8 @@ fun SettingsScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
|
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -102,60 +106,62 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
StretchOverscroll(modifier = Modifier.padding(padding).fillMaxSize()) {
|
LazyColumn(
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
state = listState,
|
||||||
item { SectionHeader("Servers") }
|
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||||
items(sources.size, key = { sources[it].id }) { index ->
|
) {
|
||||||
val source = sources[index]
|
item { SectionHeader("Servers") }
|
||||||
ListItem(
|
items(sources.size, key = { sources[it].id }) { index ->
|
||||||
headlineContent = { Text(source.name) },
|
val source = sources[index]
|
||||||
supportingContent = {
|
ListItem(
|
||||||
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
headlineContent = { Text(source.name) },
|
||||||
},
|
supportingContent = {
|
||||||
leadingContent = {
|
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
RadioButton(
|
},
|
||||||
selected = source.id == activeSourceId,
|
leadingContent = {
|
||||||
onClick = { onSelectSource(source.id) },
|
RadioButton(
|
||||||
)
|
selected = source.id == activeSourceId,
|
||||||
},
|
onClick = { onSelectSource(source.id) },
|
||||||
trailingContent = {
|
)
|
||||||
IconButton(onClick = { onDeleteSource(source.id) }) {
|
},
|
||||||
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(fill)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.palette.graphics.Palette
|
import androidx.palette.graphics.Palette
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.compose.LocalPlatformContext
|
import coil3.compose.LocalPlatformContext
|
||||||
@@ -37,16 +36,22 @@ data class ArtworkColors(
|
|||||||
val accents: List<Color>,
|
val accents: List<Color>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
fun artworkColorsFromSeed(seed: Int): ArtworkColors = artworkColorsFromSeeds(seed, null)
|
||||||
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
|
|
||||||
|
fun artworkColorsFromSeeds(
|
||||||
|
primarySeed: Int,
|
||||||
|
secondarySeed: Int?,
|
||||||
|
): ArtworkColors {
|
||||||
|
val (hue, saturation, _) = Color(primarySeed or 0xFF000000.toInt()).toHsl()
|
||||||
val s = saturation.coerceAtMost(0.85f)
|
val s = saturation.coerceAtMost(0.85f)
|
||||||
|
val secondary = secondarySeed?.let { Color(it or 0xFF000000.toInt()) }
|
||||||
|
|
||||||
fun tone(
|
fun tone(
|
||||||
h: Float,
|
h: Float,
|
||||||
sat: Float,
|
sat: Float,
|
||||||
l: Float,
|
l: Float,
|
||||||
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
|
) = 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 background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f)
|
||||||
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
|
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
|
||||||
|
|
||||||
@@ -84,36 +89,20 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
|||||||
scheme = scheme,
|
scheme = scheme,
|
||||||
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
|
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
|
||||||
gradientLow = tone(hue, s * 0.30f, 0.03f),
|
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
|
@Composable
|
||||||
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
|
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
|
||||||
val seed by rememberArtworkSeed(ref)
|
val seeds by rememberArtworkSeed(ref)
|
||||||
return remember(seed) { seed?.let(::artworkColorsFromSeed) }
|
return remember(seeds) { seeds?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
return produceState<Int?>(initialValue = null, ref?.cacheKey) {
|
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
|
||||||
value = null
|
value = null
|
||||||
val art = ref ?: return@produceState
|
val art = ref ?: return@produceState
|
||||||
value =
|
value =
|
||||||
@@ -129,7 +118,7 @@ private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
|||||||
.allowHardware(false)
|
.allowHardware(false)
|
||||||
.build()
|
.build()
|
||||||
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
|
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
|
||||||
image?.let { seedFrom(it.toBitmap()) }
|
image?.let { seedsFrom(it.toBitmap()) }
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (_: Exception) {
|
} 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()
|
val palette = Palette.from(bitmap).generate()
|
||||||
return (
|
val primary =
|
||||||
palette.vibrantSwatch
|
palette.vibrantSwatch
|
||||||
?: palette.lightVibrantSwatch
|
?: palette.lightVibrantSwatch
|
||||||
?: palette.darkVibrantSwatch
|
?: palette.darkVibrantSwatch
|
||||||
?: palette.mutedSwatch
|
?: palette.mutedSwatch
|
||||||
?: palette.dominantSwatch
|
?: 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
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user