From 2ba87114da226159fc4e4832a2eea79279fdc879 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:09:11 +0000 Subject: [PATCH] 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) --- .../ui/library/ArtistDetailScreen.kt | 43 +++++++++++--- .../subtracks/ui/theme/ArtworkSeedCache.kt | 56 ++++++++++++++++++- .../com/subtracks/ui/AppScreenshotTest.kt | 4 ++ .../subtracks/ui/theme/ArtworkNameBandTest.kt | 38 +++++++++++++ 4 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt 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 3914db03..8ca33e40 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt index 1f5bbd2e..ca1169e9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -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?>>() private val cache = LruCache>(MAX_CACHED_SEEDS) + private val busy = LruCache(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? = + ): Pair? = 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? { 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 +} diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index d7b89844..f4bf8c50 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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") } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt new file mode 100644 index 00000000..d75fd010 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt @@ -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 })) + } +}