From d55c26546995ab232723b2d765e5c2cf75f89bc8 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:01:27 +0000 Subject: [PATCH] channels: Render rows early (pages only), guard rebuild, Long-safe sort keys The channels screen waited for every listing page AND all per-channel stats before showing anything - a 40s+ blank spinner. Rows now render per page (unsorted), re-sorting as stat batches arrive. rebuildRows is guarded; the earlier ClassCastException (Integer into a Long comparator) surfaced via the sort call and can no longer abort the app. All sort keys go through Long-typed helpers. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) 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 9fa1e26..5decf11 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 @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale @@ -105,18 +106,30 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var platformNames by mutableStateOf>(emptyMap()) private set + private var allChannels: List = emptyList() + + /** Channels from followed instances, grouped per platform. */ + var followedRows by mutableStateOf>(emptyList()) + private set + private val repo: PeerTubeRepository = AppGraph.repository + private val statsMap = HashMap() + + fun load(force: Boolean = false) { if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { + // The user's own row renders FIRST (fast: one call), the rest streams in. user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + 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() @@ -136,53 +149,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + allChannels = all + rebuildRows(host) } - - val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val locals = all.filter { it.host == host } - - // platform display names: local one cached at login; foreign ones lazy - platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { - val n = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to n) - } - - // stats for every local channel: throttle below the server rate limit - val statsMap = HashMap() - for (channel in locals) { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() ?: continue - statsMap[key] = cs - stats = statsMap.toMap() - delay(250) - } - - // --- rows per user, sorted by videos / subscribers / plays ----------- - val ownNames = userChannels.map { c -> c.name }.toSet() - val byOwner = locals.groupBy { ownerKey(it) } - val groups = byOwner.values.map { row -> - val sortedChannels = row.sortedWith( - compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } - .thenByDescending { it.followersCount } - .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, - ) - val own = row.firstOrNull { it.name in ownNames } - UserGroup( - ownerName = ownerKey(row.first()), - label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", - avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, - channels = sortedChannels, - ) - } - // logged-in user first, then the others by videos / subs / plays - userRows = groups.sortedWith( - compareByDescending { g -> g.channels.any { it.name in ownNames } } - .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } - .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, - ) - ImagePrefetcher.channelArt(all + userChannels) + + // stats (videos + plays) for every local channel: throttled parallel-ish + val locals = all.filter { it.host == host } + channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -191,6 +165,73 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Fetch stats for [channels], re-sorting rows as data lands. */ + private fun channelStatsLoop(channels: List, host: String?) { + viewModelScope.launch { + for (channel in channels) { + val key = channel.name + "@" + channel.host + if (statsMap.containsKey(key)) continue + val cs = repo.channelStats(key).getOrNull() ?: continue + statsMap[key] = cs + stats = statsMap.toMap() + rebuildRows(host) + delay(120) + } + } + } + + private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L + private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L + + /** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */ + private fun rebuildRows(host: String?) { + runCatching { rebuildRowsInner(host) }.onFailure { + android.util.Log.e("JTube", "rebuildRows failed", it) + } + } + + private fun rebuildRowsInner(host: String?) { + 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 { statOf(it) } + .thenByDescending { it.followersCount }, + ), + ) + } + .sortedByDescending { g -> g.channels.sumOf { it.followersCount } } + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sorted = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sorted, + ) + } + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { @@ -358,6 +399,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT &&