images: rearm the loading shimmer only when the image changes

ShimmerAsyncImage keyed its loaded-state on the passed model, and the
callers built a fresh coil ImageRequest on every recomposition. ImageRequest
has no value equality, so the key changed every time: the loaded flag
reset, the shimmer overlay was re-added and the image load restarted with
each recomposition — an infinite animation per card that never disposed.
On the TV this showed as a general fps collapse wherever thumbnails
render (issue #28's feature shipped with this trap).

The requests are now remembered under stable keys (video/channel id and
the URL strings), so the sweep stops for real once the image (or an
error) lands.

Addresses #28.
This commit is contained in:
Jeena 2026-10-19 13:08:09 +00:00
parent f371a21e17
commit 54dfe68726
2 changed files with 33 additions and 15 deletions

View file

@ -574,13 +574,19 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) } val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl ?: stats?.latestThumbUrl
if (coverUrl != null) { if (coverUrl != null) {
ShimmerAsyncImage( // The request must be remembered: ImageRequest has no value equality,
model = coil.request.ImageRequest.Builder( // a fresh request per recomposition reset the shimmer (and retried
androidx.compose.ui.platform.LocalContext.current, // the load) every frame — the fps regression on the TV.
) val ctx = androidx.compose.ui.platform.LocalContext.current
val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host }
val coverReq = remember(coverKey, coverUrl) {
coil.request.ImageRequest.Builder(ctx)
.data(coverUrl) .data(coverUrl)
.size(480, 270) // same key as prefetch .size(480, 270) // same key as prefetch
.build(), .build()
}
ShimmerAsyncImage(
model = coverReq,
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
@ -588,13 +594,18 @@ fun ChannelCard(
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
ShimmerAsyncImage( val ctx = androidx.compose.ui.platform.LocalContext.current
model = coil.request.ImageRequest.Builder( val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
androidx.compose.ui.platform.LocalContext.current, val avatarReq = remember(avatarUrl) {
coil.request.ImageRequest.Builder(
ctx,
) )
.data(absoluteAvatar(avatar)) .data(avatarUrl)
.size(80, 80) .size(80, 80)
.build(), .build()
}
ShimmerAsyncImage(
model = avatarReq,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333), base = Color(0xFF333333),

View file

@ -124,11 +124,18 @@ fun VideoCard(
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) // The request object must be remembered: ImageRequest has no value
// equality, and a fresh request per recomposition reset the
// shimmer's loaded state (and retried the load) on every frame —
// the fps regression on the TV.
val ctx = androidx.compose.ui.platform.LocalContext.current
val req = remember(video.id, video.thumbnailUrl) {
coil.request.ImageRequest.Builder(ctx)
// search-index results carry an absolute URL for their own instance // search-index results carry an absolute URL for their own instance
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path)) .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build() .build()
}
ShimmerAsyncImage( ShimmerAsyncImage(
model = req, model = req,
contentDescription = video.name, contentDescription = video.name,