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> =