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) var loadingInitial by mutableStateOf(true)
private set 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 * Show an instant (possibly stale) snapshot before the network load lands
* (issue #9): no spinner, content paints immediately and [refresh]-style * (issue #9): no spinner, content paints immediately and [refresh]-style
@ -57,6 +61,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
fun loadMore(force: Boolean = false) { fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return if (inFlight || (!force && exhausted)) return
inFlight = true inFlight = true
loadingMore = !force // the initial kick is a fresh list, not a "load more"
initialError = null initialError = null
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -78,6 +83,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
) )
} finally { } finally {
inFlight = false 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -340,9 +341,23 @@ fun ChannelsScreen(
when { when {
viewModel.loading && viewModel.userRows.isEmpty() -> Box( viewModel.loading && viewModel.userRows.isEmpty() ->
Modifier.fillMaxSize(), contentAlignment = Alignment.Center, // Placeholder rows while the channels load (issue #19): header stub
) { androidx.compose.material3.CircularProgressIndicator() } // + 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() -> viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { 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.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.screens.list.ShimmerCard
import net.jeena.jtubetv.ui.screens.list.VideoCard import net.jeena.jtubetv.ui.screens.list.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ /** 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). */ /** Placeholder row of dummy cards (issue #19). */
@Composable @Composable
private fun ShimmerRow(size: Int = 4) { fun ShimmerRow(size: Int = 4) {
Row(Modifier.padding(horizontal = 12.dp)) { Row(Modifier.padding(horizontal = 12.dp)) {
androidx.compose.foundation.layout.Row {
}
repeat(size) { repeat(size) {
net.jeena.jtubetv.ui.screens.list.ShimmerCard( ShimmerCard(
modifier = Modifier modifier = Modifier
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.width(rowCardWidth()), .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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells 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()) { if (items.isEmpty()) {
Box( Box(
@ -171,6 +166,30 @@ fun VideoListScreen(
else -> {} 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 -> itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) { val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.HistoryEntry -> item.history.video
@ -193,10 +212,18 @@ fun VideoListScreen(
LaunchedEffect(index) { viewModel.loadMore() } LaunchedEffect(index) { viewModel.loadMore() }
} }
} }
if (!viewModel.exhausted) { if (viewModel.loadingMore) {
item { // Growing the list: placeholder row (issue #19) instead of the
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { // permanent spinner chip that used to sit under every list.
CircularProgressIndicator() item(key = "load-more-placeholders") {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard(
modifier = Modifier
.padding(6.dp)
.width(rowCardWidth()),
)
}
} }
} }
} }