From 84a0527e20ef6e084e588f98cfabc5cea4185ef4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 15 Oct 2026 12:23:20 +0000 Subject: [PATCH] images: shimmer placeholder while thumbnails and art load (#28) Thumbnails, channel banners and avatars popped in from a flat grey rectangle. A new ShimmerAsyncImage shows the same faint shimmer as the card placeholders while the image loads, and the sweep disposes once the image (or an error) arrives. Used for video thumbnails and the channel banners/avatars. Co-Authored-By: Claude Opus 4.8 --- .../jtubetv/ui/screens/ChannelsScreen.kt | 10 ++--- .../jtubetv/ui/screens/list/VideoCard.kt | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 6 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 4cd1c2d..131a64d 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 @@ -64,9 +64,9 @@ import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoDto -import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape +import net.jeena.jtubetv.ui.screens.list.ShimmerAsyncImage import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable @@ -531,7 +531,7 @@ fun ChannelCard( val coverUrl = banner?.let { absoluteBanner(it) } ?: stats?.latestThumbUrl if (coverUrl != null) { - AsyncImage( + ShimmerAsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -545,7 +545,7 @@ fun ChannelCard( } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { - AsyncImage( + ShimmerAsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -554,12 +554,12 @@ fun ChannelCard( .build(), contentDescription = null, contentScale = ContentScale.Crop, + base = Color(0xFF333333), modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) .size(40.dp) - .clip(CircleShape) - .background(Color(0xFF333333)), + .clip(CircleShape), ) } } 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 d80fa37..0a1c3a2 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 @@ -129,7 +129,7 @@ fun VideoCard( .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path)) .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs .build() - AsyncImage( + ShimmerAsyncImage( model = req, contentDescription = video.name, contentScale = ContentScale.Crop, @@ -320,6 +320,48 @@ fun ShimmerCard(modifier: Modifier = Modifier) { } } +/** + * Coil image that shows the same faint shimmer as [ShimmerCard] while it loads + * (issue #28) — thumbnails, channel banners and avatars no longer pop in from a + * flat grey rectangle. The sweep runs only while loading: once the image (or an + * error) arrives, the overlay leaves composition and its animation disposes. + */ +@Composable +fun ShimmerAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + base: Color = Color(0xFF222222), +) { + var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) } + Box(modifier.background(base)) { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier.matchParentSize(), + onState = { state -> + loaded = state is coil.compose.AsyncImagePainter.State.Success || + state is coil.compose.AsyncImagePainter.State.Error + }, + ) + if (!loaded) ShimmerFill(Modifier.matchParentSize(), base) + } +} + +/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */ +@Composable +private fun ShimmerFill(modifier: Modifier, base: Color) { + val sweep = rememberInfiniteTransition() + val x by sweep.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)), + ) + Box(modifier.background(base).shimmer(x)) +} + /** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ fun Modifier.shimmer(progress: Float): Modifier = drawBehind { drawRect(