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:
parent
8eeffe834d
commit
aea649eab9
4 changed files with 48 additions and 156 deletions
|
|
@ -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<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>> {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserDto?>(null)
|
||||
private set
|
||||
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. */
|
||||
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 ->
|
||||
|
|
|
|||
|
|
@ -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<List<ContinueItem>>(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<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>) {
|
||||
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>> =
|
||||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue