mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
share the artwork hero layout and give playlists the album layout
This commit is contained in:
@@ -0,0 +1,277 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
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
|
||||||
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.layout.positionInParent
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
|
import com.subtracks.ui.theme.gradientColorAt
|
||||||
|
import com.subtracks.ui.theme.heroDarkenAt
|
||||||
|
|
||||||
|
private const val GRADIENT_SCREENS = 2.0f
|
||||||
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HeroDetailScaffold(
|
||||||
|
artwork: ArtworkColors?,
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
|
ArtworkTheme(artwork) {
|
||||||
|
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val fill = rememberViewportFill(listState)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val gradientHeightPx = with(density) { (maxHeight * GRADIENT_SCREENS).toPx() }
|
||||||
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
|
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
||||||
|
val statusBarDp = with(density) { statusBarTop.toDp() }
|
||||||
|
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
||||||
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
|
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
val scrollPx by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val index = listState.firstVisibleItemIndex
|
||||||
|
val before = if (index <= 0) 0f else headerHeightPx + rowHeightPx * (index - 1)
|
||||||
|
(before + listState.firstVisibleItemScrollOffset).coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val barFraction by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (controlsTopPx <= 0f || fadeDistancePx <= 0f) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
((scrollPx - (controlsTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val barColor by
|
||||||
|
remember(artwork, barHeightPx, gradientHeightPx, screenHeightPx) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (artwork == null || gradientHeightPx <= 0f) {
|
||||||
|
Color.Black
|
||||||
|
} else {
|
||||||
|
val mid = scrollPx + barHeightPx / 2f
|
||||||
|
lerp(
|
||||||
|
artwork.gradientColorAt(mid / gradientHeightPx),
|
||||||
|
artwork.darkPrimary,
|
||||||
|
heroDarkenAt(mid, screenHeightPx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroGradient(
|
||||||
|
colors = artwork,
|
||||||
|
scrollPx = { scrollPx },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding =
|
||||||
|
PaddingValues(
|
||||||
|
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() }) {
|
||||||
|
header(
|
||||||
|
Modifier.onGloballyPositioned { controlsTopPx = it.positionInParent().y },
|
||||||
|
statusBarDp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content(Modifier.onSizeChanged { rowHeightPx = it.height.toFloat() })
|
||||||
|
item { Spacer(Modifier.height(fill)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(statusBarDp + 8.dp)
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
|
||||||
|
),
|
||||||
|
).graphicsLayer { alpha = 1f - barFraction },
|
||||||
|
)
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.graphicsLayer { alpha = barFraction },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.drawBehind { drawRect(barColor.copy(alpha = barFraction)) }
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HeroHeader(
|
||||||
|
art: CoverArtRef?,
|
||||||
|
name: String,
|
||||||
|
subtitle: String,
|
||||||
|
hasSongs: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onShuffle: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
topInset: Dp,
|
||||||
|
controlsModifier: Modifier = Modifier,
|
||||||
|
thumbnailRef: CoverArtRef? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp, end = 24.dp, top = topInset + 24.dp, bottom = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = art,
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = thumbnailRef,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(0.86f)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.shadow(elevation = 3.dp, shape = RoundedCornerShape(2.dp), clip = false)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Row(
|
||||||
|
modifier = controlsModifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Rounded.Download, contentDescription = "Download")
|
||||||
|
}
|
||||||
|
Button(onClick = onPlay, enabled = hasSongs) {
|
||||||
|
Icon(Icons.Rounded.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Play")
|
||||||
|
}
|
||||||
|
FilledIconButton(onClick = onShuffle, enabled = hasSongs) {
|
||||||
|
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMore) {
|
||||||
|
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +1,26 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
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
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
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
|
|
||||||
import androidx.compose.material.icons.rounded.Download
|
|
||||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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.drawBehind
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.layout.positionInParent
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueKind
|
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.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
|
||||||
import com.subtracks.ui.theme.HeroGradient
|
|
||||||
import com.subtracks.ui.theme.gradientColorAt
|
|
||||||
import com.subtracks.ui.theme.heroDarkenAt
|
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
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
|
||||||
|
|
||||||
private const val DOT = "\u00B7"
|
private const val DOT = "\u00B7"
|
||||||
private const val GRADIENT_SCREENS = 2.0f
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
|
||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -110,7 +47,6 @@ fun AlbumDetailRoute(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumDetailScreen(
|
fun AlbumDetailScreen(
|
||||||
album: Album?,
|
album: Album?,
|
||||||
@@ -125,217 +61,38 @@ fun AlbumDetailScreen(
|
|||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
HeroDetailScaffold(
|
||||||
ArtworkTheme(artwork) {
|
artwork = artwork,
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
title = album?.name.orEmpty(),
|
||||||
val listState = rememberLazyListState()
|
onBack = onBack,
|
||||||
val fill = rememberViewportFill(listState)
|
header = { controlsModifier, topInset ->
|
||||||
val density = LocalDensity.current
|
HeroHeader(
|
||||||
val gradientHeight = maxHeight * GRADIENT_SCREENS
|
art = coverArt(album?.coverArt, false),
|
||||||
val gradientHeightPx = with(density) { gradientHeight.toPx() }
|
thumbnailRef = coverArt(album?.coverArt, true),
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
name = album?.name.orEmpty(),
|
||||||
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
subtitle =
|
||||||
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
listOfNotNull(
|
||||||
val statusBarDp = with(density) { statusBarTop.toDp() }
|
album?.albumArtist?.takeIf { it.isNotBlank() },
|
||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
album?.year?.takeIf { it > 0 }?.toString(),
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
).joinToString(" $DOT "),
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
|
||||||
val scrollPx by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
val index = listState.firstVisibleItemIndex
|
|
||||||
val before = if (index <= 0) 0f else headerHeightPx + rowHeightPx * (index - 1)
|
|
||||||
(before + listState.firstVisibleItemScrollOffset).coerceAtLeast(0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val barFraction by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
if (controlsTopPx <= 0f || fadeDistancePx <= 0f) {
|
|
||||||
0f
|
|
||||||
} else {
|
|
||||||
((scrollPx - (controlsTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val barColor by
|
|
||||||
remember(artwork, barHeightPx, gradientHeightPx, screenHeightPx) {
|
|
||||||
derivedStateOf {
|
|
||||||
if (artwork == null || gradientHeightPx <= 0f) {
|
|
||||||
Color.Black
|
|
||||||
} else {
|
|
||||||
val mid = scrollPx + barHeightPx / 2f
|
|
||||||
lerp(
|
|
||||||
artwork.gradientColorAt(mid / gradientHeightPx),
|
|
||||||
artwork.darkPrimary,
|
|
||||||
heroDarkenAt(mid, screenHeightPx),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HeroGradient(
|
|
||||||
colors = artwork,
|
|
||||||
scrollPx = { scrollPx },
|
|
||||||
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(),
|
hasSongs = songs.isNotEmpty(),
|
||||||
coverArt = coverArt,
|
|
||||||
onPlay = { onSongClick(0) },
|
onPlay = { onSongClick(0) },
|
||||||
onShuffle = onShuffle,
|
onShuffle = onShuffle,
|
||||||
onDownload = onDownload,
|
onDownload = onDownload,
|
||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
topInset = statusBarDp,
|
topInset = topInset,
|
||||||
controlsModifier =
|
controlsModifier = controlsModifier,
|
||||||
Modifier.onGloballyPositioned {
|
|
||||||
controlsTopPx = it.positionInParent().y
|
|
||||||
},
|
|
||||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
content = { rowModifier ->
|
||||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||||
SongRow(
|
SongRow(
|
||||||
song = song,
|
song = song,
|
||||||
isPlaying = song.id == playingSongId,
|
isPlaying = song.id == playingSongId,
|
||||||
modifier =
|
modifier = rowModifier.clickable { onSongClick(index) },
|
||||||
Modifier
|
|
||||||
.onSizeChanged { rowHeightPx = it.height.toFloat() }
|
|
||||||
.clickable { onSongClick(index) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(fill)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(statusBarDp + 8.dp)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
|
|
||||||
),
|
|
||||||
).graphicsLayer { alpha = 1f - barFraction },
|
|
||||||
)
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = album?.name.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.graphicsLayer { alpha = barFraction },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
|
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
modifier = modifier,
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.drawBehind { drawRect(barColor.copy(alpha = barFraction)) }
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
) {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AlbumHeader(
|
|
||||||
album: Album?,
|
|
||||||
hasSongs: Boolean,
|
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
|
||||||
onPlay: () -> Unit,
|
|
||||||
onShuffle: () -> Unit,
|
|
||||||
onDownload: () -> Unit,
|
|
||||||
onMore: () -> Unit,
|
|
||||||
topInset: Dp,
|
|
||||||
controlsModifier: Modifier = Modifier,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 24.dp, end = 24.dp, top = topInset + 24.dp, bottom = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
CoverArt(
|
|
||||||
ref = coverArt(album?.coverArt, false),
|
|
||||||
name = album?.name.orEmpty(),
|
|
||||||
thumbnailRef = coverArt(album?.coverArt, true),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth(0.86f)
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.shadow(elevation = 3.dp, shape = RoundedCornerShape(2.dp), clip = false)
|
|
||||||
.clip(RoundedCornerShape(2.dp)),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Text(
|
|
||||||
text = album?.name.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle =
|
|
||||||
listOfNotNull(
|
|
||||||
album?.albumArtist?.takeIf { it.isNotBlank() },
|
|
||||||
album?.year?.takeIf { it > 0 }?.toString(),
|
|
||||||
).joinToString(" $DOT ")
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Row(
|
|
||||||
modifier = controlsModifier,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onDownload) {
|
|
||||||
Icon(Icons.Rounded.Download, contentDescription = "Download")
|
|
||||||
}
|
|
||||||
Button(onClick = onPlay, enabled = hasSongs) {
|
|
||||||
Icon(Icons.Rounded.PlayArrow, contentDescription = null)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Play")
|
|
||||||
}
|
|
||||||
FilledIconButton(onClick = onShuffle, enabled = hasSongs) {
|
|
||||||
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
|
|
||||||
}
|
|
||||||
IconButton(onClick = onMore) {
|
|
||||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,9 @@
|
|||||||
package com.subtracks.ui.library
|
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.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
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
@@ -37,12 +13,17 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.QueueKind
|
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.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.HeroHeader
|
||||||
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
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
|
||||||
|
|
||||||
|
private const val DOT = "\u00B7"
|
||||||
|
private const val THEME_TRANSITION_MS = 100
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetailRoute(
|
fun PlaylistDetailRoute(
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
@@ -57,63 +38,44 @@ fun PlaylistDetailRoute(
|
|||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetailScreen(
|
fun PlaylistDetailScreen(
|
||||||
playlist: Playlist?,
|
playlist: Playlist?,
|
||||||
songs: LazyPagingItems<SongListItem>,
|
songs: LazyPagingItems<SongListItem>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artwork: ArtworkColors?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSongClick: (Int) -> Unit,
|
onSongClick: (Int) -> Unit,
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
HeroDetailScaffold(
|
||||||
val fill = rememberViewportFill(listState)
|
artwork = artwork,
|
||||||
Scaffold(
|
title = playlist?.name.orEmpty(),
|
||||||
modifier = modifier,
|
onBack = onBack,
|
||||||
topBar = {
|
header = { controlsModifier, topInset ->
|
||||||
TopAppBar(
|
HeroHeader(
|
||||||
title = { Text(playlist?.name.orEmpty(), maxLines = 1) },
|
art = coverArt(playlist?.coverArt, false),
|
||||||
navigationIcon = {
|
thumbnailRef = coverArt(playlist?.coverArt, true),
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
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(),
|
name = playlist?.name.orEmpty(),
|
||||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
subtitle = playlistSubtitle(playlist),
|
||||||
|
hasSongs = songs.itemCount > 0,
|
||||||
|
onPlay = { onSongClick(0) },
|
||||||
|
onShuffle = {},
|
||||||
|
onDownload = {},
|
||||||
|
onMore = {},
|
||||||
|
topInset = topInset,
|
||||||
|
controlsModifier = controlsModifier,
|
||||||
)
|
)
|
||||||
Column {
|
},
|
||||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
content = { rowModifier ->
|
||||||
Text(
|
|
||||||
text = "${playlist?.songCount ?: 0} songs",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
val item = songs[index]
|
val item = songs[index]
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
@@ -122,11 +84,28 @@ fun PlaylistDetailScreen(
|
|||||||
coverArtId = item.coverArt,
|
coverArtId = item.coverArt,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
isPlaying = item.song.id == playingSongId,
|
isPlaying = item.song.id == playingSongId,
|
||||||
modifier = Modifier.clickable { onSongClick(index) },
|
modifier = rowModifier.clickable { onSongClick(index) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(fill)) }
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun playlistSubtitle(playlist: Playlist?): String {
|
||||||
|
playlist ?: return ""
|
||||||
|
return listOfNotNull(
|
||||||
|
"${playlist.songCount} ${if (playlist.songCount == 1L) "song" else "songs"}",
|
||||||
|
formatDuration(playlist.duration),
|
||||||
|
playlist.comment?.takeIf { it.isNotBlank() },
|
||||||
|
).joinToString(" $DOT ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(seconds: Long): String? {
|
||||||
|
if (seconds <= 0) return null
|
||||||
|
val totalMinutes = seconds / 60
|
||||||
|
val hours = totalMinutes / 60
|
||||||
|
val minutes = totalMinutes % 60
|
||||||
|
return if (hours > 0) "$hours hr $minutes min" else "$totalMinutes min"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user