mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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 }))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user