share the artwork hero layout and give playlists the album layout

This commit is contained in:
austinried
2026-10-04 00:29:54 +00:00
parent 9ff1cfa1da
commit 2e9e53d732
3 changed files with 359 additions and 346 deletions
@@ -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"
}