diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 523289ca..b67c5016 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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()) { 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 ffe10456..7cb6f9ff 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -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), ) } 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 57041aca..809be17f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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, 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 dbe87cfe..96531603 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index b58550b2..d784f5f2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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(null) } 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 d45681b5..ca32d763 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -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, 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 443b3743..9d80ba30 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index a1b1d90a..80537b3e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -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) diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt index 7d1419a4..04394762 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index 5ff92070..901499b5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -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(null)