loading: shimmer placeholders on every list screen

The placeholders only existed on the home rows; the shared list screens
(My videos, Search, Subscriptions, History, channel pages) still used a
full-screen spinner, and the Channels screen did too. All of them now
show card-shaped placeholders: the in-grid initial pass keeps the page
header and a two-row placeholder block, growing lists swap the always-
visible bottom spinner chip for a placeholder row driven by the new
loadingMore state on PagedListViewModel, and the channels screen shows
stub header bars plus placeholder rows while it starts up.

Closes #19
This commit is contained in:
Jeena 2026-10-14 00:53:26 +00:00
parent 4156704ffe
commit 52a0ba8fb9
4 changed files with 65 additions and 18 deletions

View file

@ -25,6 +25,10 @@ abstract class PagedListViewModel<T> : ViewModel() {
var loadingInitial by mutableStateOf(true)
private set
/** True while a follow-up page is being fetched — UI shows placeholders ( issue #19). */
var loadingMore by mutableStateOf(false)
private set
/**
* Show an instant (possibly stale) snapshot before the network load lands
* (issue #9): no spinner, content paints immediately and [refresh]-style
@ -57,6 +61,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return
inFlight = true
loadingMore = !force // the initial kick is a fresh list, not a "load more"
initialError = null
viewModelScope.launch {
try {
@ -78,6 +83,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
)
} finally {
inFlight = false
loadingMore = false
}
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
@ -340,9 +341,23 @@ fun ChannelsScreen(
when {
viewModel.loading && viewModel.userRows.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
) { androidx.compose.material3.CircularProgressIndicator() }
viewModel.loading && viewModel.userRows.isEmpty() ->
// Placeholder rows while the channels load (issue #19): header stub
// + a row of shimmer card-shaped placeholders, twice.
androidx.compose.foundation.layout.Column {
repeat(2) {
Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
Box(
Modifier
.width(180.dp)
.height(22.dp)
.clip(CircleShape)
.background(Color(0xFF232323)),
)
}
ShimmerRow()
}
}
viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {

View file

@ -41,6 +41,7 @@ import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.screens.list.ShimmerCard
import net.jeena.jtubetv.ui.screens.list.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */
@ -407,12 +408,10 @@ fun HomeRowsScreen(
/** Placeholder row of dummy cards (issue #19). */
@Composable
private fun ShimmerRow(size: Int = 4) {
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(
ShimmerCard(
modifier = Modifier
.padding(horizontal = 4.dp)
.width(rowCardWidth()),

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
@ -117,13 +118,7 @@ fun VideoListScreen(
}
}
if (viewModel.loadingInitial && items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
LaunchedEffect(source) { viewModel.loadMore(force = true) }
return
}
if (items.isEmpty()) {
Box(
@ -171,6 +166,30 @@ fun VideoListScreen(
else -> {}
}
}
if (items.isEmpty() && viewModel.loadingInitial) {
// Placeholder cards while the page's first chunk loads (issue #19):
// shaped like the real cards, with the faint shimmer, on every
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
LaunchedEffect(source) { viewModel.loadMore(force = true) }
androidx.compose.foundation.layout.Column {
repeat(2) {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard(
modifier = Modifier
.padding(6.dp)
.width(rowCardWidth()),
)
}
}
}
}
}
}
if (items.isEmpty() && !viewModel.loadingInitial && viewModel.initialError == null) {
return@LazyVerticalGrid
}
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video
@ -193,10 +212,18 @@ fun VideoListScreen(
LaunchedEffect(index) { viewModel.loadMore() }
}
}
if (!viewModel.exhausted) {
item {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
if (viewModel.loadingMore) {
// Growing the list: placeholder row (issue #19) instead of the
// permanent spinner chip that used to sit under every list.
item(key = "load-more-placeholders") {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard(
modifier = Modifier
.padding(6.dp)
.width(rowCardWidth()),
)
}
}
}
}