diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 44961b0..6ca85b5 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -50,6 +50,9 @@ data class VideoDto( val duration: Int = 0, @SerialName("publishedAt") val publishedAt: String? = null, val thumbnailPath: String? = null, + // Present on list responses (absent in detail) — lets the channels screen + // group local channels by owner without paging the channel listing. + val account: ChannelOwnerAccount? = null, val privacy: Labelled? = null, val nsfw: Boolean = false, val views: Long = 0, 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 6d19403..02447c4 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 @@ -50,8 +50,6 @@ import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import android.util.Log -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -61,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.data.api.VideoDto import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape @@ -125,6 +124,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private val repo: PeerTubeRepository = AppGraph.repository private val statsMap = HashMap() + /** SystemClock at the last successful fast-path pass — re-entry freshness. */ + private var lastLoadedAt = 0L + init { // Snapshot restore (issue #9): paint the last session's channel rows // instantly, including the per-channel stats (the "minutes of loading" @@ -141,12 +143,15 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { fun load(force: Boolean = false) { - if (!force && userRows.isNotEmpty()) return + // Re-entry (destination switch away + back) inside the freshness window: + // repaint the in-memory rows and skip the network (issue #13) — rows are + // never torn down on re-entry; an account switch builds a new ViewModel. + if (!force && userRows.isNotEmpty() && fresh()) return loading = true error = null viewModelScope.launch { try { - // The user's own row renders FIRST (fast: one call), the rest streams in. + // The user's own row first (fast: two calls). user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() @@ -154,39 +159,18 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - // enum the whole channel list (federation-follow listing incl. local) - val all = mutableListOf() - var start = 0L - var total: Long? = null - var retries = 0 - while (total == null || start < total) { - val page = repo.channelList(start, 100).getOrNull() - if (page == null) { - retries++ - if (retries > 2) break - delay(500) - continue - } - retries = 0 - if (total == null) total = page.second - val known = all.map { it.name to it.host }.toSet() - all += page.first.filter { c -> (c.name to c.host) !in known } - start += 100 - android.util.Log.w( - "JTube", - "channels page " + (start / 100) + ": total=" + total + " all=" + all.size + - " locals=" + all.count { it.host == host }, - ) - allChannels = all - rebuildRows(host) - } - ImagePrefetcher.channelArt(all + userChannels) + // Fast path (~3-4 requests): the local video listing already carries + // per-channel and per-account data + thumbnails, so every local row + // renders complete within seconds. The old flow walked 13 pages of + // the all-channels listing (1237 rows on this instance, most + // federated), re-sorting after every page (issue #13). + loadLocalsFromVideos(host) + loading = false - // stats (videos + plays) for every local channel: throttled parallel-ish - val locals = all.filter { it.host == host } - Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + - " userChannels=" + userChannels.size) - channelStatsLoop(locals, host) + // Background: the channel listing only refines — banners, exact + // follower counts, owner accounts for the row headers, the + // zero-video locals, and the followed-instance rows. + refineFromListing(host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -195,31 +179,107 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Re-entry skip window. */ + private fun fresh(): Boolean = + lastLoadedAt > 0 && + android.os.SystemClock.elapsedRealtime() - lastLoadedAt < 5 * 60_000L + /** - * Fetch stats for [channels] in parallel batches of 6 (each stat call walks - * up to 4 video pages — strictly sequential with ~40 channels took minutes), - * re-sorting the rows whenever a batch lands. Persisted per batch so the - * next start starts warm. + * Builds the local rows from `videos?isLocal=true` (sorted -publishedAt): + * groups per channel handle for stats (exact video count + play sum as long + * as the whole local library fits in a few pages) and per owner-account for + * the row headers. No per-channel requests at all. */ - private fun channelStatsLoop(channels: List, host: String?) { - val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } + private suspend fun loadLocalsFromVideos(host: String?) { + val videos = mutableListOf() + var start = 0L + var total: Long + do { + val page = repo.localVideos(start, 100).getOrNull() ?: break + videos += page.data + total = page.total + start += page.data.size + } while (start < total && videos.size < 2000) + + val base = AppGraph.repository + for ((key, vids) in videos.groupBy { it.channel.name + "@" + it.channel.host }) { + val first = vids.first() + statsMap[key] = ChannelStats( + videos = vids.size.toLong(), + plays = vids.sumOf { it.views }, + latestThumbUrl = first.thumbnailPath + ?.takeIf { it.isNotBlank() } + ?.let { base.absoluteUrl(it) }, + ) + if (allChannels.none { it.name == first.channel.name && it.host == first.channel.host }) { + allChannels += VideoChannelDto( + name = first.channel.name, + displayName = first.channel.displayName, + host = first.channel.host, + avatars = first.channel.avatars, + // video.account IS the channel's owner — enough for row grouping. + ownerAccount = first.account, + ) + } + } + stats = statsMap.toMap() + rebuildRows(host) + lastLoadedAt = android.os.SystemClock.elapsedRealtime() + snapshotContext?.let { ctx -> + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) + } + } + ImagePrefetcher.channelArt(allChannels + userChannels) + } + + /** + * Streams the full channel listing (thirteen 100-row pages on an instance + * following others — the locals are spread thin across them). Replaces the + * local stubs with full objects (banners + exact subscribers + owner), adds + * the zero-video locals that the video listing cannot see, and builds the + * followed-instance rows. Rebuilds only when a group actually changed. + */ + private fun refineFromListing(host: String?) { viewModelScope.launch { - missing.chunked(6).forEach { batch -> - batch.map { channel -> - async { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() - if (cs != null) Pair(key, cs) else null - } - }.awaitAll().filterNotNull().forEach { (key, cs) -> - statsMap[key] = cs + var start = 0L + var total: Long? = null + var retries = 0 + var localsFingerprint = "" + var foreignCount = -1 + while (total == null || start < total) { + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue } - stats = statsMap.toMap() - rebuildRows(host) - snapshotContext?.let { ctx -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) - } + retries = 0 + if (total == null) total = page.second + val merged = allChannels.toMutableList() + for (c in page.first) { + val existing = merged.indexOfFirst { it.name == c.name && it.host == c.host } + if (existing >= 0) merged[existing] = c + else merged += c + } + allChannels = merged + start += 100 + android.util.Log.w( + "JTube", + "listing page " + (start / 100) + ": total=" + total + " all=" + allChannels.size, + ) + // Full locals: banners/subscribers may have changed → repaint. + val fp = allChannels.filter { it.host == host } + .joinToString("|") { it.name + ":" + it.followersCount } + if (fp != localsFingerprint) { + localsFingerprint = fp + rebuildRows(host) + } + val fc = allChannels.count { it.host != host } + if (fc != foreignCount) { + foreignCount = fc + rebuildRows(host) // rebuilds both; userRows stay key-stable } } } @@ -309,7 +369,7 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(epoch) { viewModel.load(force = true) } + LaunchedEffect(epoch) { viewModel.load() } when {