diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt new file mode 100644 index 00000000..add04abc --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -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") + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 0376a65d..56100790 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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, + ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index a8251dc3..9cdc0eb7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -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, 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" }