theme the artist, now playing and mini player from their art

This commit is contained in:
austinried
2026-10-04 00:29:54 +00:00
parent 2e9e53d732
commit f70d1ba27e
6 changed files with 348 additions and 288 deletions
@@ -45,6 +45,7 @@ import com.subtracks.ui.playback.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.playback.QueueRoute
import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.AddSourceRoute
import com.subtracks.ui.settings.SettingsRoute import com.subtracks.ui.settings.SettingsRoute
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -200,9 +201,11 @@ private fun MainNavigation() {
} }
if (playerVisible) { if (playerVisible) {
val miniArt = playbackController.coverArt(playback.item, thumbnail = true)
MiniPlayer( MiniPlayer(
state = playback, state = playback,
coverArt = playbackController.coverArt(playback.item, thumbnail = true), coverArt = miniArt,
artwork = rememberArtworkColors(miniArt),
onExpand = { showingNowPlaying = true }, onExpand = { showingNowPlaying = true },
onPlayPause = playbackController::togglePlayPause, onPlayPause = playbackController::togglePlayPause,
onNext = playbackController::next, onNext = playbackController::next,
@@ -27,15 +27,18 @@ fun CoverArt(
name: String, name: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
thumbnailRef: CoverArtRef? = null, thumbnailRef: CoverArtRef? = null,
showPlaceholder: Boolean = true,
) { ) {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) { Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) {
if (showPlaceholder) {
Text( Text(
text = name.trim().take(1).uppercase(), text = name.trim().take(1).uppercase(),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
}
if (thumbnailRef != null && thumbnailRef != ref) { if (thumbnailRef != null && thumbnailRef != ref) {
AsyncImage( AsyncImage(
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) }, model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
@@ -27,11 +27,13 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -53,13 +55,18 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.rememberViewportFill 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.PrefetchArtworkSeeds
import com.subtracks.ui.theme.rememberArtworkColors
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
private val ART_HEIGHT = 420.dp private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp private val TITLE_INSET = 16.dp
private val FADE_LEAD = 24.dp private val FADE_LEAD = 24.dp
private const val THEME_TRANSITION_MS = 100
@Composable @Composable
fun ArtistDetailRoute( fun ArtistDetailRoute(
@@ -77,6 +84,7 @@ fun ArtistDetailRoute(
albums = albums, albums = albums,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
artwork = rememberArtworkColors(artThumbnail ?: art, THEME_TRANSITION_MS),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -90,6 +98,7 @@ fun ArtistDetailScreen(
albums: List<Album>, albums: List<Album>,
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
@@ -129,7 +138,14 @@ fun ArtistDetailScreen(
), ),
) )
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(Color.Black)) {
HeroGradient(
colors = artwork,
scrollPx = { 0f },
modifier = Modifier.fillMaxSize(),
)
LazyVerticalGrid( LazyVerticalGrid(
state = listState, state = listState,
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
@@ -144,6 +160,7 @@ fun ArtistDetailScreen(
ref = art, ref = art,
name = artist?.name.orEmpty(), name = artist?.name.orEmpty(),
thumbnailRef = artThumbnail, thumbnailRef = artThumbnail,
showPlaceholder = false,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
) )
Text( Text(
@@ -233,4 +250,6 @@ fun ArtistDetailScreen(
) {}, ) {},
) )
} }
}
}
} }
@@ -30,17 +30,21 @@ import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
state: PlaybackState, state: PlaybackState,
coverArt: CoverArtRef?, coverArt: CoverArtRef?,
artwork: ArtworkColors?,
onExpand: () -> Unit, onExpand: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val item = state.item ?: return val item = state.item ?: return
ArtworkTheme(artwork) {
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
@@ -109,4 +113,5 @@ fun MiniPlayer(
} }
} }
} }
}
} }
@@ -25,12 +25,15 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -39,12 +42,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt 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 import org.koin.compose.koinInject
@Composable @Composable
@@ -55,9 +63,11 @@ fun NowPlayingRoute(
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
) { ) {
val state by controller.state.collectAsStateWithLifecycle() val state by controller.state.collectAsStateWithLifecycle()
val art = controller.coverArt(state.item)
NowPlayingScreen( NowPlayingScreen(
state = state, state = state,
coverArt = controller.coverArt(state.item), coverArt = art,
artwork = rememberArtworkColors(art),
onBack = onBack, onBack = onBack,
onQueue = onQueue, onQueue = onQueue,
onPlayPause = controller::togglePlayPause, onPlayPause = controller::togglePlayPause,
@@ -73,6 +83,7 @@ fun NowPlayingRoute(
fun NowPlayingScreen( fun NowPlayingScreen(
state: PlaybackState, state: PlaybackState,
coverArt: CoverArtRef?, coverArt: CoverArtRef?,
artwork: ArtworkColors?,
onBack: () -> Unit, onBack: () -> Unit,
onQueue: () -> Unit, onQueue: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
@@ -84,8 +95,17 @@ fun NowPlayingScreen(
val playButtonSize = 96.dp val playButtonSize = 96.dp
val playCircleDiameter = playButtonSize * 20f / 24f val playCircleDiameter = playButtonSize * 20f / 24f
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(Color.Black)) {
HeroGradient(
colors = artwork,
scrollPx = { 0f },
modifier = Modifier.fillMaxSize(),
)
Scaffold( Scaffold(
modifier = modifier, modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Now playing") }, title = { Text("Now playing") },
@@ -94,6 +114,7 @@ fun NowPlayingScreen(
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
) )
}, },
) { padding -> ) { padding ->
@@ -220,6 +241,9 @@ fun NowPlayingScreen(
} }
} }
} }
}
}
}
} }
private fun formatTime(milliseconds: Long): String { private fun formatTime(milliseconds: Long): String {
@@ -162,6 +162,7 @@ class AppScreenshotTest {
artist = Fixtures.artists.first(), artist = Fixtures.artists.first(),
albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id }, albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"), art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {}, onBack = {},
onAlbumClick = {}, onAlbumClick = {},
@@ -181,6 +182,7 @@ class AppScreenshotTest {
playlist = Fixtures.playlists.first(), playlist = Fixtures.playlists.first(),
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(), songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artwork = artworkColorsFromSeed(Color.rgb(60, 150, 90)),
onBack = {}, onBack = {},
onSongClick = {}, onSongClick = {},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -247,6 +249,7 @@ class AppScreenshotTest {
NowPlayingScreen( NowPlayingScreen(
state = Fixtures.playbackState(), state = Fixtures.playbackState(),
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onBack = {}, onBack = {},
onQueue = {}, onQueue = {},
onPlayPause = {}, onPlayPause = {},
@@ -296,6 +299,7 @@ class AppScreenshotTest {
MiniPlayer( MiniPlayer(
state = Fixtures.playbackState(), state = Fixtures.playbackState(),
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onExpand = {}, onExpand = {},
onPlayPause = {}, onPlayPause = {},
onNext = {}, onNext = {},
@@ -388,7 +392,7 @@ private object Fixtures {
val playlists = val playlists =
listOf( 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-road-trip", "Road Trip", 63),
playlist("pl-rainy-day", "Rainy Day", 25), playlist("pl-rainy-day", "Rainy Day", 25),
playlist("pl-focus", "Focus", 18), playlist("pl-focus", "Focus", 18),
@@ -477,14 +481,16 @@ private object Fixtures {
id: String, id: String,
name: String, name: String,
songCount: Long, songCount: Long,
comment: String? = null,
) = Playlist( ) = Playlist(
sourceId = 1, sourceId = 1,
id = id, id = id,
name = name, name = name,
comment = null, comment = comment,
coverArt = "art-$id", coverArt = "art-$id",
songCount = songCount, songCount = songCount,
created = 0, created = 0,
duration = songCount * 210,
) )
fun artEngine(): FakeImageLoaderEngine { fun artEngine(): FakeImageLoaderEngine {