From 52a0ba8fb9c8a1ab48839911a0b4c9c8733892f4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 14 Oct 2026 00:53:26 +0000 Subject: [PATCH] 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 --- .../jtubetv/ui/list/PagedListViewModel.kt | 6 +++ .../jtubetv/ui/screens/ChannelsScreen.kt | 21 ++++++-- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 7 ++- .../ui/screens/list/VideoListScreen.kt | 49 ++++++++++++++----- 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt index 7a17eb7..8f2fff8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -25,6 +25,10 @@ abstract class PagedListViewModel : 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 : 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 : ViewModel() { ) } finally { inFlight = false + loadingMore = false } } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 0b50bae..203fbf7 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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) { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 56aa2de..bca62ff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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()), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 1310324..28ce36e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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()), + ) + } } } }