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:
parent
f5b37dd21d
commit
29dee24849
2 changed files with 121 additions and 58 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,11 +159,94 @@ 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
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-entry skip window. */
|
||||||
|
private fun fresh(): Boolean =
|
||||||
|
lastLoadedAt > 0 &&
|
||||||
|
android.os.SystemClock.elapsedRealtime() - lastLoadedAt < 5 * 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 suspend fun loadLocalsFromVideos(host: String?) {
|
||||||
|
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 {
|
||||||
var start = 0L
|
var start = 0L
|
||||||
var total: Long? = null
|
var total: Long? = null
|
||||||
var retries = 0
|
var retries = 0
|
||||||
|
var localsFingerprint = ""
|
||||||
|
var foreignCount = -1
|
||||||
while (total == null || start < total) {
|
while (total == null || start < total) {
|
||||||
val page = repo.channelList(start, 100).getOrNull()
|
val page = repo.channelList(start, 100).getOrNull()
|
||||||
if (page == null) {
|
if (page == null) {
|
||||||
|
|
@ -169,57 +257,29 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
}
|
}
|
||||||
retries = 0
|
retries = 0
|
||||||
if (total == null) total = page.second
|
if (total == null) total = page.second
|
||||||
val known = all.map { it.name to it.host }.toSet()
|
val merged = allChannels.toMutableList()
|
||||||
all += page.first.filter { c -> (c.name to c.host) !in known }
|
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
|
start += 100
|
||||||
android.util.Log.w(
|
android.util.Log.w(
|
||||||
"JTube",
|
"JTube",
|
||||||
"channels page " + (start / 100) + ": total=" + total + " all=" + all.size +
|
"listing page " + (start / 100) + ": total=" + total + " all=" + allChannels.size,
|
||||||
" locals=" + all.count { it.host == host },
|
|
||||||
)
|
)
|
||||||
allChannels = all
|
// 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)
|
rebuildRows(host)
|
||||||
}
|
}
|
||||||
ImagePrefetcher.channelArt(all + userChannels)
|
val fc = allChannels.count { it.host != host }
|
||||||
|
if (fc != foreignCount) {
|
||||||
// stats (videos + plays) for every local channel: throttled parallel-ish
|
foreignCount = fc
|
||||||
val locals = all.filter { it.host == host }
|
rebuildRows(host) // rebuilds both; userRows stay key-stable
|
||||||
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size +
|
|
||||||
" userChannels=" + userChannels.size)
|
|
||||||
channelStatsLoop(locals, host)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
error = e.message ?: "Could not load channels"
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
|
|
||||||
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) }
|
|
||||||
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
|
|
||||||
}
|
|
||||||
stats = statsMap.toMap()
|
|
||||||
rebuildRows(host)
|
|
||||||
snapshotContext?.let { ctx ->
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue