decode covers at their display size and bucket the memory cache by size

This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 11c0276929
commit 43f39300ce
4 changed files with 80 additions and 18 deletions
@@ -36,6 +36,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
@@ -73,13 +74,13 @@ fun CoverArt(
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) } var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailLoaded 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)) } var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null) } }
val frameModifier = val frameModifier =
Modifier Modifier
.shadow(elevation, RoundedCornerShape(2.dp), clip = elevation > 0.dp) .shadow(elevation, RoundedCornerShape(2.dp), clip = elevation > 0.dp)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
if (square) { if (square) {
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null) } }
Box(modifier.then(frameModifier)) { Box(modifier.then(frameModifier)) {
CoverArtContent( CoverArtContent(
ref = ref, ref = ref,
@@ -109,13 +110,28 @@ fun CoverArt(
} }
} }
} else { } else {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val density = LocalDensity.current
val targetSize =
remember(maxWidth, maxHeight, density) {
val width = with(density) { if (maxWidth.value.isFinite()) maxWidth.roundToPx() else 0 }
val height = with(density) { if (maxHeight.value.isFinite()) maxHeight.roundToPx() else 0 }
when {
width > 0 && height > 0 -> IntSize(width, height)
width > 0 -> IntSize(width, width)
height > 0 -> IntSize(height, height)
else -> null
}
}
val painter = val painter =
rememberAsyncImagePainter( rememberAsyncImagePainter(
model = model, model =
remember(ref, thumbnailRef, targetSize) {
ref?.let { imageRequest(context, it, crossfade = thumbnailRef != null, size = targetSize) }
},
onError = { failed = true }, onError = { failed = true },
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val ratio = val ratio =
painter.intrinsicSize.ratioOrNull() painter.intrinsicSize.ratioOrNull()
?: thumbnailRatio ?: thumbnailRatio
@@ -191,18 +207,27 @@ private fun BoxScope.CoverArtContent(
private fun Size.ratioOrNull(): Float? = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else null private fun Size.ratioOrNull(): Float? = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else null
private fun imageRequest( internal fun imageRequest(
context: Context, context: Context,
ref: CoverArtRef, ref: CoverArtRef,
crossfade: Boolean, crossfade: Boolean,
): ImageRequest = size: IntSize? = null,
): ImageRequest {
val builder =
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(ref.url) .data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey) .diskCacheKey(ref.cacheKey)
.crossfade(crossfade) .crossfade(crossfade)
.build() if (size == null) {
builder.memoryCacheKey(ref.cacheKey)
} else {
builder
.size(size.width, size.height)
.memoryCacheKey("${ref.cacheKey}:${size.width}x${size.height}")
}
return builder.build()
}
@Composable @Composable
fun LoadingState(modifier: Modifier = Modifier) { fun LoadingState(modifier: Modifier = Modifier) {
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
@@ -138,8 +139,8 @@ internal fun prefetchImageRequest(
return ImageRequest return ImageRequest
.Builder(context) .Builder(context)
.data(ref.url) .data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey) .diskCacheKey(ref.cacheKey)
.memoryCachePolicy(CachePolicy.DISABLED)
.size(maxDimension) .size(maxDimension)
.build() .build()
} }
@@ -0,0 +1,35 @@
package com.subtracks.ui.components
import android.content.Context
import androidx.compose.ui.unit.IntSize
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.CoverArtRef
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CoverArtRequestTest {
@Test
fun sizedRequestBucketsTheMemoryKeyBySizeAndKeepsTheDiskKey() {
val context = ApplicationProvider.getApplicationContext<Context>()
val ref = CoverArtRef(url = "http://example.test/art", cacheKey = "1:art:false")
val request = imageRequest(context, ref, crossfade = false, size = IntSize(600, 800))
assertEquals("1:art:false:600x800", request.memoryCacheKey)
assertEquals(ref.cacheKey, request.diskCacheKey)
}
@Test
fun unsizedRequestKeepsTheIdentityMemoryKey() {
val context = ApplicationProvider.getApplicationContext<Context>()
val ref = CoverArtRef(url = "http://example.test/art", cacheKey = "1:art:true")
val request = imageRequest(context, ref, crossfade = false)
assertEquals(ref.cacheKey, request.memoryCacheKey)
assertEquals(ref.cacheKey, request.diskCacheKey)
}
}
@@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import coil3.request.CachePolicy
import coil3.size.Dimension import coil3.size.Dimension
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -33,7 +34,7 @@ class NowPlayingArtworkTest {
val metrics = context.resources.displayMetrics val metrics = context.resources.displayMetrics
assertEquals(maxOf(metrics.widthPixels, metrics.heightPixels), width) assertEquals(maxOf(metrics.widthPixels, metrics.heightPixels), width)
assertEquals(ref.cacheKey, request.memoryCacheKey)
assertEquals(ref.cacheKey, request.diskCacheKey) assertEquals(ref.cacheKey, request.diskCacheKey)
assertEquals(CachePolicy.DISABLED, request.memoryCachePolicy)
} }
} }