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.
This commit is contained in:
Jeena 2026-10-11 07:01:27 +00:00
parent 8d21cf35ec
commit d55c265469

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -105,18 +106,30 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var platformNames by mutableStateOf<Map<String, String>>(emptyMap()) var platformNames by mutableStateOf<Map<String, String>>(emptyMap())
private set private set
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>()
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
if (!force && userRows.isNotEmpty()) return if (!force && userRows.isNotEmpty()) 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.
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()
} ?: 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) // enum the whole channel list (federation-follow listing incl. local)
val all = mutableListOf<VideoChannelDto>() val all = mutableListOf<VideoChannelDto>()
@ -136,53 +149,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
val known = all.map { it.name to it.host }.toSet() val known = all.map { it.name to it.host }.toSet()
all += page.first.filter { c -> (c.name to c.host) !in known } all += page.first.filter { c -> (c.name to c.host) !in known }
start += 100 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<String, ChannelStats>()
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<VideoChannelDto> { 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<UserGroup> { 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) 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) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } 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<VideoChannelDto>, 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<VideoChannelDto> { 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<VideoChannelDto> { 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<UserGroup> { 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 ?: "" private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: ""
companion object { companion object {
@ -358,6 +399,7 @@ fun ChannelCard(
modifier = modifier modifier = modifier
.width(rowCardWidth()) .width(rowCardWidth())
.padding(6.dp) .padding(6.dp)
.onFocusChanged { cardFocused = it.isFocused }
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT &&