From f70d1ba27eb80e32b92178f0abab811de22497e3 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:20:54 +0000 Subject: [PATCH] theme the artist, now playing and mini player from their art --- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 5 +- .../com/subtracks/ui/components/Components.kt | 15 +- .../ui/library/ArtistDetailScreen.kt | 215 +++++++------- .../com/subtracks/ui/playback/MiniPlayer.kt | 117 ++++---- .../subtracks/ui/playback/NowPlayingScreen.kt | 274 ++++++++++-------- .../com/subtracks/ui/AppScreenshotTest.kt | 10 +- 6 files changed, 348 insertions(+), 288 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 794e85e7..95c6df43 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -45,6 +45,7 @@ import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.SettingsRoute +import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -200,9 +201,11 @@ private fun MainNavigation() { } if (playerVisible) { + val miniArt = playbackController.coverArt(playback.item, thumbnail = true) MiniPlayer( state = playback, - coverArt = playbackController.coverArt(playback.item, thumbnail = true), + coverArt = miniArt, + artwork = rememberArtworkColors(miniArt), onExpand = { showingNowPlaying = true }, onPlayPause = playbackController::togglePlayPause, onNext = playbackController::next, diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 1af9c881..481dee79 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -27,15 +27,18 @@ fun CoverArt( name: String, modifier: Modifier = Modifier, thumbnailRef: CoverArtRef? = null, + showPlaceholder: Boolean = true, ) { val context = LocalPlatformContext.current Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) { - Text( - text = name.trim().take(1).uppercase(), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.Center), - ) + if (showPlaceholder) { + Text( + text = name.trim().take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center), + ) + } if (thumbnailRef != null && thumbnailRef != ref) { AsyncImage( model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) }, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index b878c3be..cf91d86f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -27,11 +27,13 @@ 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.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.remember @@ -53,13 +55,18 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.rememberViewportFill +import com.subtracks.ui.theme.ArtworkColors +import com.subtracks.ui.theme.ArtworkTheme +import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.PrefetchArtworkSeeds +import com.subtracks.ui.theme.rememberArtworkColors import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf private val ART_HEIGHT = 420.dp private val TITLE_INSET = 16.dp private val FADE_LEAD = 24.dp +private const val THEME_TRANSITION_MS = 100 @Composable fun ArtistDetailRoute( @@ -77,6 +84,7 @@ fun ArtistDetailRoute( albums = albums, art = art, artThumbnail = artThumbnail, + artwork = rememberArtworkColors(artThumbnail ?: art, THEME_TRANSITION_MS), coverArt = viewModel::coverArt, onBack = onBack, onAlbumClick = onAlbumClick, @@ -90,6 +98,7 @@ fun ArtistDetailScreen( albums: List, art: CoverArtRef?, artThumbnail: CoverArtRef? = null, + artwork: ArtworkColors? = null, coverArt: (String?, Boolean) -> CoverArtRef?, onBack: () -> Unit, onAlbumClick: (Album) -> Unit, @@ -129,108 +138,118 @@ fun ArtistDetailScreen( ), ) - Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - LazyVerticalGrid( - state = listState, - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(bottom = 16.dp + navBarBottom), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxSize(), - ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { - CoverArt( - ref = art, - name = artist?.name.orEmpty(), - thumbnailRef = artThumbnail, - modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), - ) - Text( - text = artist?.name.orEmpty(), - style = imageNameStyle, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET), - ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { + ArtworkTheme(artwork) { + Box(modifier.fillMaxSize().background(Color.Black)) { + HeroGradient( + colors = artwork, + scrollPx = { 0f }, + modifier = Modifier.fillMaxSize(), + ) + LazyVerticalGrid( + state = listState, + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(bottom = 16.dp + navBarBottom), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { + CoverArt( + ref = art, + name = artist?.name.orEmpty(), + thumbnailRef = artThumbnail, + showPlaceholder = false, + modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), + ) + Text( + text = artist?.name.orEmpty(), + style = imageNameStyle, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomStart).padding(TITLE_INSET), + ) + } + } + itemsIndexed(albums, key = { _, album -> album.id }) { index, album -> + PrefetchArtworkSeeds(coverArt(album.coverArt, true)) + Column( + modifier = + Modifier + .padding( + start = if (index % 2 == 0) 16.dp else 0.dp, + end = if (index % 2 == 1) 16.dp else 0.dp, + ).clickable { onAlbumClick(album) }, + ) { + CoverArt( + ref = coverArt(album.coverArt, false), + name = album.name, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), + ) + Text( + text = album.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + Text( + text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(fill)) + } } - } - itemsIndexed(albums, key = { _, album -> album.id }) { index, album -> - PrefetchArtworkSeeds(coverArt(album.coverArt, true)) - Column( + + Box( modifier = Modifier - .padding( - start = if (index % 2 == 0) 16.dp else 0.dp, - end = if (index % 2 == 1) 16.dp else 0.dp, - ).clickable { onAlbumClick(album) }, - ) { - CoverArt( - ref = coverArt(album.coverArt, false), - name = album.name, - modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), - ) - Text( - text = album.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 6.dp), - ) - Text( - text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.height(fill)) + .align(Alignment.TopStart) + .fillMaxWidth() + .height(barHeight + 24.dp) + .alpha(1f - barFraction) + .background( + Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent)), + ), + ) + + TopAppBar( + title = { + Text( + text = artist?.name.orEmpty(), + style = nameTextStyle, + color = Color.White.copy(alpha = barFraction), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + modifier = + Modifier + .align(Alignment.TopStart) + .background(Color.Black.copy(alpha = barFraction)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + ) } } - - Box( - modifier = - Modifier - .align(Alignment.TopStart) - .fillMaxWidth() - .height(barHeight + 24.dp) - .alpha(1f - barFraction) - .background( - Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent)), - ), - ) - - TopAppBar( - title = { - Text( - text = artist?.name.orEmpty(), - style = nameTextStyle, - color = Color.White.copy(alpha = barFraction), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - navigationIcon = { - IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", - tint = Color.White, - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - modifier = - Modifier - .align(Alignment.TopStart) - .background(Color.Black.copy(alpha = barFraction)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) {}, - ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt index df50e816..eeed9bdc 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -30,82 +30,87 @@ import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef import com.subtracks.playback.PlaybackState import com.subtracks.ui.components.CoverArt +import com.subtracks.ui.theme.ArtworkColors +import com.subtracks.ui.theme.ArtworkTheme @Composable fun MiniPlayer( state: PlaybackState, coverArt: CoverArtRef?, + artwork: ArtworkColors?, onExpand: () -> Unit, onPlayPause: () -> Unit, onNext: () -> Unit, modifier: Modifier = Modifier, ) { val item = state.item ?: return - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - modifier = - modifier.fillMaxWidth().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onExpand, - ), - ) { - Row( + ArtworkTheme(artwork) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier.fillMaxWidth().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onExpand, + ), ) { - CoverArt( - ref = coverArt, - name = item.title, - modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), - ) - Column(Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - modifier = Modifier.basicMarquee(), + Row( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CoverArt( + ref = coverArt, + name = item.title, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), ) - Text( - text = listOfNotNull(item.artist, item.album).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.basicMarquee(), - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onPlayPause, modifier = Modifier.size(56.dp)) { - if (state.isBuffering && state.isPlaying) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onSurface, - strokeWidth = 3.dp, - modifier = Modifier.size(28.dp), - ) - } else { + Column(Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + Text( + text = listOfNotNull(item.artist, item.album).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onPlayPause, modifier = Modifier.size(56.dp)) { + if (state.isBuffering && state.isPlaying) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSurface, + strokeWidth = 3.dp, + modifier = Modifier.size(28.dp), + ) + } else { + Icon( + imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(42.dp), + ) + } + } + IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(56.dp)) { Icon( - imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, - contentDescription = if (state.isPlaying) "Pause" else "Play", + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next", tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(42.dp), ) } } - IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(56.dp)) { - Icon( - imageVector = Icons.Rounded.SkipNext, - contentDescription = "Next", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(42.dp), - ) - } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 49cf44da..960a736e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -25,12 +25,15 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider 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.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -39,12 +42,17 @@ 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.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.data.model.CoverArtRef import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackState import com.subtracks.ui.components.CoverArt +import com.subtracks.ui.theme.ArtworkColors +import com.subtracks.ui.theme.ArtworkTheme +import com.subtracks.ui.theme.HeroGradient +import com.subtracks.ui.theme.rememberArtworkColors import org.koin.compose.koinInject @Composable @@ -55,9 +63,11 @@ fun NowPlayingRoute( controller: PlaybackController = koinInject(), ) { val state by controller.state.collectAsStateWithLifecycle() + val art = controller.coverArt(state.item) NowPlayingScreen( state = state, - coverArt = controller.coverArt(state.item), + coverArt = art, + artwork = rememberArtworkColors(art), onBack = onBack, onQueue = onQueue, onPlayPause = controller::togglePlayPause, @@ -73,6 +83,7 @@ fun NowPlayingRoute( fun NowPlayingScreen( state: PlaybackState, coverArt: CoverArtRef?, + artwork: ArtworkColors?, onBack: () -> Unit, onQueue: () -> Unit, onPlayPause: () -> Unit, @@ -84,139 +95,152 @@ fun NowPlayingScreen( val playButtonSize = 96.dp val playCircleDiameter = playButtonSize * 20f / 24f - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text("Now playing") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .padding(padding) - .fillMaxSize() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier.weight(1f).fillMaxWidth().padding(top = 8.dp), - contentAlignment = Alignment.Center, - ) { - CoverArt( - ref = coverArt, - name = state.item?.title.orEmpty(), - modifier = - Modifier - .fillMaxHeight() - .aspectRatio(1f) - .clip(RoundedCornerShape(2.dp)), + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { + ArtworkTheme(artwork) { + Box(modifier.fillMaxSize().background(Color.Black)) { + HeroGradient( + colors = artwork, + scrollPx = { 0f }, + modifier = Modifier.fillMaxSize(), ) - } - Text( - text = state.item?.title.orEmpty(), - style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - modifier = Modifier.padding(top = 28.dp).basicMarquee(), - ) - Text( - text = listOfNotNull(state.item?.artist, state.item?.album).joinToString(" • "), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.padding(top = 6.dp).basicMarquee(), - ) - var dragging by remember { mutableStateOf(false) } - var dragPosition by remember { mutableFloatStateOf(0f) } - Slider( - value = if (dragging) dragPosition else state.positionMs.toFloat(), - onValueChange = { - dragging = true - dragPosition = it - }, - onValueChangeFinished = { - onSeek(dragPosition.toLong()) - dragging = false - }, - valueRange = 0f..state.durationMs.toFloat().coerceAtLeast(1f), - enabled = state.durationMs > 0, - modifier = Modifier.padding(top = 28.dp), - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(formatTime(state.positionMs), style = MaterialTheme.typography.bodySmall) - Text(formatTime(state.durationMs), style = MaterialTheme.typography.bodySmall) - } - if (state.error != null) { - Text( - text = state.error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp), - ) - } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onPrevious, enabled = state.hasPrevious, modifier = Modifier.size(64.dp)) { - Icon( - imageVector = Icons.Rounded.SkipPrevious, - contentDescription = "Previous", - modifier = Modifier.size(48.dp), - ) - } - IconButton(onClick = onPlayPause, modifier = Modifier.padding(horizontal = 20.dp).size(playButtonSize)) { - if (state.isBuffering && state.isPlaying) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text("Now playing") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Box( - modifier = - Modifier - .size(playCircleDiameter) - .background(MaterialTheme.colorScheme.onBackground, CircleShape), + modifier = Modifier.weight(1f).fillMaxWidth().padding(top = 8.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.background, - strokeWidth = 4.dp, - modifier = Modifier.size(playCircleDiameter * 0.65f), + CoverArt( + ref = coverArt, + name = state.item?.title.orEmpty(), + modifier = + Modifier + .fillMaxHeight() + .aspectRatio(1f) + .clip(RoundedCornerShape(2.dp)), ) } - } else { - Icon( - imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle, - contentDescription = if (state.isPlaying) "Pause" else "Play", - modifier = Modifier.size(playButtonSize), + Text( + text = state.item?.title.orEmpty(), + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + modifier = Modifier.padding(top = 28.dp).basicMarquee(), ) + Text( + text = listOfNotNull(state.item?.artist, state.item?.album).joinToString(" • "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(top = 6.dp).basicMarquee(), + ) + var dragging by remember { mutableStateOf(false) } + var dragPosition by remember { mutableFloatStateOf(0f) } + Slider( + value = if (dragging) dragPosition else state.positionMs.toFloat(), + onValueChange = { + dragging = true + dragPosition = it + }, + onValueChangeFinished = { + onSeek(dragPosition.toLong()) + dragging = false + }, + valueRange = 0f..state.durationMs.toFloat().coerceAtLeast(1f), + enabled = state.durationMs > 0, + modifier = Modifier.padding(top = 28.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(formatTime(state.positionMs), style = MaterialTheme.typography.bodySmall) + Text(formatTime(state.durationMs), style = MaterialTheme.typography.bodySmall) + } + if (state.error != null) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious, enabled = state.hasPrevious, modifier = Modifier.size(64.dp)) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Previous", + modifier = Modifier.size(48.dp), + ) + } + IconButton(onClick = onPlayPause, modifier = Modifier.padding(horizontal = 20.dp).size(playButtonSize)) { + if (state.isBuffering && state.isPlaying) { + Box( + modifier = + Modifier + .size(playCircleDiameter) + .background(MaterialTheme.colorScheme.onBackground, CircleShape), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.background, + strokeWidth = 4.dp, + modifier = Modifier.size(playCircleDiameter * 0.65f), + ) + } + } else { + Icon( + imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle, + contentDescription = if (state.isPlaying) "Pause" else "Play", + modifier = Modifier.size(playButtonSize), + ) + } + } + IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(64.dp)) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next", + modifier = Modifier.size(48.dp), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + horizontalArrangement = Arrangement.Start, + ) { + IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = "Queue", + modifier = Modifier.size(30.dp), + ) + } + } } } - IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(64.dp)) { - Icon( - imageVector = Icons.Rounded.SkipNext, - contentDescription = "Next", - modifier = Modifier.size(48.dp), - ) - } - } - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), - horizontalArrangement = Arrangement.Start, - ) { - IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.QueueMusic, - contentDescription = "Queue", - modifier = Modifier.size(30.dp), - ) - } } } } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 9643ad8c..4d2b0157 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -162,6 +162,7 @@ class AppScreenshotTest { artist = Fixtures.artists.first(), albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id }, art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"), + artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, onBack = {}, onAlbumClick = {}, @@ -181,6 +182,7 @@ class AppScreenshotTest { playlist = Fixtures.playlists.first(), songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(), coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, + artwork = artworkColorsFromSeed(Color.rgb(60, 150, 90)), onBack = {}, onSongClick = {}, modifier = Modifier.fillMaxSize(), @@ -247,6 +249,7 @@ class AppScreenshotTest { NowPlayingScreen( state = Fixtures.playbackState(), coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), + artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), onBack = {}, onQueue = {}, onPlayPause = {}, @@ -296,6 +299,7 @@ class AppScreenshotTest { MiniPlayer( state = Fixtures.playbackState(), coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), + artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), onExpand = {}, onPlayPause = {}, onNext = {}, @@ -388,7 +392,7 @@ private object Fixtures { val playlists = listOf( - playlist("pl-late-night", "Late Night", 42), + playlist("pl-late-night", "Late Night", 42, "Smooth late-night picks"), playlist("pl-road-trip", "Road Trip", 63), playlist("pl-rainy-day", "Rainy Day", 25), playlist("pl-focus", "Focus", 18), @@ -477,14 +481,16 @@ private object Fixtures { id: String, name: String, songCount: Long, + comment: String? = null, ) = Playlist( sourceId = 1, id = id, name = name, - comment = null, + comment = comment, coverArt = "art-$id", songCount = songCount, created = 0, + duration = songCount * 210, ) fun artEngine(): FakeImageLoaderEngine {