compute and persist the artist name busy flag with the artwork seed

- ArtworkSeedStore now carries the flag; artwork_seeds gains a nullable nameBusy column
  (DB v12, migration 11->12) so it survives cold starts with no decode
- resolve() computes the flag from the same bitmap it extracts the colour seed from, so
  there is no second decode, and persists it alongside the seed
- overlaidNameBusy reuses the seed job and reads the cached flag synchronously, so the
  scrim is known before the art shows; drop the fade
- bottomBandBusy now uses perceptual (gamma-space) luma, edge density and a bright
  fraction, which catches busy art on dark backgrounds (issue #40) that mean/variance missed
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 658b7f9452
commit f4ca0fd6d4
8 changed files with 916 additions and 48 deletions
@@ -105,6 +105,13 @@ val MIGRATION_10_11 =
}
}
val MIGRATION_11_12 =
object : Migration(11, 12) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artwork_seeds` ADD COLUMN `nameBusy` INTEGER")
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -117,4 +124,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 11,
version = 12,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -242,6 +242,7 @@ data class ArtworkSeed(
@PrimaryKey val cacheKey: String,
val primary: Int,
val secondary: Int?,
val nameBusy: Boolean? = null,
)
class QueueKindConverter {
@@ -4,26 +4,17 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.ArtworkSeed
interface ArtworkSeedStore {
suspend fun seed(cacheKey: String): Pair<Int, Int?>?
suspend fun seed(cacheKey: String): ArtworkSeed?
suspend fun save(
cacheKey: String,
seeds: Pair<Int, Int?>,
)
suspend fun save(seed: ArtworkSeed)
}
class ArtworkSeedRepository(
private val db: SubtracksDatabase,
) : ArtworkSeedStore {
override suspend fun seed(cacheKey: String): Pair<Int, Int?>? {
val row = db.artworkSeedDao().seed(cacheKey) ?: return null
return row.primary to row.secondary
}
override suspend fun seed(cacheKey: String): ArtworkSeed? = db.artworkSeedDao().seed(cacheKey)
override suspend fun save(
cacheKey: String,
seeds: Pair<Int, Int?>,
) {
db.artworkSeedDao().upsert(ArtworkSeed(cacheKey, seeds.first, seeds.second))
override suspend fun save(seed: ArtworkSeed) {
db.artworkSeedDao().upsert(seed)
}
}
@@ -1,7 +1,5 @@
package com.subtracks.ui.library
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -73,7 +71,6 @@ 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
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
@Composable
@@ -148,7 +145,6 @@ fun ArtistDetailScreen(
),
)
val nameBusy = rememberOverlaidNameBusy(art ?: artThumbnail)
val nameScrimAlpha by animateFloatAsState(if (nameBusy) 1f else 0f, tween(SCRIM_FADE_MS), label = "artistNameScrim")
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
@@ -185,14 +181,13 @@ fun ArtistDetailScreen(
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
if (nameBusy) {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(ART_HEIGHT * 0.4f)
.graphicsLayer { alpha = nameScrimAlpha }
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)),
@@ -16,6 +16,7 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.ArtworkSeedStore
import kotlinx.coroutines.CancellationException
@@ -28,14 +29,15 @@ 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
private const val BUSY_BRIGHT_LUMINANCE = 0.6f
private const val BUSY_BRIGHT_FRACTION = 0.05f
private const val BUSY_EDGE_LUMINANCE = 0.2f
private const val BUSY_EDGE_FRACTION = 0.1f
object ArtworkSeedCache {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -52,6 +54,8 @@ object ArtworkSeedCache {
fun cached(cacheKey: String): Pair<Int, Int?>? = cache.get(cacheKey)
fun cachedBusy(cacheKey: String): Boolean? = busy.get(cacheKey)
suspend fun seeds(
context: Context,
ref: CoverArtRef,
@@ -74,9 +78,8 @@ object ArtworkSeedCache {
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
seeds(context, ref)
return busy.get(ref.cacheKey) ?: false
}
@VisibleForTesting
@@ -90,13 +93,17 @@ object ArtworkSeedCache {
context: Context,
ref: CoverArtRef,
): Pair<Int, Int?>? {
ignoreFailure { store?.seed(ref.cacheKey) }?.let {
cache.put(ref.cacheKey, it)
return it
ignoreFailure { store?.seed(ref.cacheKey) }?.let { stored ->
cache.put(ref.cacheKey, stored.primary to stored.secondary)
stored.nameBusy?.let { busy.put(ref.cacheKey, it) }
if (stored.nameBusy != null) return stored.primary to stored.secondary
}
val seeds = extract(context, ref) ?: return null
val bitmap = decode(context, ref) ?: return null
val seeds = seedsFrom(bitmap) ?: return null
val nameBusy = bottomBandBusy(bitmap)
cache.put(ref.cacheKey, seeds)
ignoreFailure { store?.save(ref.cacheKey, seeds) }
busy.put(ref.cacheKey, nameBusy)
ignoreFailure { store?.save(ArtworkSeed(ref.cacheKey, seeds.first, seeds.second, nameBusy)) }
return seeds
}
@@ -109,11 +116,6 @@ object ArtworkSeedCache {
null
}
private suspend fun extract(
context: Context,
ref: CoverArtRef,
): Pair<Int, Int?>? = decode(context, ref)?.let(::seedsFrom)
private suspend fun decode(
context: Context,
ref: CoverArtRef,
@@ -135,25 +137,36 @@ object ArtworkSeedCache {
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
val width = bitmap.width
var previousRow: FloatArray? = null
var bright = 0
var edges = 0
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
val row = FloatArray(width) { x -> lumaAt(bitmap, x, y) }
for (x in 0 until width) {
val luminance = row[x]
if (luminance > BUSY_BRIGHT_LUMINANCE) bright++
if (x > 0 && abs(luminance - row[x - 1]) > BUSY_EDGE_LUMINANCE) edges++
if (previousRow != null && abs(luminance - previousRow[x]) > BUSY_EDGE_LUMINANCE) edges++
count++
}
previousRow = row
}
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
return bright.toFloat() / count > BUSY_BRIGHT_FRACTION || edges.toFloat() / count > BUSY_EDGE_FRACTION
}
private fun lumaAt(
bitmap: Bitmap,
x: Int,
y: Int,
): Float {
val color = Color(bitmap.getPixel(x, y))
return 0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue
}
private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
@@ -206,7 +219,8 @@ fun PrefetchArtworkSeeds(ref: CoverArtRef?) {
@Composable
fun rememberOverlaidNameBusy(ref: CoverArtRef?): Boolean {
val context = LocalPlatformContext.current
return produceState(false, ref?.cacheKey) {
val key = ref?.cacheKey
return produceState(key?.let(ArtworkSeedCache::cachedBusy) ?: false, key) {
value = ref?.let { ArtworkSeedCache.overlaidNameBusy(context, it) } ?: false
}.value
}
@@ -26,6 +26,11 @@ class ArtworkNameBandTest {
assertFalse(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(24, 20, 34) }))
}
@Test
fun smoothDarkGradientNeedsNoScrim() {
assertFalse(ArtworkSeedCache.bottomBandBusy(bitmap { x, _ -> Color.rgb(x * 2, x * 2, x * 2) }))
}
@Test
fun brightBandNeedsAScrim() {
assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(235, 230, 220) }))
@@ -35,4 +40,13 @@ class ArtworkNameBandTest {
fun noisyBandNeedsAScrim() {
assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { x, y -> if ((x + y) % 2 == 0) Color.WHITE else Color.BLACK }))
}
@Test
fun darkBandWithBrightStrokesNeedsAScrim() {
assertTrue(
ArtworkSeedCache.bottomBandBusy(
bitmap { x, y -> if (x % 9 == 0 || y % 9 == 0) Color.rgb(210, 40, 40) else Color.BLACK },
),
)
}
}