loading: placeholder cards with a faint shimmer; thumbnail while buffering

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.
This commit is contained in:
Jeena 2026-10-14 00:26:26 +00:00
parent b2601bb0c0
commit 05bcc55aac
3 changed files with 111 additions and 15 deletions

View file

@ -78,7 +78,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private var subsInFlight = false private var subsInFlight = false
var subsExhausted by mutableStateOf(false) var subsExhausted by mutableStateOf(false)
private set private set
private var subsLoading = false /** Exposed for the shimmer placeholders (issue #19). */
var subsLoading = false
private set
val recent = RecentVideosViewModel(repository) val recent = RecentVideosViewModel(repository)
@ -310,10 +312,14 @@ fun HomeRowsScreen(
) )
} }
item { 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)) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text( Text(
text = if (viewModel.continueLoading) "Loading…" else "Watch something first", text = "Watch something first",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A), color = Color(0xFF8A8A8A),
) )
@ -350,13 +356,7 @@ fun HomeRowsScreen(
item { item {
if (recentItems.isEmpty()) { if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
Row(Modifier.padding(start = 16.dp)) { ShimmerRow()
Text(
text = "Loading…",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
)
}
} else { } else {
FocusScrollRow( FocusScrollRow(
videos = recentItems, videos = recentItems,
@ -373,7 +373,7 @@ fun HomeRowsScreen(
} }
} }
} }
if (viewModel.subItems.isNotEmpty()) { if (viewModel.subItems.isNotEmpty() || viewModel.subsLoading) {
item { item {
Text( Text(
text = "New from your subscriptions", text = "New from your subscriptions",
@ -383,6 +383,9 @@ fun HomeRowsScreen(
) )
} }
item { item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
ShimmerRow()
} else {
FocusScrollRow( FocusScrollRow(
videos = viewModel.subItems, videos = viewModel.subItems,
progressOf = { null }, progressOf = { null },
@ -399,6 +402,23 @@ fun HomeRowsScreen(
} }
} }
} }
}
}
/** 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 * Horizontal card row with eased scrolling: when focus moves to a card outside the

View file

@ -554,9 +554,10 @@ private fun PlayerSurface(
.fillMaxSize() .fillMaxSize()
.padding(end = if (sidebar != null) SIDEBAR_WIDTH else 0.dp), .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 // 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) { if (buffering) {
Box( Box(
Modifier Modifier
@ -564,7 +565,18 @@ private fun PlayerSurface(
.background(Color.Black) .background(Color.Black)
.pointerInput(Unit) {}, // swallow taps/clicks while covering .pointerInput(Unit) {}, // swallow taps/clicks while covering
contentAlignment = Alignment.Center, 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 + // Controls: SmartTube-style (title + channel above, then the seek bar +
// action row). Persistent when paused, transient while playing. // action row). Persistent when paused, transient while playing.

View file

@ -4,6 +4,17 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@ -267,3 +278,56 @@ internal fun formatAge(iso: String?): String {
"" ""
} }
} }
/**
* 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),
),
)
}