colour letter placeholders by name and seed artwork themes from them

This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 065bd99086
commit f33266113b
10 changed files with 60 additions and 9 deletions
@@ -258,7 +258,12 @@ private fun MainNavigation() {
label = "libraryBottomInset",
)
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), markActive = true)
val artwork =
rememberArtworkColors(
playbackController.coverArt(playback.item, thumbnail = true),
markActive = true,
fallbackName = playback.item?.title,
)
ArtworkTheme(artwork) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().imePadding()) {
@@ -47,6 +47,8 @@ import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.size.Dimension
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed
private const val MAX_CACHED_RATIOS = 256
@@ -78,10 +80,16 @@ fun CoverArt(
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
val letterScheme =
if (showPlaceholder || (failed && !thumbnailLoaded)) {
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
} else {
null
}
val frameModifier =
Modifier
.shadow(elevation, RoundedCornerShape(2.dp), clip = elevation > 0.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
if (square) {
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null) } }
@@ -97,6 +105,7 @@ fun CoverArt(
onThumbnailRatio = { thumbnailRatio = it },
contentScale = ContentScale.Crop,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
) {
if (ref != null) {
AsyncImage(
@@ -160,6 +169,7 @@ fun CoverArt(
onThumbnailRatio = { thumbnailRatio = it },
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
) {
if (ref != null) {
Image(
@@ -188,6 +198,7 @@ private fun BoxScope.CoverArtContent(
onThumbnailRatio: (Float) -> Unit,
contentScale: ContentScale,
colorFilter: ColorFilter?,
placeholderContentColor: Color?,
main: @Composable () -> Unit,
) {
val context = LocalPlatformContext.current
@@ -195,7 +206,7 @@ private fun BoxScope.CoverArtContent(
Text(
text = name.trim().take(1).uppercase(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = placeholderContentColor ?: MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Center),
)
}
@@ -97,7 +97,12 @@ fun AlbumDetailRoute(
songs = songs,
discs = discs,
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
artwork =
rememberArtworkColors(
shortcutArt ?: viewModel.coverArt(album?.coverArt, true),
THEME_TRANSITION_MS,
fallbackName = album?.name,
),
downloads = downloads,
downloadStatus = downloadStatus,
onDownloadAction = requestDownloadAction,
@@ -180,7 +180,7 @@ fun ArtistDetailRoute(
albums = albums,
art = art,
artThumbnail = artThumbnail,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS),
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artist?.name),
coverArt = viewModel::coverArt,
onBack = onBack,
onAlbumClick = onAlbumClick,
@@ -222,7 +222,7 @@ fun LibraryRoute(
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
}
val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
val downloads by viewModel.downloads(listTab).collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
@@ -82,7 +82,7 @@ fun PlaylistDetailRoute(
playlist = playlist,
songs = viewModel.songs.collectAsLazyPagingItems(),
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS, fallbackName = playlist?.name),
downloads = downloads,
downloadStatus = downloadStatus,
offline = offline,
@@ -152,7 +152,7 @@ fun NowPlayingRoute(
title = sourceTitle.value,
coverArt = art,
thumbnailRef = thumbnail,
artwork = rememberArtworkColors(thumbnail ?: art),
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
onBack = onBack,
onQueue = onQueue,
onPlayPause = controller::togglePlayPause,
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import coil3.compose.LocalPlatformContext
import com.subtracks.data.model.CoverArtRef
import kotlin.random.Random
@@ -179,15 +180,23 @@ private fun blendHue(
return (from + delta * fraction + 360f) % 360f
}
internal fun placeholderSeed(name: String): Int {
val key = name.trim()
if (key.isEmpty()) return 0
val hue = (key.hashCode() and 0x7FFFFFFF) % 360
return Color.hsl(hue.toFloat(), 0.45f, 0.42f).toArgb()
}
@Composable
fun rememberArtworkColors(
ref: CoverArtRef?,
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
markActive: Boolean = false,
fallbackName: String? = null,
): ArtworkColors? {
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
val seeds by rememberArtworkSeed(ref)
val effective = seeds ?: cached
val effective = seeds ?: cached ?: fallbackName?.takeIf { it.isNotBlank() }?.let { placeholderSeed(it) to null }
if (markActive) {
LaunchedEffect(ref?.cacheKey, effective) {
if (ref != null && effective != null) ArtworkSeedCache.markLast(ref)
@@ -3,6 +3,7 @@ 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.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -27,6 +28,14 @@ class ArtworkColorsTest {
}
}
@Test
fun placeholderColourDependsOnTheNameAndIsNotGrey() {
assertEquals(0, placeholderSeed(""))
assertNotEquals(placeholderSeed("Kid A"), placeholderSeed("Amnesiac"))
val (_, saturation, _) = Color(placeholderSeed("Kid A")).toHsl()
assertTrue("placeholder should carry colour", saturation > 0f)
}
@Test
fun independentOfAlphaBits() {
assertEquals(
@@ -77,6 +77,18 @@ class ArtworkExtractionTest {
assertTrue(colors!!.scheme.background.luminance() < 0.1f)
}
@Test
fun fallsBackToAPlaceholderColourWhenThereIsNoArt() {
var colors: ArtworkColors? = null
composeRule.setContent {
SubtracksTheme {
colors = rememberArtworkColors(null, fallbackName = "Kid A")
}
}
composeRule.waitForIdle()
assertEquals(artworkColorsFromSeed(placeholderSeed("Kid A")).scheme.primary, colors?.scheme?.primary)
}
@Test
fun keepsContentStateWhenArtworkArrives() {
val artwork = mutableStateOf<ArtworkColors?>(null)