From 3d5fd44a5960b07c3f42875711f9bae8a6b887d7 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 08:29:43 +0000 Subject: [PATCH 1/2] channels: Parallelize stats and persist them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channels screen enumerated every channel and then fetched each channel's stats strictly one after another, with a row rebuild and a row re-save after every single one — with several dozen local channels, each stat call walking up to four video pages, that took minutes. Stats now fetch in parallel batches of six and are persisted alongside the rows snapshot, so an existing install paints the channels screen with full stats instantly. Closes #9 --- .../net/jeena/jtubetv/ui/list/Snapshots.kt | 24 +++++++++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 40 ++++++++++++++----- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt index f2fc26f..be56817 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt @@ -67,6 +67,30 @@ object Snapshots { write(context, "chan-" + handle, json.encodeToString(VideoChannelDto.serializer(), channel)) } + fun saveChannelStats(context: Context, baseUrlKey: String, stats: Map) = runCatching { + write( + context, + "chanstats-" + baseUrlKey, + json.encodeToString( + kotlinx.serialization.serializer>(), + stats, + ), + ) + } + + fun loadChannelStats( + context: Context, + baseUrlKey: String, + ): Map = + read(context, "chanstats-" + baseUrlKey)?.let { raw -> + runCatching { + json.decodeFromString( + kotlinx.serialization.serializer>(), + raw, + ) + }.getOrNull() + } ?: emptyMap() + fun saveContinue(context: Context, key: String, items: List) = runCatching { write( context, 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 d6f0d2d..6d19403 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 @@ -50,6 +50,8 @@ import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import android.util.Log +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -125,11 +127,16 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { init { // Snapshot restore (issue #9): paint the last session's channel rows - // instantly; the streaming reload below then updates stats in place. + // instantly, including the per-channel stats (the "minutes of loading" + // complaint) — the streaming reload below then updates them in place. if (snapshotRows != null) { userRows = snapshotRows loading = false } + if (snapshotContext != null) { + statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey)) + if (statsMap.isNotEmpty()) stats = statsMap.toMap() + } } @@ -188,19 +195,32 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - /** Fetch stats for [channels], re-sorting rows as data lands. */ + /** + * 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, host: String?) { + val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } viewModelScope.launch { - for (channel in channels) { - val key = channel.name + "@" + channel.host - if (statsMap.containsKey(key)) continue - val cs = repo.channelStats(key).getOrNull() - if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } - android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) - statsMap[key] = cs + 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) - delay(120) + snapshotContext?.let { ctx -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) + } + } } } } From 3d4de90671e21c2616f2601669fe87ba582aaf59 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 08:29:43 +0000 Subject: [PATCH 2/2] player: Align the controls overlay; consistent stat icons The overlay's spacing came out lopsided: 34dp between the seek bar and the action row while the title/channel line sat tight, the first pill's glyph ~50dp from the screen edge vs the 24dp-aligned text behind it, and the time text at a different right margin than the track it belongs to. Positioning recipe (measured on the emulator, cross-checked with a second model): seek-row box 28dp with no horizontal padding so the track spans the same 24dp margins as the text, action row 6dp below it, first pill drops its start padding so the glyph aligns flush with the text margin, time padded 3dp so its right edge tracks the rounded cap. The stat icons now match the Channels view (Groups = subscribers, PlayArrow = views next to the numbers), the overlay's subscribe became Groups instead of the YouTube-notifications bell (the bell read as notifications), and federated channels without an avatar get a letter-circle fallback so the channel row keeps its shape. --- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 5 +- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 100 +++++++++++++++--- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 86d8916..44961b0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -37,6 +37,8 @@ data class VideoChannelSummary( @SerialName("displayName") val displayName: String, val host: String, val avatars: List = emptyList(), + // Present on video detail responses — used for the subscriber count. + @SerialName("followersCount") val followersCount: Long? = null, ) @Serializable @@ -156,7 +158,8 @@ data class InstanceInfoDto( val shortDescription: String? = null, ) -/** Per-channel stats not available in the listing; fetched on demand. */ +/** Per-channel stats not available in the listing; fetched on demand. Snapshotable. */ +@Serializable data class ChannelStats( val videos: Long, val plays: Long, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 07b3c7f..dc9e192 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.Replay10 @@ -657,30 +659,60 @@ private fun PausedControls( .focusable() .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = detail.channel.avatars.maxByOrNull { it.width } - if (avatar != null) { + val avatarUrl = detail.channel.avatars.maxByOrNull { it.width }?.let { + it.fileUrl ?: AppGraph.repository.absoluteUrl(it.path) + } + if (avatarUrl != null) { coil.compose.AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + model = avatarUrl, contentDescription = "Open channel", contentScale = ContentScale.Crop, modifier = channelModifier .clip(CircleShape), ) + } else { + // Federated mirrors often ship without an avatar: draw a + // letter-circle so the row keeps its shape. + Box( + modifier = channelModifier + .clip(CircleShape) + .background(avatarColorFor(detail.channel.displayName)), + contentAlignment = Alignment.Center, + ) { + Text( + text = detail.channel.displayName.take(1).uppercase(), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + } } - Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) { + Column(modifier = Modifier.padding(start = 14.dp)) { Text( text = detail.name, color = Color.White, style = MaterialTheme.typography.titleLarge, maxLines = 2, ) - Text( - text = detail.channel.displayName + " · " + formatViews(detail.views) + - " · " + formatPublished(detail.publishedAt), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 2.dp), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = detail.channel.displayName, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + // Same stat icons as the Channels view: Groups = subscribers, + // PlayArrow = views. + detail.channel.followersCount?.let { followers -> + InlineStat(Icons.Filled.Groups, followers) + InlineStat(Icons.Filled.PlayArrow, detail.views) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = formatPublished(detail.publishedAt), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } } } // The seek bar is the only playback control: focused = scrub, OK toggles. @@ -710,13 +742,14 @@ private fun PausedControls( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 10.dp), + .padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerActionButton( - icon = if (subscribed == true) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsNone, + icon = Icons.Filled.Groups, desc = if (subscribed == true) "Subscribed" else "Subscribe", filled = subscribed == true, + flushLeft = true, focusRequester = actionBarFocus, ) { done -> scope.launch { @@ -789,6 +822,7 @@ private fun PausedControls( formatDuration((durationMs / 1000).toInt()), color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 3.dp), ) } } @@ -912,7 +946,35 @@ private fun formatDuration(seconds: Int): String { } } -/** Pill button of the video action row (issue #10): text or emoji-labelled; the +/** Icon + number inline in the channel line, styled like the Channels view stats. */ +@Composable +private fun InlineStat(icon: androidx.compose.ui.graphics.vector.ImageVector, value: Long) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = Color(0xFF9A9A9A), + modifier = Modifier + .padding(start = 14.dp) + .size(18.dp), + ) + Text( + text = value.toString(), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) +} + +/** Stable pleasant color for a letter-avatar fallback. */ +private fun avatarColorFor(name: String): Color { + val palette = listOf( + 0xFFB3554D, 0xFF3E7A82, 0xFF7A5F9E, 0xFF5B8C51, 0xFF9E7B45, + 0xFF4D6FB3, 0xFFA85C82, 0xFF6B8E4E, + ) + return Color(palette[(name.hashCode().let { if (it < 0) -it else it } % palette.size)]) +} + +/** Pill button of the video action row (issue #10): icon-only (no strings to translate); * subscribed/liked states get a solid accent background. */ @Composable private fun PlayerActionButton( @@ -920,6 +982,9 @@ private fun PlayerActionButton( desc: String, count: Long? = null, filled: Boolean = false, + // First pill in the row: drop the left inner padding so the glyph aligns + // flush with the 24dp text margin. + flushLeft: Boolean = false, focusRequester: FocusRequester? = null, // [onClick] receives an onDone callback; only call it when the action's // network roundtrip finished — events arriving before onDone are dropped. @@ -958,7 +1023,12 @@ private fun PlayerActionButton( // Click only for real pointer input; the key activation above is guarded. .pointerInput(Unit) { detectTapGestures { activate() } } Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.padding( + start = if (flushLeft) 0.dp else 12.dp, + top = 8.dp, + end = 12.dp, + bottom = 8.dp, + ), verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.Icon(