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 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
sealed interface SessionState { sealed interface SessionState {
data object Loading : 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. */ /** Clears the stored session; the user returns to the setup screen. */
fun logout() { fun logout() {
repository.signOut() repository.signOut()
// Snapshots contain account-private listings — invalidate them.
AppGraph.appContext?.let { Snapshots.clearAll(it) }
_userProfile.value = null _userProfile.value = null
_sessionState.value = SessionState.NeedsSetup _sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1 sessionEpoch.value += 1

View file

@ -25,6 +25,30 @@ abstract class PagedListViewModel<T> : ViewModel() {
var loadingInitial by mutableStateOf(true) var loadingInitial by mutableStateOf(true)
private set 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>> 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. */ /** 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 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 {
@ -41,6 +42,37 @@ class VideoListViewModel(
) )
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 {
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>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails( ImagePrefetcher.videoThumbnails(
chunk.mapNotNull { 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>> { override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
@ -74,7 +120,24 @@ 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) {
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.ChannelStats
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto 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.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg 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 * the full listing (max 100/page) and fetch per-channel stats (videos + sum of
* video views = plays) with small throttled calls. * video views = plays) with small throttled calls.
*/ */
@kotlinx.serialization.Serializable
data class UserGroup( data class UserGroup(
val ownerName: String, val ownerName: String,
val label: String, val label: String,
@ -87,6 +89,10 @@ 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())
@ -115,9 +121,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private set private set
private val repo: PeerTubeRepository = AppGraph.repository private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>() 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) { fun load(force: Boolean = false) {
if (!force && userRows.isNotEmpty()) return 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 { 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

@ -40,6 +40,7 @@ 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.VideoCard import net.jeena.jtubetv.ui.screens.list.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ /** 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). */ /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */
@kotlinx.serialization.Serializable
data class ContinueItem(val video: VideoDto, val progress: Float?) 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)
@ -64,11 +68,38 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
val recent = RecentVideosViewModel(repository) 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() { private fun fetchSubs() {
viewModelScope.launch { viewModelScope.launch {
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subItems = subs subItems = subs
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)
}
}
} }
} }
@ -117,6 +148,12 @@ 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)
}
}
} }
} }
@ -131,8 +168,29 @@ 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>> =