From 4176edfd6998fbca67117406076b91a088445ef5 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 15 Oct 2026 12:23:03 +0000 Subject: [PATCH] loading: drop the snapshot cache, one loading card per thing (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 --- .../jtubetv/ui/list/VideoListViewModel.kt | 60 +----------- .../jtubetv/ui/screens/ChannelsScreen.kt | 32 +------ .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 91 +++++++------------ .../ui/screens/list/VideoListScreen.kt | 21 ++--- 4 files changed, 48 insertions(+), 156 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 3a10e30..2c811d3 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository -import net.jeena.jtubetv.ui.list.Snapshots /** Where the list's content comes from. */ sealed interface ListSource { @@ -56,16 +55,6 @@ class VideoListViewModel( var videoTotal by androidx.compose.runtime.mutableStateOf(0L) 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 { // Probe whether the instance allows the search-index ("Vidiverse") scope. if (source is ListSource.Search) { @@ -74,24 +63,10 @@ class VideoListViewModel( repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true } } - val context = AppGraph.appContext - if (context != null && !shouldSkipSnapshot) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch - 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 - } - } - } - } - } + // No snapshot instant-paint any more (issue #25): the saved-list cache + // repainted stale rows that then jumped when the fresh page landed with + // newer videos. Screens now show the loading card until the first page + // arrives; images stay fast via Coil's own disk cache. } override fun onChunkLoaded(chunk: List) { @@ -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().take(50).map { it.video }, - ) - } - } - } } override suspend fun fetch(start: Long, count: Int): Result> { @@ -150,22 +111,9 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source 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 { repository.videoChannel(s.handle).getOrNull()?.let { header -> channelHeader = header - val ctx = AppGraph.appContext - if (ctx != null) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelHeader(ctx, s.handle, header) - } - } } } } 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 603a561..4cd1c2d 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 @@ -94,10 +94,6 @@ data class UserGroup( 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(null) private set var userChannels by mutableStateOf>(emptyList()) @@ -127,20 +123,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { /** SystemClock at the last successful fast-path pass — re-entry freshness. */ private var lastLoadedAt = 0L - init { - // Snapshot restore (issue #9): paint the last session's channel rows - // instantly, including the per-channel stats (the "minutes of loading" - // 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() - } - } - + // No snapshot instant-paint any more (issue #25): the Channels screen shows + // its shimmer placeholder rows until the (fast-path, few-request) load lands, + // instead of painting a stale snapshot that reshuffled as stats streamed in. fun load(force: Boolean = false) { // Re-entry (destination switch away + back) inside the freshness window: @@ -225,11 +210,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { stats = statsMap.toMap() rebuildRows(host) lastLoadedAt = android.os.SystemClock.elapsedRealtime() - snapshotContext?.let { ctx -> - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) - } - } 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 { 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( "JTube", "order now: " + userRows.take(6).joinToString(" | ") { g -> 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 83007c0..7ebda20 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,7 +41,6 @@ import net.jeena.jtubetv.AppGraph 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 @@ -58,8 +57,6 @@ data class ContinueItem(val video: VideoDto, val progress: Float?) class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { - private fun contextOrNull() = AppGraph.appContext - var continueItems by mutableStateOf>(emptyList()) private set var continueLoading by mutableStateOf(true) @@ -76,36 +73,24 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private var continueInFlight = false var continueExhausted by mutableStateOf(false) 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 subsInFlight = false var subsExhausted by mutableStateOf(false) private set + var subsLoadingMore by mutableStateOf(false) + private set /** Exposed for the shimmer placeholders (issue #19). */ var subsLoading = false private set val recent = RecentVideosViewModel(repository) - init { - // Snapshot restore (issue #9): Home paints from the last session's rows - // immediately; the loads below freshen them from the network. - 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 - } - } - } - } - } + // No snapshot instant-paint any more (issue #25): Home shows one loading card + // per row until the network pages land, instead of painting a stale snapshot + // that then jumped as newer videos arrived. private fun fetchSubs() { viewModelScope.launch { @@ -116,12 +101,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( subItems = subs subsLoading = false 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() { if (continueInFlight || continueExhausted || continueItems.isEmpty()) return continueInFlight = true + continueLoadingMore = true viewModelScope.launch { val page = fetchContinuePage() 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 }) } continueInFlight = false + continueLoadingMore = false } } @@ -191,6 +172,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( fun subsMore() { if (subsInFlight || subsExhausted || subItems.isEmpty()) return subsInFlight = true + subsLoadingMore = true viewModelScope.launch { val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList() subsStart += page.size @@ -200,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page) } subsInFlight = false + subsLoadingMore = false } } @@ -211,12 +194,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( continueItems = items continueLoading = false 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. */ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { - // 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) { 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> = @@ -315,9 +272,10 @@ fun HomeRowsScreen( } item { 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. - ShimmerRow() + ShimmerRow(size = 1) } else if (viewModel.continueItems.isEmpty()) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Text( @@ -339,6 +297,7 @@ fun HomeRowsScreen( autoFocusOnLoad = true, autoFocusAllowed = { !userNavigated }, refocusPulse = drawerPulse, + trailingLoading = viewModel.continueLoadingMore, ) { index, video -> // Grow the row on demand (issue #18), same hook as the recent row. if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) { @@ -358,7 +317,7 @@ fun HomeRowsScreen( item { if (recentItems.isEmpty()) { LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } - ShimmerRow() + ShimmerRow(size = 1) } else { FocusScrollRow( videos = recentItems, @@ -368,6 +327,7 @@ fun HomeRowsScreen( clickedIndex = recentRestore, restoreToken = restoreToken, refocusPulse = drawerPulse, + trailingLoading = viewModel.recent.loadingMore, ) { index, video -> if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } @@ -386,7 +346,7 @@ fun HomeRowsScreen( } item { if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { - ShimmerRow() + ShimmerRow(size = 1) } else { FocusScrollRow( videos = viewModel.subItems, @@ -396,6 +356,7 @@ fun HomeRowsScreen( clickedIndex = subRestore, restoreToken = restoreToken, refocusPulse = drawerPulse, + trailingLoading = viewModel.subsLoadingMore, ) { index, video -> if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.subsMore() } @@ -444,6 +405,9 @@ fun FocusScrollRow( // NOTE: stays before [afterItem] — the trailing lambda binds to the last // declared parameter. 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 = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -518,5 +482,14 @@ fun FocusScrollRow( ) afterItem(index, video) } + if (trailingLoading) { + item(key = "trailing-loading") { + 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 571e02f..6e9f037 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 @@ -239,21 +239,18 @@ fun VideoListScreen( } } 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()), - ) - } - } + // Growing the list: a single loading card at the end (#25) so it's + // clear more is coming, without faking a whole row of content. + item(key = "load-more-placeholder") { + ShimmerCard( + modifier = Modifier + .padding(6.dp) + .width(rowCardWidth() - 12.dp), + ) } } } + } } @Composable