From 05bcc55aac27802edba29508fdcb0b45d5bc719c Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 14 Oct 2026 00:26:26 +0000 Subject: [PATCH] loading: placeholder cards with a faint shimmer; thumbnail while buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows that were still loading kept silent or a bare "Loading…" line. Every list row (continue, new videos, subscriptions) now shows dummy cards shaped like the real thing — gray rectangles with a very faint sweeping sheen — because that says what is coming and that it is moving; the spinner only survives where there is nothing to shape. Fullscreen video loading shows the video's own thumbnail dimmed under the circle loader instead of black, at least until the detail response has the thumbnail; before that only the circle exists (issue #19). The shimmer is a linear sheen at 8% alpha looping over 1.6s on a near-black base, subtle per the report. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 42 ++++++++---- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 18 ++++- .../jtubetv/ui/screens/list/VideoCard.kt | 66 ++++++++++++++++++- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index b397537..56aa2de 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -78,7 +78,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private var subsInFlight = false var subsExhausted by mutableStateOf(false) private set - private var subsLoading = false + /** Exposed for the shimmer placeholders (issue #19). */ + var subsLoading = false + private set val recent = RecentVideosViewModel(repository) @@ -310,10 +312,14 @@ fun HomeRowsScreen( ) } item { - if (viewModel.continueItems.isEmpty()) { + if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) { + // Dummy cards with the faint shimmer while the row loads (#19); + // "Watch something first" only when there is genuinely no history. + ShimmerRow() + } else if (viewModel.continueItems.isEmpty()) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Text( - text = if (viewModel.continueLoading) "Loading…" else "Watch something first", + text = "Watch something first", style = MaterialTheme.typography.bodySmall, color = Color(0xFF8A8A8A), ) @@ -350,13 +356,7 @@ fun HomeRowsScreen( item { if (recentItems.isEmpty()) { LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } - Row(Modifier.padding(start = 16.dp)) { - Text( - text = "Loading…", - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), - ) - } + ShimmerRow() } else { FocusScrollRow( videos = recentItems, @@ -373,7 +373,7 @@ fun HomeRowsScreen( } } } - if (viewModel.subItems.isNotEmpty()) { + if (viewModel.subItems.isNotEmpty() || viewModel.subsLoading) { item { Text( text = "New from your subscriptions", @@ -383,6 +383,9 @@ fun HomeRowsScreen( ) } item { + if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { + ShimmerRow() + } else { FocusScrollRow( videos = viewModel.subItems, progressOf = { null }, @@ -396,10 +399,27 @@ fun HomeRowsScreen( LaunchedEffect(index) { viewModel.subsMore() } } } + } } } } } + +/** Placeholder row of dummy cards (issue #19). */ +@Composable +private fun ShimmerRow(size: Int = 4) { + Row(Modifier.padding(horizontal = 12.dp)) { + androidx.compose.foundation.layout.Row { + } + repeat(size) { + net.jeena.jtubetv.ui.screens.list.ShimmerCard( + modifier = Modifier + .padding(horizontal = 4.dp) + .width(rowCardWidth()), + ) + } + } +} /** * Horizontal card row with eased scrolling: when focus moves to a card outside the * viewport, the row animates it to the left edge (SmartTube behaviour) instead of diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index a080bed..a8e6593 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -554,9 +554,10 @@ private fun PlayerSurface( .fillMaxSize() .padding(end = if (sidebar != null) SIDEBAR_WIDTH else 0.dp), ) - // Cover the picture with the loading indicator until the player is really + // Cover the picture with a loading state until the player is really // ready to render — metadata resolution alone (the Loading state) is not - // enough (issue #8). + // enough (issue #8). Fullscreen loading shows the video's own thumbnail + // dimmed under the circle loader (issue #19) instead of plain black. if (buffering) { Box( Modifier @@ -564,7 +565,18 @@ private fun PlayerSurface( .background(Color.Black) .pointerInput(Unit) {}, // swallow taps/clicks while covering contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } + ) { + s.detail.thumbnailPath?.let { thumbPath -> + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(thumbPath), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box(Modifier.fillMaxSize().background(Color(0xB3000000))) + } + CircularProgressIndicator() + } } // Controls: SmartTube-style (title + channel above, then the seek bar + // action row). Persistent when paused, transient while playing. 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 0675655..ccd3802 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 @@ -4,6 +4,17 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.foundation.layout.size import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.drawBehind import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio @@ -266,4 +277,57 @@ internal fun formatAge(iso: String?): String { } catch (_: DateTimeParseException) { "" } -} \ No newline at end of file +} +/** + * Dummy card while a row/grid loads: gray rectangles in the card's shape with + * a very faint sweeping sheen instead of a spinner (issue #19 — the spinner + * says nothing about what's coming). + */ +@Composable +fun ShimmerCard(modifier: Modifier = Modifier) { + val sweep = rememberInfiniteTransition() + val x by sweep.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)), + ) + val base = Color(0xFF232323) + Column(modifier) { + Box( + Modifier + .fillMaxWidth() + .height(158.dp) + .background(base, RoundedCornerShape(8.dp)) + .shimmer(x), + ) + Box( + Modifier + .padding(top = 10.dp) + .height(13.dp) + .fillMaxWidth(0.85f) + .background(base, RoundedCornerShape(4.dp)) + .shimmer(x), + ) + Box( + Modifier + .padding(top = 6.dp) + .height(10.dp) + .fillMaxWidth(0.55f) + .background(base, RoundedCornerShape(4.dp)) + .shimmer(x), + ) + } +} + +/** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ +fun Modifier.shimmer(progress: Float): Modifier = drawBehind { + drawRect( + Brush.linearGradient( + 0f to Color.Transparent, + 0.5f to Color(0x14FFFFFF), + 1f to Color.Transparent, + start = Offset(size.width * (progress * 2f - 1f), size.height), + end = Offset(size.width * (progress * 2f), 0f), + ), + ) +}