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.
This commit is contained in:
Jeena 2026-10-15 12:23:20 +00:00
parent aea649eab9
commit b2af6fc1a5
2 changed files with 48 additions and 6 deletions

View file

@ -64,9 +64,9 @@ import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoDto 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.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape 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.UnfocusedCardBg
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
@ -531,7 +531,7 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) } val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl ?: stats?.latestThumbUrl
if (coverUrl != null) { if (coverUrl != null) {
AsyncImage( ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
@ -545,7 +545,7 @@ fun ChannelCard(
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
AsyncImage( ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
@ -554,12 +554,12 @@ fun ChannelCard(
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(8.dp) .padding(8.dp)
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape),
.background(Color(0xFF333333)),
) )
} }
} }

View file

@ -129,7 +129,7 @@ fun VideoCard(
.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()
AsyncImage( ShimmerAsyncImage(
model = req, model = req,
contentDescription = video.name, contentDescription = video.name,
contentScale = ContentScale.Crop, 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. */ /** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */
fun Modifier.shimmer(progress: Float): Modifier = drawBehind { fun Modifier.shimmer(progress: Float): Modifier = drawBehind {
drawRect( drawRect(