Compare commits

...

2 commits

Author SHA1 Message Date
3d4de90671 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.
2026-10-13 08:29:43 +00:00
3d5fd44a59 channels: Parallelize stats and persist them
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
2026-10-13 08:29:43 +00:00
4 changed files with 143 additions and 26 deletions

View file

@ -37,6 +37,8 @@ data class VideoChannelSummary(
@SerialName("displayName") val displayName: String, @SerialName("displayName") val displayName: String,
val host: String, val host: String,
val avatars: List<Avatar> = emptyList(), val avatars: List<Avatar> = emptyList(),
// Present on video detail responses — used for the subscriber count.
@SerialName("followersCount") val followersCount: Long? = null,
) )
@Serializable @Serializable
@ -156,7 +158,8 @@ data class InstanceInfoDto(
val shortDescription: String? = null, 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( data class ChannelStats(
val videos: Long, val videos: Long,
val plays: Long, val plays: Long,

View file

@ -67,6 +67,30 @@ object Snapshots {
write(context, "chan-" + handle, json.encodeToString(VideoChannelDto.serializer(), channel)) write(context, "chan-" + handle, json.encodeToString(VideoChannelDto.serializer(), channel))
} }
fun saveChannelStats(context: Context, baseUrlKey: String, stats: Map<String, net.jeena.jtubetv.data.api.ChannelStats>) = runCatching {
write(
context,
"chanstats-" + baseUrlKey,
json.encodeToString(
kotlinx.serialization.serializer<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(),
stats,
),
)
}
fun loadChannelStats(
context: Context,
baseUrlKey: String,
): Map<String, net.jeena.jtubetv.data.api.ChannelStats> =
read(context, "chanstats-" + baseUrlKey)?.let { raw ->
runCatching {
json.decodeFromString(
kotlinx.serialization.serializer<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(),
raw,
)
}.getOrNull()
} ?: emptyMap()
fun saveContinue(context: Context, key: String, items: List<net.jeena.jtubetv.ui.screens.ContinueItem>) = runCatching { fun saveContinue(context: Context, key: String, items: List<net.jeena.jtubetv.ui.screens.ContinueItem>) = runCatching {
write( write(
context, context,

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
@ -125,11 +127,16 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
init { init {
// Snapshot restore (issue #9): paint the last session's channel rows // 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) { if (snapshotRows != null) {
userRows = snapshotRows userRows = snapshotRows
loading = false 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<VideoChannelDto>, host: String?) { private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) }
viewModelScope.launch { viewModelScope.launch {
for (channel in channels) { missing.chunked(6).forEach { batch ->
val key = channel.name + "@" + channel.host batch.map { channel ->
if (statsMap.containsKey(key)) continue async {
val cs = repo.channelStats(key).getOrNull() val key = channel.name + "@" + channel.host
if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } val cs = repo.channelStats(key).getOrNull()
android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) if (cs != null) Pair(key, cs) else null
statsMap[key] = cs }
}.awaitAll().filterNotNull().forEach { (key, cs) ->
statsMap[key] = cs
}
stats = statsMap.toMap() stats = statsMap.toMap()
rebuildRows(host) rebuildRows(host)
delay(120) snapshotContext?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
}
}
} }
} }
} }

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material.icons.filled.Pause 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.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.Replay10
@ -657,30 +659,60 @@ private fun PausedControls(
.focusable() .focusable()
.then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = detail.channel.avatars.maxByOrNull { it.width } val avatarUrl = detail.channel.avatars.maxByOrNull { it.width }?.let {
if (avatar != null) { it.fileUrl ?: AppGraph.repository.absoluteUrl(it.path)
}
if (avatarUrl != null) {
coil.compose.AsyncImage( coil.compose.AsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), model = avatarUrl,
contentDescription = "Open channel", contentDescription = "Open channel",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = channelModifier modifier = channelModifier
.clip(CircleShape), .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(
text = detail.name, text = detail.name,
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 2, maxLines = 2,
) )
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = detail.channel.displayName + " · " + formatViews(detail.views) + Text(
" · " + formatPublished(detail.publishedAt), text = detail.channel.displayName,
color = Color(0xFFCCCCCC), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp), )
) // 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. // The seek bar is the only playback control: focused = scrub, OK toggles.
@ -710,13 +742,14 @@ private fun PausedControls(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 10.dp), .padding(top = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
PlayerActionButton( PlayerActionButton(
icon = if (subscribed == true) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsNone, icon = Icons.Filled.Groups,
desc = if (subscribed == true) "Subscribed" else "Subscribe", desc = if (subscribed == true) "Subscribed" else "Subscribe",
filled = subscribed == true, filled = subscribed == true,
flushLeft = true,
focusRequester = actionBarFocus, focusRequester = actionBarFocus,
) { done -> ) { done ->
scope.launch { scope.launch {
@ -789,6 +822,7 @@ private fun PausedControls(
formatDuration((durationMs / 1000).toInt()), formatDuration((durationMs / 1000).toInt()),
color = Color(0xFFCCCCCC), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium, 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. */ * subscribed/liked states get a solid accent background. */
@Composable @Composable
private fun PlayerActionButton( private fun PlayerActionButton(
@ -920,6 +982,9 @@ private fun PlayerActionButton(
desc: String, desc: String,
count: Long? = null, count: Long? = null,
filled: Boolean = false, 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, focusRequester: FocusRequester? = null,
// [onClick] receives an onDone callback; only call it when the action's // [onClick] receives an onDone callback; only call it when the action's
// network roundtrip finished — events arriving before onDone are dropped. // 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. // Click only for real pointer input; the key activation above is guarded.
.pointerInput(Unit) { detectTapGestures { activate() } } .pointerInput(Unit) { detectTapGestures { activate() } }
Row( 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
androidx.compose.material3.Icon( androidx.compose.material3.Icon(