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(