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/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()) + } + } } } } 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(