From 54dfe68726012636a77e5bbcffc9c28e96746fa2 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 19 Oct 2026 13:08:09 +0000 Subject: [PATCH] images: rearm the loading shimmer only when the image changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 31 +++++++++++++------ .../jtubetv/ui/screens/list/VideoCard.kt | 17 +++++++--- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 4207968..6dbba4c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 0a1c3a2..e910f5b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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,