cache: Show the last session's lists instantly at cold start

Opening the app (especially the Channels screen) waited for the API and
then for every image before anything painted. JSON list snapshots now
live in cacheDir: on the next start the last loaded page paints
immediately, then the load that runs anyway silently replaces the first
page with fresh data, so stale content can never stick (issue asks
exactly for that show-cached-revalidate behavior). Logout wipes the
snapshots because they contain account-private listings; History and
Search results are never snapshotted. Thumbnails were already
disk-cached by Coil (the instance serves them with a one-year max-age).

Closes #9
This commit is contained in:
Jeena 2026-10-13 07:45:17 +00:00
parent fdc570ac9e
commit 9f1c6d54c1
7 changed files with 271 additions and 3 deletions

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.ui.list.Snapshots
sealed interface SessionState {
data object Loading : SessionState
@ -83,6 +84,8 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
/** Clears the stored session; the user returns to the setup screen. */
fun logout() {
repository.signOut()
// Snapshots contain account-private listings — invalidate them.
AppGraph.appContext?.let { Snapshots.clearAll(it) }
_userProfile.value = null
_sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1

View file

@ -25,6 +25,30 @@ abstract class PagedListViewModel<T> : ViewModel() {
var loadingInitial by mutableStateOf(true)
private set
/**
* Show an instant (possibly stale) snapshot before the network load lands
* (issue #9): no spinner, content paints immediately and [refresh]-style
* pages replace it silently afterwards.
*/
protected fun showInstant(snapshot: List<T>) {
if (items.isNotEmpty() || snapshot.isEmpty()) return
items = snapshot
exhausted = false
loadingInitial = false
viewModelScope.launch {
// Replace the (possibly stale) first page with fresh network data.
runCatching { fetchAndReplace() }
}
}
private suspend fun fetchAndReplace() {
val fresh = fetch(0, pageSize).getOrNull() ?: return
exhausted = fresh.size < pageSize
items = fresh
initialError = null
onChunkLoaded(fresh)
}
protected abstract suspend fun fetch(start: Long, count: Int): Result<List<T>>
/** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */

View file

@ -0,0 +1,100 @@
package net.jeena.jtubetv.ui.list
import android.content.Context
import kotlinx.serialization.builtins.ListSerializer
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoDto
/**
* Disk snapshots of the last successfully loaded lists (issue #9): the app
* shows them instantly on the next cold start while the network refresh runs
* in the background, so navigation never waits for the API again on the first
* paint. Storage lives in cacheDir (the OS may evict it) and is invalidated
* implicitly: every successful chunk overwrites the snapshot, logout clears it.
*/
object Snapshots {
private val json = net.jeena.jtubetv.data.network.peertubeJson
private const val DIR = "list-snapshots"
private fun file(context: Context, key: String) =
java.io.File(context.cacheDir, DIR + "/" + key.replace(Regex("[^A-Za-z0-9._-]"), "_") + ".json")
private fun write(context: Context, key: String, content: String) {
val f = file(context, key)
f.parentFile?.mkdirs()
f.writeText(content)
}
private fun read(context: Context, key: String): String? =
file(context, key).takeIf { it.exists() }?.readText()
fun saveVideos(context: Context, key: String, videos: List<VideoDto>) = runCatching {
write(context, key, json.encodeToString(ListSerializer(VideoDto.serializer()), videos))
}
fun loadVideos(context: Context, key: String): List<VideoDto>? =
read(context, key)?.let { raw ->
runCatching { json.decodeFromString(ListSerializer(VideoDto.serializer()), raw) }.getOrNull()
}
fun saveUserGroups(
context: Context,
key: String,
rows: List<net.jeena.jtubetv.ui.screens.UserGroup>,
) = runCatching {
write(
context,
key,
json.encodeToString(
ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()),
rows,
),
)
}
fun loadUserGroups(context: Context, key: String): List<net.jeena.jtubetv.ui.screens.UserGroup>? =
read(context, key)?.let { raw ->
runCatching {
json.decodeFromString(
ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()),
raw,
)
}.getOrNull()
}
fun saveChannelHeader(context: Context, handle: String, channel: VideoChannelDto) = runCatching {
write(context, "chan-" + handle, json.encodeToString(VideoChannelDto.serializer(), channel))
}
fun saveContinue(context: Context, key: String, items: List<net.jeena.jtubetv.ui.screens.ContinueItem>) = runCatching {
write(
context,
key,
json.encodeToString(
ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()),
items,
),
)
}
fun loadContinue(context: Context, key: String): List<net.jeena.jtubetv.ui.screens.ContinueItem>? =
read(context, key)?.let { raw ->
runCatching {
json.decodeFromString(
ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()),
raw,
)
}.getOrNull()
}
fun loadChannelHeader(context: Context, handle: String): VideoChannelDto? =
read(context, "chan-" + handle)?.let { raw ->
runCatching { json.decodeFromString(VideoChannelDto.serializer(), raw) }.getOrNull()
}
/** Forget everything (logout / account switch). */
fun clearAll(context: Context) {
java.io.File(context.cacheDir, DIR).deleteRecursively()
}
}

View file

@ -10,6 +10,7 @@ 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 {
@ -41,6 +42,37 @@ class VideoListViewModel(
)
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 {
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
}
}
}
}
}
}
override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails(
chunk.mapNotNull {
@ -50,6 +82,20 @@ 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>> {
@ -74,7 +120,24 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source
if (s is ListSource.Channel) {
viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() }
// 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)
}
}
}
}
}
}

View file

@ -59,6 +59,7 @@ import net.jeena.jtubetv.data.api.Avatar
import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg
@ -78,6 +79,7 @@ import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
* the full listing (max 100/page) and fetch per-channel stats (videos + sum of
* video views = plays) with small throttled calls.
*/
@kotlinx.serialization.Serializable
data class UserGroup(
val ownerName: String,
val label: String,
@ -87,6 +89,10 @@ 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())
@ -115,9 +121,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private set
private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>()
init {
// Snapshot restore (issue #9): paint the last session's channel rows
// instantly; the streaming reload below then updates stats in place.
if (snapshotRows != null) {
userRows = snapshotRows
loading = false
}
}
fun load(force: Boolean = false) {
if (!force && userRows.isNotEmpty()) return
@ -241,6 +255,12 @@ 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 ->

View file

@ -40,6 +40,7 @@ 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.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */
@ -50,10 +51,13 @@ fun rowCardWidth(): androidx.compose.ui.unit.Dp {
}
/** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */
@kotlinx.serialization.Serializable
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)
@ -64,11 +68,38 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
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
}
}
}
}
}
private fun fetchSubs() {
viewModelScope.launch {
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subItems = 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)
}
}
}
}
@ -117,6 +148,12 @@ 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)
}
}
}
}
@ -131,8 +168,29 @@ 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>> =