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:
parent
4156704ffe
commit
52a0ba8fb9
4 changed files with 65 additions and 18 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue