mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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
|
||||
|
||||
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.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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
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 org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -110,7 +47,6 @@ fun AlbumDetailRoute(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumDetailScreen(
|
||||
album: Album?,
|
||||
@@ -125,217 +61,38 @@ fun AlbumDetailScreen(
|
||||
playingSongId: String? = null,
|
||||
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 gradientHeight = maxHeight * GRADIENT_SCREENS
|
||||
val gradientHeightPx = with(density) { gradientHeight.toPx() }
|
||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||
val statusBarTop = WindowInsets.statusBars.getTop(density)
|
||||
val navBarBottom = WindowInsets.navigationBars.getBottom(density)
|
||||
val statusBarDp = with(density) { statusBarTop.toDp() }
|
||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||
|
||||
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(),
|
||||
coverArt = coverArt,
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
onMore = onMore,
|
||||
topInset = statusBarDp,
|
||||
controlsModifier =
|
||||
Modifier.onGloballyPositioned {
|
||||
controlsTopPx = it.positionInParent().y
|
||||
},
|
||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
|
||||
)
|
||||
}
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier =
|
||||
Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height.toFloat() }
|
||||
.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
|
||||
Box(
|
||||
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
|
||||
.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,
|
||||
HeroDetailScaffold(
|
||||
artwork = artwork,
|
||||
title = album?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(album?.coverArt, false),
|
||||
thumbnailRef = coverArt(album?.coverArt, true),
|
||||
name = album?.name.orEmpty(),
|
||||
subtitle =
|
||||
listOfNotNull(
|
||||
album?.albumArtist?.takeIf { it.isNotBlank() },
|
||||
album?.year?.takeIf { it > 0 }?.toString(),
|
||||
).joinToString(" $DOT "),
|
||||
hasSongs = songs.isNotEmpty(),
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
)
|
||||
}
|
||||
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")
|
||||
},
|
||||
content = { rowModifier ->
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier = rowModifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
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.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
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.SongListItem
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
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.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
private const val DOT = "\u00B7"
|
||||
private const val THEME_TRANSITION_MS = 100
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailRoute(
|
||||
playlistId: String,
|
||||
@@ -57,63 +38,44 @@ fun PlaylistDetailRoute(
|
||||
playlist = playlist,
|
||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
playlist: Playlist?,
|
||||
songs: LazyPagingItems<SongListItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit,
|
||||
playingSongId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val fill = rememberViewportFill(listState)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(playlist?.name.orEmpty(), maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
HeroDetailScaffold(
|
||||
artwork = artwork,
|
||||
title = playlist?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(playlist?.coverArt, false),
|
||||
thumbnailRef = coverArt(playlist?.coverArt, true),
|
||||
name = playlist?.name.orEmpty(),
|
||||
subtitle = playlistSubtitle(playlist),
|
||||
hasSongs = songs.itemCount > 0,
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = {},
|
||||
onDownload = {},
|
||||
onMore = {},
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
)
|
||||
},
|
||||
) { 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(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = "${playlist?.songCount ?: 0} songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = { rowModifier ->
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
if (item != null) {
|
||||
@@ -122,11 +84,28 @@ fun PlaylistDetailScreen(
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
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