align the artist top bar with the themed screens and scrim a busy name

- the artist top bar now uses the default app-bar title style and the art-derived
  heroBarColor instead of a hardcoded headline/black/white, so it follows the artist theme
- add a bottom scrim behind the overlaid artist name only when the art under it is bright
  or noisy, measured from the seed bitmap (so no extra decode)
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 8b43de25f7
commit 2ba87114da
4 changed files with 129 additions and 12 deletions
@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -59,7 +61,9 @@ import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.PrefetchArtworkSeeds
import com.subtracks.ui.theme.heroBarColor
import com.subtracks.ui.theme.rememberArtworkColors
import com.subtracks.ui.theme.rememberOverlaidNameBusy
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -67,6 +71,7 @@ private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp
private val FADE_LEAD = 24.dp
private const val THEME_TRANSITION_MS = 100
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
@Composable
fun ArtistDetailRoute(
@@ -139,10 +144,21 @@ fun ArtistDetailScreen(
blurRadius = 10f,
),
)
val nameBusy = rememberOverlaidNameBusy(art ?: artThumbnail)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(Color.Black)) {
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val screenHeightPx = with(density) { maxHeight.toPx() }
val barHeightPx = with(density) { barHeight.toPx() }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
val before = if (index <= 0) 0f else with(density) { ART_HEIGHT.toPx() } * index
before + listState.firstVisibleItemScrollOffset
}
}
val barColor = heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx)
HeroGradient(
colors = artwork,
scrollPx = { 0f },
@@ -165,6 +181,20 @@ fun ArtistDetailScreen(
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameBusy) {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(ART_HEIGHT * 0.4f)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)),
),
).testTag(ARTIST_NAME_SCRIM_TAG),
)
}
Text(
text = artist?.name.orEmpty(),
style = imageNameStyle,
@@ -227,26 +257,21 @@ fun ArtistDetailScreen(
title = {
Text(
text = artist?.name.orEmpty(),
style = nameTextStyle,
color = Color.White.copy(alpha = barFraction),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer { alpha = barFraction },
)
},
navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = Color.White,
)
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier =
Modifier
.align(Alignment.TopStart)
.background(Color.Black.copy(alpha = barFraction))
.background(barColor.copy(alpha = barFraction))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -6,6 +6,7 @@ import androidx.annotation.VisibleForTesting
import androidx.collection.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.produceState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette
@@ -27,15 +28,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.sqrt
private const val SEED_ART_SIZE_PX = 128
private const val PREFETCH_SETTLE_MS = 250L
private const val MAX_CACHED_SEEDS = 256
private const val BUSY_BAND_TOP = 0.72f
private const val BUSY_MEAN_LUMINANCE = 0.52f
private const val BUSY_STDDEV_LUMINANCE = 0.16f
object ArtworkSeedCache {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>()
private val cache = LruCache<String, Pair<Int, Int?>>(MAX_CACHED_SEEDS)
private val busy = LruCache<String, Boolean>(MAX_CACHED_SEEDS)
@Volatile
private var store: ArtworkSeedStore? = null
@@ -63,10 +69,21 @@ object ArtworkSeedCache {
scope.launch { seeds(context, ref) }
}
suspend fun overlaidNameBusy(
context: Context,
ref: CoverArtRef,
): Boolean {
busy.get(ref.cacheKey)?.let { return it }
val result = decode(context, ref)?.let(::bottomBandBusy) ?: false
busy.put(ref.cacheKey, result)
return result
}
@VisibleForTesting
fun clear() {
jobs.clear()
cache.evictAll()
busy.evictAll()
}
private suspend fun resolve(
@@ -95,7 +112,12 @@ object ArtworkSeedCache {
private suspend fun extract(
context: Context,
ref: CoverArtRef,
): Pair<Int, Int?>? =
): Pair<Int, Int?>? = decode(context, ref)?.let(::seedsFrom)
private suspend fun decode(
context: Context,
ref: CoverArtRef,
): Bitmap? =
try {
val request =
ImageRequest
@@ -106,14 +128,34 @@ object ArtworkSeedCache {
.size(SEED_ART_SIZE_PX)
.allowHardware(false)
.build()
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
image?.let { seedsFrom(it.toBitmap()) }
(SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image?.toBitmap()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
// ponytail: whole-band mean/variance heuristic, swap for an edge-density pass if it misfires
@VisibleForTesting
internal fun bottomBandBusy(bitmap: Bitmap): Boolean {
val firstRow = (bitmap.height * BUSY_BAND_TOP).toInt().coerceIn(0, bitmap.height - 1)
var sum = 0f
var sumSquares = 0f
var count = 0
for (y in firstRow until bitmap.height) {
for (x in 0 until bitmap.width) {
val luminance = Color(bitmap.getPixel(x, y)).luminance()
sum += luminance
sumSquares += luminance * luminance
count++
}
}
if (count == 0) return false
val mean = sum / count
val deviation = sqrt((sumSquares / count - mean * mean).coerceAtLeast(0f))
return mean > BUSY_MEAN_LUMINANCE || deviation > BUSY_STDDEV_LUMINANCE
}
private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
val palette = Palette.from(bitmap).generate()
val primary =
@@ -160,3 +202,11 @@ fun PrefetchArtworkSeeds(ref: CoverArtRef?) {
ArtworkSeedCache.prefetch(context, ref)
}
}
@Composable
fun rememberOverlaidNameBusy(ref: CoverArtRef?): Boolean {
val context = LocalPlatformContext.current
return produceState(false, ref?.cacheKey) {
value = ref?.let { ArtworkSeedCache.overlaidNameBusy(context, it) } ?: false
}.value
}
@@ -40,6 +40,7 @@ import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
import com.subtracks.ui.library.AlbumDetailScreen
import com.subtracks.ui.library.ArtistDetailScreen
import com.subtracks.ui.library.LibraryScreen
@@ -280,6 +281,9 @@ class AppScreenshotTest {
}
}
awaitText("Kid A")
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(ARTIST_NAME_SCRIM_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail.png")
}
@@ -0,0 +1,38 @@
package com.subtracks.ui.theme
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ArtworkNameBandTest {
private fun bitmap(bottom: (Int, Int) -> Int): Bitmap =
Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
for (y in 0 until 64) {
for (x in 0 until 64) {
setPixel(x, y, if (y >= 46) bottom(x, y) else Color.rgb(20, 20, 20))
}
}
}
@Test
fun darkCalmBandNeedsNoScrim() {
assertFalse(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(24, 20, 34) }))
}
@Test
fun brightBandNeedsAScrim() {
assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(235, 230, 220) }))
}
@Test
fun noisyBandNeedsAScrim() {
assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { x, y -> if ((x + y) % 2 == 0) Color.WHITE else Color.BLACK }))
}
}