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:
parent
b2601bb0c0
commit
05bcc55aac
3 changed files with 111 additions and 15 deletions
|
|
@ -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 },
|
||||||
|
|
@ -396,10 +399,27 @@ fun HomeRowsScreen(
|
||||||
LaunchedEffect(index) { viewModel.subsMore() }
|
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
|
* 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
|
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue