channels: build the local rows from the video listing

Entering Channels walked thirteen 100-row pages of the all-channels
listing (1237 rows on this instance, most of them federated follows)
and re-sorted the rows after every page; the signed-in user's own row
only appeared at page 12, which read as "loading one channel after
another" and never kept icons visible above the fold.

The local video listing already carries what the cards show: paging its
few pages yields exact per-channel video/play counts, covers, channel
avatars and the owner accounts for the row headers — no per-channel
requests. The all-channels listing now runs behind it as a background
metadata refinement (banners, exact subscriber counts, zero-video local
channels, followed platforms) and rebuilds rows only when a group
actually changes, so the visible rows don't churn. Re-entering the
screen within five minutes repaints the kept rows untouched.

Closes #13
This commit is contained in:
Jeena 2026-10-13 14:25:31 +00:00
parent f5b37dd21d
commit 29dee24849
2 changed files with 121 additions and 58 deletions

View file

@ -50,6 +50,9 @@ data class VideoDto(
val duration: Int = 0, val duration: Int = 0,
@SerialName("publishedAt") val publishedAt: String? = null, @SerialName("publishedAt") val publishedAt: String? = null,
val thumbnailPath: 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 privacy: Labelled? = null,
val nsfw: Boolean = false, val nsfw: Boolean = false,
val views: Long = 0, val views: Long = 0,

View file

@ -50,8 +50,6 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage import coil.compose.AsyncImage
import android.util.Log import android.util.Log
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph 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.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.data.api.VideoDto
import net.jeena.jtubetv.ui.list.Snapshots 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
@ -125,6 +124,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private val repo: PeerTubeRepository = AppGraph.repository private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>() private val statsMap = HashMap<String, ChannelStats>()
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
private var lastLoadedAt = 0L
init { init {
// Snapshot restore (issue #9): paint the last session's channel rows // Snapshot restore (issue #9): paint the last session's channel rows
// instantly, including the per-channel stats (the "minutes of loading" // 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) { 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 loading = true
error = null error = null
viewModelScope.launch { viewModelScope.launch {
try { 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() user = repo.me().getOrNull()
userChannels = user?.let { u -> userChannels = user?.let { u ->
repo.accountChannels(u.username).getOrNull() ?: emptyList() repo.accountChannels(u.username).getOrNull() ?: emptyList()
@ -154,39 +159,18 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
// enum the whole channel list (federation-follow listing incl. local) // Fast path (~3-4 requests): the local video listing already carries
val all = mutableListOf<VideoChannelDto>() // per-channel and per-account data + thumbnails, so every local row
var start = 0L // renders complete within seconds. The old flow walked 13 pages of
var total: Long? = null // the all-channels listing (1237 rows on this instance, most
var retries = 0 // federated), re-sorting after every page (issue #13).
while (total == null || start < total) { loadLocalsFromVideos(host)
val page = repo.channelList(start, 100).getOrNull() loading = false
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)
// stats (videos + plays) for every local channel: throttled parallel-ish // Background: the channel listing only refines — banners, exact
val locals = all.filter { it.host == host } // follower counts, owner accounts for the row headers, the
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + // zero-video locals, and the followed-instance rows.
" userChannels=" + userChannels.size) refineFromListing(host)
channelStatsLoop(locals, host)
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } 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 * Builds the local rows from `videos?isLocal=true` (sorted -publishedAt):
* up to 4 video pages — strictly sequential with ~40 channels took minutes), * groups per channel handle for stats (exact video count + play sum as long
* re-sorting the rows whenever a batch lands. Persisted per batch so the * as the whole local library fits in a few pages) and per owner-account for
* next start starts warm. * the row headers. No per-channel requests at all.
*/ */
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) { private suspend fun loadLocalsFromVideos(host: String?) {
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } val videos = mutableListOf<VideoDto>()
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 { viewModelScope.launch {
missing.chunked(6).forEach { batch -> var start = 0L
batch.map { channel -> var total: Long? = null
async { var retries = 0
val key = channel.name + "@" + channel.host var localsFingerprint = ""
val cs = repo.channelStats(key).getOrNull() var foreignCount = -1
if (cs != null) Pair(key, cs) else null while (total == null || start < total) {
} val page = repo.channelList(start, 100).getOrNull()
}.awaitAll().filterNotNull().forEach { (key, cs) -> if (page == null) {
statsMap[key] = cs retries++
if (retries > 2) break
delay(500)
continue
} }
stats = statsMap.toMap() retries = 0
rebuildRows(host) if (total == null) total = page.second
snapshotContext?.let { ctx -> val merged = allChannels.toMutableList()
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { for (c in page.first) {
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) 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 = {}, onOpenDrawer: () -> Unit = {},
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) { ) {
LaunchedEffect(epoch) { viewModel.load(force = true) } LaunchedEffect(epoch) { viewModel.load() }
when { when {