artwork: keep the cover request stable across recompositions

CoverArt keyed its remember blocks on the whole CoverArtRef, whose url is
freshly salted on every call. A recomposition therefore built a new
ImageRequest, Coil restarted the load and dropped the current drawable, so
the image flashed back to the letter placeholder before resolving from
cache. Key the request and the load-state remembers on the ref's stable
cacheKey instead.
This commit is contained in:
austinried
2026-10-09 07:03:49 +00:00
parent 206ac8988f
commit 32360fac08
2 changed files with 86 additions and 6 deletions
@@ -87,9 +87,11 @@ fun CoverArt(
colorFilter: ColorFilter? = null,
) {
val context = LocalPlatformContext.current
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
val letterScheme =
if (showPlaceholder || (failed && !thumbnailLoaded)) {
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
@@ -102,7 +104,7 @@ fun CoverArt(
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
if (square) {
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
Box(modifier.then(frameModifier)) {
CoverArtContent(
ref = ref,
@@ -147,7 +149,7 @@ fun CoverArt(
val painter =
rememberAsyncImagePainter(
model =
remember(ref, thumbnailRef, target) {
remember(refKey, thumbnailKey, target) {
ref?.let {
imageRequest(
context,
@@ -231,7 +233,7 @@ private fun BoxScope.CoverArtContent(
}
if (thumbnailRef != null && thumbnailRef != ref) {
AsyncImage(
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
contentDescription = null,
contentScale = contentScale,
colorFilter = colorFilter,
@@ -0,0 +1,78 @@
package com.subtracks.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.asImage
import coil3.test.FakeImageLoaderEngine
import com.subtracks.data.model.CoverArtRef
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
import java.util.concurrent.atomic.AtomicInteger
@OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CoverArtReloadTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val requests = AtomicInteger(0)
@Before
fun installImageLoader() {
SingletonImageLoader.reset()
val image = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }.asImage()
val engine =
FakeImageLoaderEngine
.Builder()
.requestTransformer {
requests.incrementAndGet()
it
}.default(image)
.build()
SingletonImageLoader.setUnsafe(
ImageLoader
.Builder(ApplicationProvider.getApplicationContext<Context>())
.components { add(engine) }
.build(),
)
}
@After
fun resetImageLoader() {
SingletonImageLoader.reset()
}
@Test
fun aNewSaltedUrlForTheSameArtworkDoesNotRestartTheLoad() {
val refState = mutableStateOf(CoverArtRef(url = "http://host/art?id=1&s=one", cacheKey = "1:art:true"))
composeRule.setContent {
CoverArt(ref = refState.value, name = "Kid A", modifier = Modifier.size(64.dp))
}
composeRule.waitUntil(timeoutMillis = 5_000) { requests.get() == 1 }
refState.value = CoverArtRef(url = "http://host/art?id=1&s=two", cacheKey = "1:art:true")
composeRule.waitForIdle()
composeRule.mainClock.advanceTimeBy(500)
composeRule.waitForIdle()
assertEquals(1, requests.get())
}
}