loading: drop the snapshot cache, one loading card per thing (#25)

The saved-list snapshot (issue #9) painted the last session's rows instantly,
then the network refresh swapped them in and the tiles jumped as newer videos
appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and
the Channels screen; images stay fast because Coil already disk-caches them.

Screens now show the loading card while the first page loads instead of stale
content: one card per Home row (not a fake full row), a single card at the end
of a growing grid, and a trailing card when a Home row loads its next page.
This commit is contained in:
Jeena 2026-10-15 12:23:03 +00:00
parent 8eeffe834d
commit aea649eab9
4 changed files with 48 additions and 156 deletions

View file

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.ui.list.Snapshots
/** Where the list's content comes from. */ /** Where the list's content comes from. */
sealed interface ListSource { sealed interface ListSource {
@ -56,16 +55,6 @@ class VideoListViewModel(
var videoTotal by androidx.compose.runtime.mutableStateOf(0L) var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
private set private set
// Snapshot caching (issue #9): live content lists only — Search results and
// History rows (which carry extra watch metadata) must not persist.
private val shouldSkipSnapshot =
source is ListSource.Search || source is ListSource.History
private fun cacheKey(): String = when (val s = source) {
is ListSource.Channel -> "channel-" + s.handle
else -> s::class.java.simpleName
}
init { init {
// Probe whether the instance allows the search-index ("Vidiverse") scope. // Probe whether the instance allows the search-index ("Vidiverse") scope.
if (source is ListSource.Search) { if (source is ListSource.Search) {
@ -74,24 +63,10 @@ class VideoListViewModel(
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
} }
} }
val context = AppGraph.appContext // No snapshot instant-paint any more (issue #25): the saved-list cache
if (context != null && !shouldSkipSnapshot) { // repainted stale rows that then jumped when the fresh page landed with
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { // newer videos. Screens now show the loading card until the first page
val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch // arrives; images stay fast via Coil's own disk cache.
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
showInstant(videos.map { VideoListItem.Video(it) })
}
// Channel page banner: restore the saved one instantly too.
val s = source
if (s is ListSource.Channel) {
Snapshots.loadChannelHeader(context, s.handle)?.let { header ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
channelHeader = header
}
}
}
}
}
} }
override fun onChunkLoaded(chunk: List<VideoListItem>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
@ -103,20 +78,6 @@ class VideoListViewModel(
} }
} }
) )
// Persist the freshly loaded content so the next app start paints
// instantly (issue #9).
if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) {
val context = AppGraph.appContext
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(
context,
cacheKey(),
items.filterIsInstance<VideoListItem.Video>().take(50).map { it.video },
)
}
}
}
} }
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> { override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
@ -150,22 +111,9 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) { fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source val s = source
if (s is ListSource.Channel) { if (s is ListSource.Channel) {
// Instant restore from the snapshot, then a fresh fetch (issue #9).
val context = AppGraph.appContext
if (context != null) {
Snapshots.loadChannelHeader(context, s.handle)?.let { snap ->
if (channelHeader == null) channelHeader = snap
}
}
viewModelScope.launch { viewModelScope.launch {
repository.videoChannel(s.handle).getOrNull()?.let { header -> repository.videoChannel(s.handle).getOrNull()?.let { header ->
channelHeader = header channelHeader = header
val ctx = AppGraph.appContext
if (ctx != null) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelHeader(ctx, s.handle, header)
}
}
} }
} }
} }

View file

