From fe943af2b66668834cf68c38f484634332afa089 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:26:15 +0000 Subject: [PATCH] theme album screens from their cover art --- app/build.gradle.kts | 1 + .../subtracks/ui/library/AlbumDetailScreen.kt | 170 +++++++++++---- .../com/subtracks/ui/theme/ArtworkTheme.kt | 201 ++++++++++++++++++ .../com/subtracks/ui/AppScreenshotTest.kt | 2 + .../subtracks/ui/theme/ArtworkColorsTest.kt | 46 ++++ gradle/libs.versions.toml | 2 + 6 files changed, 380 insertions(+), 42 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 895c42ac..d4402368 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,7 @@ dependencies { implementation(libs.androidx.room3.sqlite.wrapper) implementation(libs.androidx.room3.paging) implementation(libs.androidx.paging.compose) + implementation(libs.androidx.palette) implementation(libs.reorderable) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.media3.exoplayer) 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 6c3936b8..a95c8308 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -2,29 +2,44 @@ package com.subtracks.ui.library import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed 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.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.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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.data.model.Album @@ -33,10 +48,17 @@ 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.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.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +private val ART_SIZE = 240.dp +private const val DOT = "\u00B7" + @Composable fun AlbumDetailRoute( albumId: String, @@ -52,6 +74,7 @@ fun AlbumDetailRoute( album = album, songs = songs, coverArt = viewModel::coverArt, + artwork = rememberArtworkColors(viewModel.coverArt(album?.coverArt, false)), onBack = onBack, onSongClick = viewModel::play, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, @@ -64,57 +87,120 @@ fun AlbumDetailScreen( album: Album?, songs: List, coverArt: (String?, Boolean) -> CoverArtRef?, + artwork: ArtworkColors?, onBack: () -> Unit, onSongClick: (Int) -> Unit, + onDownload: () -> Unit = {}, + onShuffle: () -> Unit = {}, + onMore: () -> Unit = {}, playingSongId: String? = null, modifier: Modifier = Modifier, ) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text(album?.name.orEmpty(), maxLines = 1) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - LazyColumn(Modifier.padding(padding).fillMaxSize()) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CoverArt( - ref = coverArt(album?.coverArt, false), - name = album?.name.orEmpty(), - modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)), - ) - Column { - Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) - Text( - text = album?.albumArtist.orEmpty(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = listOfNotNull(album?.year?.takeIf { it > 0 }, "${songs.size} songs").joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + ArtworkTheme(artwork) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { + Box(modifier.fillMaxSize()) { + HeroGradient(artwork, Modifier.fillMaxSize()) + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), ) + }, + ) { padding -> + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + item { + AlbumHeader( + album = album, + hasSongs = songs.isNotEmpty(), + coverArt = coverArt, + onPlay = { onSongClick(0) }, + onShuffle = onShuffle, + onDownload = onDownload, + onMore = onMore, + ) + } + itemsIndexed(songs, key = { _, song -> song.id }) { index, song -> + SongRow( + song = song, + isPlaying = song.id == playingSongId, + modifier = Modifier.clickable { onSongClick(index) }, + ) + } } } } - itemsIndexed(songs, key = { _, song -> song.id }) { index, song -> - SongRow( - song = song, - isPlaying = song.id == playingSongId, - modifier = Modifier.clickable { onSongClick(index) }, - ) + } + } +} + +@Composable +private fun AlbumHeader( + album: Album?, + hasSongs: Boolean, + coverArt: (String?, Boolean) -> CoverArtRef?, + onPlay: () -> Unit, + onShuffle: () -> Unit, + onDownload: () -> Unit, + onMore: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CoverArt( + ref = coverArt(album?.coverArt, false), + name = album?.name.orEmpty(), + modifier = Modifier.size(ART_SIZE).clip(RoundedCornerShape(8.dp)), + ) + Spacer(Modifier.height(20.dp)) + Text( + text = album?.name.orEmpty(), + style = MaterialTheme.typography.headlineSmall, + 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, + ) + } + Spacer(Modifier.height(20.dp)) + Row( + 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") + } + IconButton(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/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt new file mode 100644 index 00000000..05b11b2e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -0,0 +1,201 @@ +package com.subtracks.ui.theme + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.palette.graphics.Palette +import coil3.SingletonImageLoader +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.toBitmap +import com.subtracks.data.model.CoverArtRef +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class ArtworkColors( + val scheme: ColorScheme, + val gradientHigh: Color, + val gradientLow: Color, + val accents: List, +) + +fun artworkColorsFromSeed(seed: Int): ArtworkColors { + val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl() + val chroma = saturation.coerceIn(0.30f, 0.72f) + val secondaryHue = (hue + 60f) % 360f + + fun tone( + h: Float, + s: Float, + l: Float, + ) = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f)) + + val primary = tone(hue, chroma, 0.80f) + val background = tone(hue, (saturation * 0.7f).coerceIn(0.06f, 0.22f), 0.06f) + val onBackground = tone(hue, 0.08f, 0.95f) + + val scheme = + darkColorScheme( + primary = primary, + onPrimary = tone(hue, chroma * 0.6f, 0.14f), + primaryContainer = tone(hue, chroma, 0.32f), + onPrimaryContainer = tone(hue, chroma * 0.5f, 0.92f), + secondary = tone(hue, chroma * 0.5f, 0.78f), + onSecondary = tone(hue, chroma * 0.4f, 0.14f), + secondaryContainer = tone(hue, chroma * 0.45f, 0.30f), + onSecondaryContainer = tone(hue, chroma * 0.4f, 0.90f), + tertiary = tone(secondaryHue, chroma, 0.80f), + onTertiary = tone(secondaryHue, chroma * 0.6f, 0.14f), + tertiaryContainer = tone(secondaryHue, chroma, 0.30f), + onTertiaryContainer = tone(secondaryHue, chroma * 0.5f, 0.90f), + background = background, + onBackground = onBackground, + surface = background, + onSurface = onBackground, + surfaceTint = primary, + surfaceVariant = tone(hue, 0.15f, 0.17f), + onSurfaceVariant = tone(hue, 0.12f, 0.78f), + surfaceContainerLowest = tone(hue, 0.16f, 0.04f), + surfaceContainerLow = tone(hue, 0.16f, 0.08f), + surfaceContainer = tone(hue, 0.16f, 0.10f), + surfaceContainerHigh = tone(hue, 0.15f, 0.14f), + surfaceContainerHighest = tone(hue, 0.14f, 0.18f), + outline = tone(hue, 0.10f, 0.55f), + outlineVariant = tone(hue, 0.12f, 0.28f), + ) + + return ArtworkColors( + scheme = scheme, + gradientHigh = tone(hue, (saturation * 0.9f).coerceIn(0.30f, 0.60f), 0.24f), + gradientLow = tone(hue, 0.12f, 0.04f), + accents = listOf(tone(hue, chroma, 0.60f), tone(secondaryHue, chroma, 0.55f)), + ) +} + +@Composable +fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? { + val seed by rememberArtworkSeed(ref) + return remember(seed) { seed?.let(::artworkColorsFromSeed) } +} + +@Composable +private fun rememberArtworkSeed(ref: CoverArtRef?): State { + val context = LocalPlatformContext.current + return produceState(initialValue = null, ref?.cacheKey) { + value = null + val art = ref ?: return@produceState + value = + withContext(Dispatchers.Default) { + val request = + ImageRequest + .Builder(context) + .data(art.url) + .memoryCacheKey(art.cacheKey) + .diskCacheKey(art.cacheKey) + .build() + val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image + image?.let { seedFrom(it.toBitmap()) } + } + } +} + +private fun seedFrom(bitmap: Bitmap): Int? { + val palette = Palette.from(bitmap).generate() + return ( + palette.vibrantSwatch + ?: palette.lightVibrantSwatch + ?: palette.darkVibrantSwatch + ?: palette.mutedSwatch + ?: palette.dominantSwatch + )?.rgb +} + +@Composable +fun ArtworkTheme( + colors: ArtworkColors?, + content: @Composable () -> Unit, +) { + val scheme = colors?.scheme + if (scheme == null) { + content() + } else { + MaterialTheme(colorScheme = scheme, typography = SubtracksTypography, content = content) + } +} + +@Composable +fun HeroGradient( + colors: ArtworkColors?, + modifier: Modifier = Modifier, +) { + val high = colors?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh + val low = colors?.gradientLow ?: MaterialTheme.colorScheme.background + val accents = colors?.accents.orEmpty() + + Canvas(modifier) { + drawRect( + brush = + Brush.linearGradient( + colors = listOf(high, low), + start = Offset(size.width * 0.2f, 0f), + end = Offset(size.width * 0.8f, size.height), + ), + ) + accents.getOrNull(0)?.let { accent -> + drawRect( + brush = + Brush.radialGradient( + colors = listOf(accent.copy(alpha = 0.35f), accent.copy(alpha = 0f)), + center = Offset(size.width * 0.15f, size.height * 0.08f), + radius = size.width * 0.95f, + ), + ) + } + accents.getOrNull(1)?.let { accent -> + drawRect( + brush = + Brush.radialGradient( + colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)), + center = Offset(size.width * 0.95f, size.height * 0.35f), + radius = size.width * 0.9f, + ), + ) + } + drawRect( + brush = + Brush.radialGradient( + colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)), + center = Offset(size.width * 0.55f, size.height * 0.85f), + radius = size.width * 1.1f, + ), + ) + } +} + +private fun Color.toHsl(): Triple { + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + val lightness = (max + min) / 2f + val delta = max - min + if (delta == 0f) return Triple(0f, 0f, lightness) + val saturation = if (lightness > 0.5f) delta / (2f - max - min) else delta / (max + min) + val hue = + when (max) { + red -> 60f * (((green - blue) / delta) % 6f) + green -> 60f * (((blue - red) / delta) + 2f) + else -> 60f * (((red - green) / delta) + 4f) + } + return Triple((hue + 360f) % 360f, saturation, lightness) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index dc7fa83e..c24ea035 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -50,6 +50,7 @@ import com.subtracks.ui.settings.AddSourceScreen import com.subtracks.ui.settings.AddSourceState import com.subtracks.ui.settings.SettingsScreen import com.subtracks.ui.theme.SubtracksTheme +import com.subtracks.ui.theme.artworkColorsFromSeed import kotlinx.coroutines.flow.flowOf import org.junit.After import org.junit.Before @@ -120,6 +121,7 @@ class AppScreenshotTest { album = Fixtures.albums.first(), songs = Fixtures.songs, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, + artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), onBack = {}, onSongClick = {}, playingSongId = Fixtures.songs.first().id, diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt new file mode 100644 index 00000000..677bef1a --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt @@ -0,0 +1,46 @@ +package com.subtracks.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtworkColorsTest { + private val seeds = + listOf( + 0xFF3A7BD5.toInt(), + 0xFFD53A3A.toInt(), + 0xFF3AD55F.toInt(), + 0xFFB0B0B0.toInt(), + 0x00000000, + ) + + @Test + fun darkBackgroundWithReadableText() { + for (seed in seeds) { + val scheme = artworkColorsFromSeed(seed).scheme + assertTrue("background for $seed", scheme.background.luminance() < 0.1f) + assertTrue("onBackground for $seed", contrast(scheme.background, scheme.onBackground) >= 4.5f) + assertTrue("primary text for $seed", contrast(scheme.primary, scheme.onPrimary) >= 3f) + assertTrue("onSurface for $seed", contrast(scheme.surface, scheme.onSurface) >= 4.5f) + } + } + + @Test + fun independentOfAlphaBits() { + assertEquals( + artworkColorsFromSeed(0xFF3A7BD5.toInt()).scheme.primary, + artworkColorsFromSeed(0x3A7BD5).scheme.primary, + ) + } + + private fun contrast( + a: Color, + b: Color, + ): Float { + val hi = maxOf(a.luminance(), b.luminance()) + val lo = minOf(a.luminance(), b.luminance()) + return (hi + 0.05f) / (lo + 0.05f) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68e9845b..02c9272e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ coroutines = "1.11.0" room3 = "3.0.3" sqliteBundled = "2.7.1" paging = "3.5.1" +palette = "1.0.0" navigationCompose = "2.10.1" reorderable = "3.1.0" datastore = "1.2.1" @@ -49,6 +50,7 @@ androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", v androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" } androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" } androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }