From ee3bf734dac27c1641b9ccae6584f87329fe28eb Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:12:29 +0000 Subject: [PATCH] bound and harden the artwork seed cache --- .../ui/theme/AnimatedArtworkColors.kt | 14 +++---- .../subtracks/ui/theme/ArtworkSeedCache.kt | 38 +++++++++++++----- .../com/subtracks/ui/theme/ArtworkTheme.kt | 4 +- .../ui/theme/ArtworkExtractionTest.kt | 2 + .../preview_server.cpython-313.pyc | Bin 4135 -> 0 bytes 5 files changed, 38 insertions(+), 20 deletions(-) delete mode 100644 tools/__pycache__/preview_server.cpython-313.pyc diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt index 8509852a..6831bc5c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt @@ -9,12 +9,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp const val ARTWORK_THEME_TRANSITION_MS = 600 -fun baseArtworkColors(): ArtworkColors = +private val baseArtworkColors = ArtworkColors( scheme = SubtracksColorScheme, gradientHigh = SubtracksColorScheme.surfaceContainerHigh, @@ -29,15 +28,14 @@ fun rememberAnimatedArtworkColors( target: ArtworkColors?, durationMillis: Int = ARTWORK_THEME_TRANSITION_MS, ): ArtworkColors { - val base = remember { baseArtworkColors() } - var from by remember { mutableStateOf(target ?: base) } - var to by remember { mutableStateOf(target ?: base) } + var from by remember { mutableStateOf(target ?: baseArtworkColors) } + var to by remember { mutableStateOf(target ?: baseArtworkColors) } val progress = remember { Animatable(1f) } - LaunchedEffect(target) { - val next = target ?: base + LaunchedEffect(target, durationMillis) { + val next = target ?: baseArtworkColors if (next !== to) { - from = to + from = lerpArtworkColors(from, to, progress.value) to = next progress.snapTo(0f) progress.animateTo(1f, tween(durationMillis = durationMillis)) 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 34ead2ee..e478a85d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -2,6 +2,8 @@ package com.subtracks.ui.theme import android.content.Context import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import androidx.collection.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color @@ -21,27 +23,43 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs private const val SEED_ART_SIZE_PX = 128 private const val PREFETCH_SETTLE_MS = 250L +private const val MAX_CACHED_SEEDS = 256 object ArtworkSeedCache { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val jobs = ConcurrentHashMap?>>() + private val cache = LruCache>(MAX_CACHED_SEEDS) - fun load( + suspend fun seeds( context: Context, ref: CoverArtRef, - ): Deferred?> = - jobs.getOrPut(ref.cacheKey) { - scope.async { - val seeds = extract(context, ref) - if (seeds == null) jobs.remove(ref.cacheKey) - seeds - } - } + ): Pair? { + cache.get(ref.cacheKey)?.let { return it } + val job = jobs.computeIfAbsent(ref.cacheKey) { scope.async { extract(context, ref) } } + val seeds = job.await() + jobs.remove(ref.cacheKey, job) + 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( context: Context, @@ -108,6 +126,6 @@ fun PrefetchArtworkSeeds(ref: CoverArtRef?) { LaunchedEffect(ref?.cacheKey) { if (ref == null) return@LaunchedEffect delay(PREFETCH_SETTLE_MS) - ArtworkSeedCache.load(context, ref) + ArtworkSeedCache.prefetch(context, ref) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index c1ac1a37..e2d52ebd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -141,7 +141,7 @@ private fun Color.withMinLuminance(min: Float): Color { } fun ArtworkColors.gradientColorAt(fraction: Float): Color { - if (fraction.isNaN()) return gradientHigh + if (!fraction.isFinite()) return gradientHigh val f = fraction.mod(1f) return if (f < 0.5f) { lerp(gradientHigh, gradientLow, f * 2f) @@ -173,7 +173,7 @@ fun rememberArtworkColors( private fun rememberArtworkSeed(ref: CoverArtRef?): State?> { val context = LocalPlatformContext.current return produceState?>(initialValue = null, ref?.cacheKey) { - value = ref?.let { ArtworkSeedCache.load(context, it).await() } + value = ref?.let { ArtworkSeedCache.seeds(context, it) } } } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index 78f4a527..18786ab8 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -37,6 +37,7 @@ class ArtworkExtractionTest { @Before fun installImageLoader() { + ArtworkSeedCache.clear() SingletonImageLoader.reset() SingletonImageLoader.setUnsafe( ImageLoader @@ -48,6 +49,7 @@ class ArtworkExtractionTest { @After fun resetImageLoader() { + ArtworkSeedCache.clear() SingletonImageLoader.reset() } diff --git a/tools/__pycache__/preview_server.cpython-313.pyc b/tools/__pycache__/preview_server.cpython-313.pyc deleted file mode 100644 index d254072ceac3164750dc368b43b41d914b243336..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4135 zcmey&%ge>Uz`!6NxhboZgMr~Ohy%l%P{!wPj0_A@8G;##7=jstnYCUi zB}fIL2APDrg41dW14E)H14E23F15j&tPBhcFdhDk!CXZmI9#TUYzKrL%pD_Q!W6@A z$^_vT@y755=|b4Zq$!l6z>vHaoQD$B`$T3`r1qBMZ`6;D2sR}uXmHDM53K~hJnK>!?5VsZU7r|Ydn3tkZ zT$HR|l3HA%Uz}W&nwnRfkzZ1*srM4(TfZtMJ-sRpn3ajyObiTMHpL~CIjMGB3JRII z>3U#qDO7VQC@3i8CRXTXq-LgPlvpVk85tB*Sc8SjGgC@3tP~PUOY$KCFf||%1;xx< zP>3bwl|Ur(ld@8iOLWsRORN-<^Ycm)GxIRiB$pNy=NDNiRORRA>SpGFG}dz2=tCW5 zQ=D9sSx^FUVM=~-X>Mv>iC$r8YEflyY7RKS9CLCs)IpBV%}+_p(Jd*;OixcOQrFZ= z%P(?GOwQ0qE6qzT$;{7F(8$b9*M$16EU^d_RtmOYf2XIGIF^(YWhRxDq-v-a7bUA} zf}EkC08*BmlbVjKNG&3|SmSEMPV>n9T}ivw+!b!K_8>p$xW+ zMI0&&nrye&JreU$a#D+4YBDe|Bty$Suw|ga1XSvNHez64uw$Ic5DgXv5h2W>46!T> z3`z{4jKPf2EFduuHed{8a$z)MRA5MF&}8zv#R<=wn#{LYi%Sbqi*9km$7kkcmc+;3 zVhQs157A_~#ZsJ_lXiKa z*gqN=?r`vSvR~qmn4xlk!*UIX*r0Zq!}2r8KPce=atEj$`0NG^k5Gmnz z6oz>pNe8H4FP3LuVEEC%aD#*AI)}tX4v87^7qslJa5#X2pU{e>w4%304Lm6SZHK2lt3JeU0#D~=!Q2awx z!zfUag+&lScYuNwrV&bGamRHAhEPUqjhSF33^yNSV2B3A4cN3$Mo{x2nAMV*i6M_E zlqm=l++Z050ZTA>%)tyG2y?pvLpXB~mL^OvTQn%z!3G4gfg&2FBabVPslJiqC z^U|x>ZS<4!Q!4FVo?u{LsNw)kQvqr$r*`7#i=E>U=ynN zVCsBQ^U_N)G(~Uml@ulB73UoG6QmGSJKbU_N=;0;#SX1=Zn1g#dAT}=++qW}GxZiPxP&Q6EiTBZidQrZuyJc z@(Wy+`!4j|pnhG&>Y|F(9YN6vDKnf`s9zE^+K_ldTw;3J#Ing16Ii}8GYA#p$F!6mNn$lc%(==bXMy3PaE8FGb3=?0GwxZv&N zyUHQ)U6et{_^P1MuVNks28Kgwg09-kN42?JE!kHxXmWxo(Y(am)cE*YT=DU_;6`qI z{4JjN_`=e}9H?sfu4b$p{DpP_W1ae{N(ufTih8X zB?Wqr3LKP-N(zcV-Kbm4#g)ZH;N-=TmtT|%O8P}|pxTW+C9??R<6G>BNyQ*xkY%8B z8XsQ->T(u=dR4dBpe9NLXXX~@|^kn?Z!N4edn}PE-1J?s~&i3-g^6Tv47um&cu&}ke zH@dg`H2O>snl3(3e7gKZ`2|AD#TSY%mtQEqLE^Ha)n!5J9U%v-F0=T3W@h4GYGC=m zA<4zk!1l#LoKdnPr9Znf`#PV|MLwf14C0KEA50incsi;sGfUo-S6X1a++v}{?AjHD zm*ovQjXR3_D>^G?Fm_g7;g;Tz(%^D~SFk_2Gx|EO&P86G71=vXF6cTOP`}LU)!=r6 Xh3h(t#6=c~t1MC<*cn))z_APf!NscS