channels: drop the all-channels listing walk

The 13-page listing only produced rows that were never rendered
(followed-instance groups had no UI consumer) and 12 of its pages were
spent finding locals already known. What the cards still show and the
video listing cannot carry — banners, exact subscriber counts — comes
from one per-channel call per local channel, in six parallel batches.
Zero-video local channels are no longer listed: they had nothing on
their cards anyway; the signed-in user's own channels stay complete
via the account listing.
This commit is contained in:
Jeena 2026-10-13 23:03:58 +00:00
parent 29dee24849
commit 710a0f23cc
2 changed files with 35 additions and 79 deletions

View file

@ -158,16 +158,6 @@ class PeerTubeRepository(
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> = suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
runCatching { network.api.channels(start = start, count = count, search = search) } runCatching { network.api.channels(start = start, count = count, search = search) }
/**
* Plain channel listing (the API has no local-only filter; returns local +
* followed-instance channels). Caller splits by host.
*/
suspend fun channelList(start: Long, count: Int): Result<Pair<List<VideoChannelDto>, Long>> =
runCatching {
val response = network.api.channels(search = null, start = start, count = count)
response.data to response.total
}
suspend fun videoChannel(handle: String): Result<VideoChannelDto> = suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
runCatching { network.api.videoChannel(handle) } runCatching { network.api.videoChannel(handle) }

View file

@ -50,6 +50,8 @@ 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
@ -117,10 +119,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private var allChannels: List<VideoChannelDto> = emptyList() private var allChannels: List<VideoChannelDto> = emptyList()
/** Channels from followed instances, grouped per platform. */
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
private val repo: PeerTubeRepository = AppGraph.repository private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>() private val statsMap = HashMap<String, ChannelStats>()
@ -167,10 +165,10 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
loadLocalsFromVideos(host) loadLocalsFromVideos(host)
loading = false loading = false
// Background: the channel listing only refines — banners, exact // Background: banners + exact subscriber counts per channel — the
// follower counts, owner accounts for the row headers, the // only card data the video listing cannot carry. Six parallel
// zero-video locals, and the followed-instance rows. // per-channel calls, no listing walk.
refineFromListing(host) refineChannelMeta(host)
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {
@ -234,54 +232,39 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
} }
/** /**
* Streams the full channel listing (thirteen 100-row pages on an instance * Background touch-up: per-channel metadata the video listing cannot carry
* following others — the locals are spread thin across them). Replaces the * (banners + exact subscriber counts, shown on the cards). Batches of six
* local stubs with full objects (banners + exact subscribers + owner), adds * parallel per-channel calls; the rows only repaint when a card's data
* the zero-video locals that the video listing cannot see, and builds the * actually changed — sort keys are stat-stable, so no visible churn.
* followed-instance rows. Rebuilds only when a group actually changed.
*/ */
private fun refineFromListing(host: String?) { private fun refineChannelMeta(host: String?) {
val handles = allChannels
.filter { it.host == host && it.host != null }
.filter { it.banners.isEmpty() || it.followersCount == 0L }
.map { it.name + "@" + it.host!! }
viewModelScope.launch { viewModelScope.launch {
var start = 0L handles.chunked(6).forEach { batch ->
var total: Long? = null batch.map { handle ->
var retries = 0 async { handle to repo.videoChannel(handle).getOrNull() }
var localsFingerprint = "" }.awaitAll().filterNotNull().forEach { (handle, full) ->
var foreignCount = -1 if (full == null) return@forEach
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 merged = allChannels.toMutableList() val merged = allChannels.toMutableList()
for (c in page.first) { val index = merged.indexOfFirst {
val existing = merged.indexOfFirst { it.name == c.name && it.host == c.host } it.name + "@" + it.host == handle && it.host == host
if (existing >= 0) merged[existing] = c
else merged += c
} }
allChannels = merged if (index >= 0 &&
start += 100 (merged[index].banners != full.banners ||
android.util.Log.w( merged[index].followersCount != full.followersCount)
"JTube", ) {
"listing page " + (start / 100) + ": total=" + total + " all=" + allChannels.size, merged[index] = merged[index].copy(
banners = full.banners,
followersCount = full.followersCount,
) )
// Full locals: banners/subscribers may have changed → repaint. allChannels = merged
val fp = allChannels.filter { it.host == host } }
.joinToString("|") { it.name + ":" + it.followersCount } }
if (fp != localsFingerprint) {
localsFingerprint = fp
rebuildRows(host) rebuildRows(host)
} }
val fc = allChannels.count { it.host != host }
if (fc != foreignCount) {
foreignCount = fc
rebuildRows(host) // rebuilds both; userRows stay key-stable
}
}
} }
} }
@ -297,23 +280,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private fun rebuildRowsInner(host: String?) { private fun rebuildRowsInner(host: String?) {
val locals = allChannels.filter { it.host == host } val locals = allChannels.filter { it.host == host }
val foreign = allChannels.filter { it.host != host }
followedRows = foreign
.groupBy { it.host ?: "?" }
.values
.map { row ->
UserGroup(
ownerName = row.firstOrNull()?.host ?: "?",
label = (platformNames[row.firstOrNull()?.host]
?: row.firstOrNull()?.host ?: "?") + " (followed)",
avatar = null,
channels = row.sortedWith(
compareByDescending<VideoChannelDto> { statOf(it) }
.thenByDescending { it.followersCount },
),
)
}
.sortedByDescending { g -> g.channels.sumOf { it.followersCount } }
val ownNames = userChannels.map { c -> c.name }.toSet() val ownNames = userChannels.map { c -> c.name }.toSet()
val byOwner = locals.groupBy { ownerKey(it) } val byOwner = locals.groupBy { ownerKey(it) }
val groups = byOwner.values.map { row -> val groups = byOwner.values.map { row ->