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.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeena 2026-10-19 13:08:09 +00:00
parent afb3a0c871
commit 96c53c6c89
2 changed files with 33 additions and 15 deletions

View file

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

View file

@ -124,11 +124,18 @@ fun VideoCard(
.background(Color(0xFF222222)),
) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
// search-index results carry an absolute URL for their own instance
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build()
// 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
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build()
}
ShimmerAsyncImage(
model = req,
contentDescription = video.name,