bound and harden the artwork seed cache

This commit is contained in:
austinried
2026-10-04 00:29:53 +00:00
parent f2ebac783d
commit ee3bf734da
5 changed files with 38 additions and 20 deletions
@@ -9,12 +9,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
const val ARTWORK_THEME_TRANSITION_MS = 600 const val ARTWORK_THEME_TRANSITION_MS = 600
fun baseArtworkColors(): ArtworkColors = private val baseArtworkColors =
ArtworkColors( ArtworkColors(
scheme = SubtracksColorScheme, scheme = SubtracksColorScheme,
gradientHigh = SubtracksColorScheme.surfaceContainerHigh, gradientHigh = SubtracksColorScheme.surfaceContainerHigh,
@@ -29,15 +28,14 @@ fun rememberAnimatedArtworkColors(
target: ArtworkColors?, target: ArtworkColors?,
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS, durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
): ArtworkColors { ): ArtworkColors {
val base = remember { baseArtworkColors() } var from by remember { mutableStateOf(target ?: baseArtworkColors) }
var from by remember { mutableStateOf(target ?: base) } var to by remember { mutableStateOf(target ?: baseArtworkColors) }
var to by remember { mutableStateOf(target ?: base) }
val progress = remember { Animatable(1f) } val progress = remember { Animatable(1f) }
LaunchedEffect(target) { LaunchedEffect(target, durationMillis) {
val next = target ?: base val next = target ?: baseArtworkColors
if (next !== to) { if (next !== to) {
from = to from = lerpArtworkColors(from, to, progress.value)
to = next to = next
progress.snapTo(0f) progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = durationMillis)) progress.animateTo(1f, tween(durationMillis = durationMillis))
@@ -2,6 +2,8 @@ package com.subtracks.ui.theme
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.annotation.VisibleForTesting
import androidx.collection.LruCache
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -21,27 +23,43 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs import kotlin.math.abs
private const val SEED_ART_SIZE_PX = 128 private const val SEED_ART_SIZE_PX = 128
private const val PREFETCH_SETTLE_MS = 250L private const val PREFETCH_SETTLE_MS = 250L
private const val MAX_CACHED_SEEDS = 256
object ArtworkSeedCache { object ArtworkSeedCache {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>() private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>()
private val cache = LruCache<String, Pair<Int, Int?>>(MAX_CACHED_SEEDS)
fun load( suspend fun seeds(
context: Context, context: Context,
ref: CoverArtRef, ref: CoverArtRef,
): Deferred<Pair<Int, Int?>?> = ): Pair<Int, Int?>? {
jobs.getOrPut(ref.cacheKey) { cache.get(ref.cacheKey)?.let { return it }
scope.async { val job = jobs.computeIfAbsent(ref.cacheKey) { scope.async { extract(context, ref) } }
val seeds = extract(context, ref) val seeds = job.await()
if (seeds == null) jobs.remove(ref.cacheKey) jobs.remove(ref.cacheKey, job)
seeds if (seeds != null) cache.put(ref.cacheKey, seeds)
} return seeds
} }
fun prefetch(
context: Context,
ref: CoverArtRef,
) {
scope.launch { seeds(context, ref) }
}
@VisibleForTesting
fun clear() {
jobs.clear()
cache.evictAll()
}
private suspend fun extract( private suspend fun extract(
context: Context, context: Context,
@@ -108,6 +126,6 @@ fun PrefetchArtworkSeeds(ref: CoverArtRef?) {
LaunchedEffect(ref?.cacheKey) { LaunchedEffect(ref?.cacheKey) {
if (ref == null) return@LaunchedEffect if (ref == null) return@LaunchedEffect
delay(PREFETCH_SETTLE_MS) delay(PREFETCH_SETTLE_MS)
ArtworkSeedCache.load(context, ref) ArtworkSeedCache.prefetch(context, ref)
} }
} }
@@ -141,7 +141,7 @@ private fun Color.withMinLuminance(min: Float): Color {
} }
fun ArtworkColors.gradientColorAt(fraction: Float): Color { fun ArtworkColors.gradientColorAt(fraction: Float): Color {
if (fraction.isNaN()) return gradientHigh if (!fraction.isFinite()) return gradientHigh
val f = fraction.mod(1f) val f = fraction.mod(1f)
return if (f < 0.5f) { return if (f < 0.5f) {
lerp(gradientHigh, gradientLow, f * 2f) lerp(gradientHigh, gradientLow, f * 2f)
@@ -173,7 +173,7 @@ fun rememberArtworkColors(
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> { private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) { return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
value = ref?.let { ArtworkSeedCache.load(context, it).await() } value = ref?.let { ArtworkSeedCache.seeds(context, it) }
} }
} }
@@ -37,6 +37,7 @@ class ArtworkExtractionTest {
@Before @Before
fun installImageLoader() { fun installImageLoader() {
ArtworkSeedCache.clear()
SingletonImageLoader.reset() SingletonImageLoader.reset()
SingletonImageLoader.setUnsafe( SingletonImageLoader.setUnsafe(
ImageLoader ImageLoader
@@ -48,6 +49,7 @@ class ArtworkExtractionTest {
@After @After
fun resetImageLoader() { fun resetImageLoader() {
ArtworkSeedCache.clear()
SingletonImageLoader.reset() SingletonImageLoader.reset()
} }
Binary file not shown.