mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
colour letter placeholders by name and seed artwork themes from them
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user