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:
parent
8d21cf35ec
commit
d55c265469
1 changed files with 87 additions and 45 deletions
|
|
@ -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 &&
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue