From 9f1c6d54c12adea88c6faf3a2b17eea492f1dddb Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 07:45:17 +0000 Subject: [PATCH] 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 --- .../net/jeena/jtubetv/data/network/Network.kt | 2 +- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 3 + .../jtubetv/ui/list/PagedListViewModel.kt | 24 +++++ .../net/jeena/jtubetv/ui/list/Snapshots.kt | 100 ++++++++++++++++++ .../jtubetv/ui/list/VideoListViewModel.kt | 65 +++++++++++- .../jtubetv/ui/screens/ChannelsScreen.kt | 22 +++- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 58 ++++++++++ 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt diff --git a/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt index dd2b68d..e529cde 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt @@ -161,4 +161,4 @@ class TokenAuthenticator( } return count } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 8bc14bd..7a45d46 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -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 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt index 3429a76..7a17eb7 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -25,6 +25,30 @@ abstract class PagedListViewModel : 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) { + 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> /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt new file mode 100644 index 0000000..f2fc26f --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt @@ -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) = runCatching { + write(context, key, json.encodeToString(ListSerializer(VideoDto.serializer()), videos)) + } + + fun loadVideos(context: Context, key: String): List? = + read(context, key)?.let { raw -> + runCatching { json.decodeFromString(ListSerializer(VideoDto.serializer()), raw) }.getOrNull() + } + + fun saveUserGroups( + context: Context, + key: String, + rows: List, + ) = runCatching { + write( + context, + key, + json.encodeToString( + ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()), + rows, + ), + ) + } + + fun loadUserGroups(context: Context, key: String): List? = + 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) = runCatching { + write( + context, + key, + json.encodeToString( + ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()), + items, + ), + ) + } + + fun loadContinue(context: Context, key: String): List? = + 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() + } +} \ No newline at end of file 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 dca398c..4535e21 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,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) { 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().take(50).map { it.video }, + ) + } + } + } } override suspend fun fetch(start: Long, count: Int): Result> { @@ -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) + } + } + } + } } } 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 a97a119..d6f0d2d 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 @@ -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(null) private set var userChannels by mutableStateOf>(emptyList()) @@ -115,9 +121,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private set private val repo: PeerTubeRepository = AppGraph.repository - private val statsMap = HashMap() + 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 -> 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 74cf99a..3def3a1 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 @@ -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>(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() { + + // 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> =