mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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",
|
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) {
|
ArtworkTheme(artwork) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().imePadding()) {
|
Column(Modifier.fillMaxSize().imePadding()) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import coil3.request.ImageRequest
|
|||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.size.Dimension
|
import coil3.size.Dimension
|
||||||
import com.subtracks.data.model.CoverArtRef
|
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
|
private const val MAX_CACHED_RATIOS = 256
|
||||||
|
|
||||||
@@ -78,10 +80,16 @@ fun CoverArt(
|
|||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
||||||
var thumbnailLoaded 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)) }
|
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 =
|
val frameModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.shadow(elevation, RoundedCornerShape(2.dp), clip = elevation > 0.dp)
|
.shadow(elevation, RoundedCornerShape(2.dp), clip = elevation > 0.dp)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
if (square) {
|
||||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null) } }
|
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null) } }
|
||||||
@@ -97,6 +105,7 @@ fun CoverArt(
|
|||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
) {
|
) {
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@@ -160,6 +169,7 @@ fun CoverArt(
|
|||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
) {
|
) {
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
Image(
|
Image(
|
||||||
@@ -188,6 +198,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
|
placeholderContentColor: Color?,
|
||||||
main: @Composable () -> Unit,
|
main: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
@@ -195,7 +206,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
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 = placeholderContentColor ?: MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ fun AlbumDetailRoute(
|
|||||||
songs = songs,
|
songs = songs,
|
||||||
discs = discs,
|
discs = discs,
|
||||||
coverArt = viewModel::coverArt,
|
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,
|
downloads = downloads,
|
||||||
downloadStatus = downloadStatus,
|
downloadStatus = downloadStatus,
|
||||||
onDownloadAction = requestDownloadAction,
|
onDownloadAction = requestDownloadAction,
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fun ArtistDetailRoute(
|
|||||||
albums = albums,
|
albums = albums,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artist?.name),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ fun LibraryRoute(
|
|||||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
|
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
|
||||||
}
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
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 downloads by viewModel.downloads(listTab).collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fun PlaylistDetailRoute(
|
|||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||||
coverArt = viewModel::coverArt,
|
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,
|
downloads = downloads,
|
||||||
downloadStatus = downloadStatus,
|
downloadStatus = downloadStatus,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ fun NowPlayingRoute(
|
|||||||
title = sourceTitle.value,
|
title = sourceTitle.value,
|
||||||
coverArt = art,
|
coverArt = art,
|
||||||
thumbnailRef = thumbnail,
|
thumbnailRef = thumbnail,
|
||||||
artwork = rememberArtworkColors(thumbnail ?: art),
|
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onQueue = onQueue,
|
onQueue = onQueue,
|
||||||
onPlayPause = controller::togglePlayPause,
|
onPlayPause = controller::togglePlayPause,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.TileMode
|
import androidx.compose.ui.graphics.TileMode
|
||||||
import androidx.compose.ui.graphics.lerp
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import coil3.compose.LocalPlatformContext
|
import coil3.compose.LocalPlatformContext
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
@@ -179,15 +180,23 @@ private fun blendHue(
|
|||||||
return (from + delta * fraction + 360f) % 360f
|
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
|
@Composable
|
||||||
fun rememberArtworkColors(
|
fun rememberArtworkColors(
|
||||||
ref: CoverArtRef?,
|
ref: CoverArtRef?,
|
||||||
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
|
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
|
||||||
markActive: Boolean = false,
|
markActive: Boolean = false,
|
||||||
|
fallbackName: String? = null,
|
||||||
): ArtworkColors? {
|
): ArtworkColors? {
|
||||||
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
|
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
|
||||||
val seeds by rememberArtworkSeed(ref)
|
val seeds by rememberArtworkSeed(ref)
|
||||||
val effective = seeds ?: cached
|
val effective = seeds ?: cached ?: fallbackName?.takeIf { it.isNotBlank() }?.let { placeholderSeed(it) to null }
|
||||||
if (markActive) {
|
if (markActive) {
|
||||||
LaunchedEffect(ref?.cacheKey, effective) {
|
LaunchedEffect(ref?.cacheKey, effective) {
|
||||||
if (ref != null && effective != null) ArtworkSeedCache.markLast(ref)
|
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.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
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
|
@Test
|
||||||
fun independentOfAlphaBits() {
|
fun independentOfAlphaBits() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ class ArtworkExtractionTest {
|
|||||||
assertTrue(colors!!.scheme.background.luminance() < 0.1f)
|
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
|
@Test
|
||||||
fun keepsContentStateWhenArtworkArrives() {
|
fun keepsContentStateWhenArtworkArrives() {
|
||||||
val artwork = mutableStateOf<ArtworkColors?>(null)
|
val artwork = mutableStateOf<ArtworkColors?>(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user