From 32360fac08cd94813e5dd2b665c5754f29a80777 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:42:21 +0000 Subject: [PATCH] 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. --- .../com/subtracks/ui/components/Components.kt | 14 ++-- .../ui/components/CoverArtReloadTest.kt | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/CoverArtReloadTest.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index b9026c94..9516cebe 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -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, diff --git a/app/src/test/kotlin/com/subtracks/ui/components/CoverArtReloadTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/CoverArtReloadTest.kt new file mode 100644 index 00000000..d1950c51 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/CoverArtReloadTest.kt @@ -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() + + 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()) + .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()) + } +}