@ -94,10 +94,6 @@ data class UserGroup(
class ChannelsViewModel(private val store: TokenStore) : ViewModel() { class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private val snapshotContext = AppGraph.appContext
private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_")
private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) }
var user by mutableStateOf<UserDto?>(null) var user by mutableStateOf<UserDto?>(null)
private set private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList()) var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
@ -127,20 +123,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
/** SystemClock at the last successful fast-path pass — re-entry freshness. */ /** SystemClock at the last successful fast-path pass — re-entry freshness. */
private var lastLoadedAt = 0L private var lastLoadedAt = 0L
init { // No snapshot instant-paint any more (issue #25): the Channels screen shows
// Snapshot restore (issue #9): paint the last session's channel rows // its shimmer placeholder rows until the (fast-path, few-request) load lands,
// instantly, including the per-channel stats (the "minutes of loading" // instead of painting a stale snapshot that reshuffled as stats streamed in.
// complaint) — the streaming reload below then updates them in place.
if (snapshotRows != null) {
userRows = snapshotRows
loading = false
}
if (snapshotContext != null) {
statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey))
if (statsMap.isNotEmpty()) stats = statsMap.toMap()
}
}
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window: // Re-entry (destination switch away + back) inside the freshness window:
@ -225,11 +210,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
stats = statsMap.toMap() stats = statsMap.toMap()
rebuildRows(host) rebuildRows(host)
lastLoadedAt = android.os.SystemClock.elapsedRealtime() lastLoadedAt = android.os.SystemClock.elapsedRealtime()
snapshotContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
}
}
ImagePrefetcher.channelArt(allChannels + userChannels) ImagePrefetcher.channelArt(allChannels + userChannels)
} }
@ -303,12 +283,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { it.followersCount } }, .thenByDescending { g -> g.channels.sumOf { it.followersCount } },
) )
// Persist for the next cold start (issue #9).
snapshotContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveUserGroups(ctx, snapshotKey, userRows)
}
}
Log.w( Log.w(
"JTube", "JTube",
"order now: " + userRows.take(6).joinToString(" | ") { g -> "order now: " + userRows.take(6).joinToString(" | ") { g ->

View file

@ -41,7 +41,6 @@ import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository 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.screens.list.ShimmerCard import net.jeena.jtubetv.ui.screens.list.ShimmerCard
import net.jeena.jtubetv.ui.screens.list.VideoCard import net.jeena.jtubetv.ui.screens.list.VideoCard
@ -58,8 +57,6 @@ data class ContinueItem(val video: VideoDto, val progress: Float?)
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
private fun contextOrNull() = AppGraph.appContext
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList()) var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
private set private set
var continueLoading by mutableStateOf(true) var continueLoading by mutableStateOf(true)
@ -76,36 +73,24 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private var continueInFlight = false private var continueInFlight = false
var continueExhausted by mutableStateOf(false) var continueExhausted by mutableStateOf(false)
private set private set
/** True while the next continue page is loading — drives a trailing loading card (#25). */
var continueLoadingMore by mutableStateOf(false)
private set
private var subsStart = 0L private var subsStart = 0L
private var subsInFlight = false private var subsInFlight = false
var subsExhausted by mutableStateOf(false) var subsExhausted by mutableStateOf(false)
private set private set
var subsLoadingMore by mutableStateOf(false)
private set
/** Exposed for the shimmer placeholders (issue #19). */ /** Exposed for the shimmer placeholders (issue #19). */
var subsLoading = false var subsLoading = false
private set private set
val recent = RecentVideosViewModel(repository) val recent = RecentVideosViewModel(repository)
init { // No snapshot instant-paint any more (issue #25): Home shows one loading card
// Snapshot restore (issue #9): Home paints from the last session's rows // per row until the network pages land, instead of painting a stale snapshot
// immediately; the loads below freshen them from the network. // that then jumped as newer videos arrived.
val context = contextOrNull()
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val savedContinue = Snapshots.loadContinue(context, "home-continue")
val savedSubs = Snapshots.loadVideos(context, "home-subs")
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
if (continueItems.isEmpty() && savedContinue != null) {
continueItems = savedContinue
continueLoading = false
}
if (subItems.isEmpty() && savedSubs != null) {
subItems = savedSubs
}
}
}
}
}
private fun fetchSubs() { private fun fetchSubs() {
viewModelScope.launch { viewModelScope.launch {
@ -116,12 +101,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
subItems = subs subItems = subs
subsLoading = false subsLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
// Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(ctx, "home-subs", subs)
}
}
} }
} }
@ -177,6 +156,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun continueMore() { fun continueMore() {
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
continueInFlight = true continueInFlight = true
continueLoadingMore = true
viewModelScope.launch { viewModelScope.launch {
val page = fetchContinuePage() val page = fetchContinuePage()
if (page.isNotEmpty()) { if (page.isNotEmpty()) {
@ -184,6 +164,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video }) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
} }
continueInFlight = false continueInFlight = false
continueLoadingMore = false
} }
} }
@ -191,6 +172,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun subsMore() { fun subsMore() {
if (subsInFlight || subsExhausted || subItems.isEmpty()) return if (subsInFlight || subsExhausted || subItems.isEmpty()) return
subsInFlight = true subsInFlight = true
subsLoadingMore = true
viewModelScope.launch { viewModelScope.launch {
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList() val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
subsStart += page.size subsStart += page.size
@ -200,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
} }
subsInFlight = false subsInFlight = false
subsLoadingMore = false
} }
} }
@ -211,12 +194,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
continueItems = items continueItems = items
continueLoading = false continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
// Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveContinue(ctx, "home-continue", items)
}
}
} }
} }
@ -236,28 +213,8 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
/** Paged "New videos" row content. */ /** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() { class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
// Instant restore from the last session (issue #9); showInstant() refreshes
// the first page in the background.
init {
val context = AppGraph.appContext
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.loadVideos(context, "RecentVideos")?.let { snapshot ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
showInstant(snapshot)
}
}
}
}
}
override fun onChunkLoaded(chunk: List<VideoDto>) { override fun onChunkLoaded(chunk: List<VideoDto>) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
AppGraph.appContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(ctx, "RecentVideos", items.take(50))
}
}
} }
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> = override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
@ -315,9 +272,10 @@ fun HomeRowsScreen(
} }
item { item {
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) { if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
// Dummy cards with the faint shimmer while the row loads (#19); // One placeholder card per row while it loads (#25): a single card
// reads as "this row is coming", not a fake full row of content.
// "Watch something first" only when there is genuinely no history. // "Watch something first" only when there is genuinely no history.
ShimmerRow() ShimmerRow(size = 1)
} else if (viewModel.continueItems.isEmpty()) { } else if (viewModel.continueItems.isEmpty()) {
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text( Text(
@ -339,6 +297,7 @@ fun HomeRowsScreen(
autoFocusOnLoad = true, autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated }, autoFocusAllowed = { !userNavigated },
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.continueLoadingMore,
) { index, video -> ) { index, video ->
// Grow the row on demand (issue #18), same hook as the recent row. // Grow the row on demand (issue #18), same hook as the recent row.
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
@ -358,7 +317,7 @@ fun HomeRowsScreen(
item { item {
if (recentItems.isEmpty()) { if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
ShimmerRow() ShimmerRow(size = 1)
} else { } else {
FocusScrollRow( FocusScrollRow(
videos = recentItems, videos = recentItems,
@ -368,6 +327,7 @@ fun HomeRowsScreen(
clickedIndex = recentRestore, clickedIndex = recentRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.recent.loadingMore,
) { index, video -> ) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() } LaunchedEffect(index) { viewModel.recent.loadMore() }
@ -386,7 +346,7 @@ fun HomeRowsScreen(
} }
item { item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
ShimmerRow() ShimmerRow(size = 1)
} else { } else {
FocusScrollRow( FocusScrollRow(
videos = viewModel.subItems, videos = viewModel.subItems,
@ -396,6 +356,7 @@ fun HomeRowsScreen(
clickedIndex = subRestore, clickedIndex = subRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.subsLoadingMore,
) { index, video -> ) { index, video ->
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.subsMore() } LaunchedEffect(index) { viewModel.subsMore() }
@ -444,6 +405,9 @@ fun FocusScrollRow(
// NOTE: stays before [afterItem] — the trailing lambda binds to the last // NOTE: stays before [afterItem] — the trailing lambda binds to the last
// declared parameter. // declared parameter.
refocusPulse: Int = 0, refocusPulse: Int = 0,
// A trailing loading card at the row's end while the next page loads (#25),
// so it's clear more is coming when scrolling right.
trailingLoading: Boolean = false,
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
@ -518,5 +482,14 @@ fun FocusScrollRow(
) )
afterItem(index, video) afterItem(index, video)
} }
if (trailingLoading) {
item(key = "trailing-loading") {
ShimmerCard(
modifier = Modifier
.padding(horizontal = 4.dp)
.width(rowCardWidth()),
)
}
}
} }
} }

View file

@ -239,22 +239,19 @@ fun VideoListScreen(
} }
} }
if (viewModel.loadingMore) { if (viewModel.loadingMore) {
// Growing the list: placeholder row (issue #19) instead of the // Growing the list: a single loading card at the end (#25) so it's
// permanent spinner chip that used to sit under every list. // clear more is coming, without faking a whole row of content.
item(key = "load-more-placeholders") { item(key = "load-more-placeholder") {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard( ShimmerCard(
modifier = Modifier modifier = Modifier
.padding(6.dp) .padding(6.dp)
.width(rowCardWidth()), .width(rowCardWidth() - 12.dp),
) )
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) { private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {