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:
parent
fdc570ac9e
commit
9f1c6d54c1
7 changed files with 271 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
100
app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt
Normal file
100
app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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>> =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue