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 44961b0..86d8916 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,8 +37,6 @@ 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 @@ -158,8 +156,7 @@ data class InstanceInfoDto( val shortDescription: String? = null, ) -/** Per-channel stats not available in the listing; fetched on demand. Snapshotable. */ -@Serializable +/** Per-channel stats not available in the listing; fetched on demand. */ 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 be56817..f2fc26f 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,30 +67,6 @@ 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 6d19403..d6f0d2d 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,8 +50,6 @@ 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 @@ -127,16 +125,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { init { // Snapshot restore (issue #9): paint the last session's channel rows - // instantly, including the per-channel stats (the "minutes of loading" - // complaint) — the streaming reload below then updates them in place. + // instantly; the streaming reload below then updates stats in place. if (snapshotRows != null) { userRows = snapshotRows loading = false } - if (snapshotContext != null) { - statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey)) - if (statsMap.isNotEmpty()) stats = statsMap.toMap() - } } @@ -195,32 +188,19 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - /** - * 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. - */ + /** Fetch stats for [channels], re-sorting rows as data lands. */ private fun channelStatsLoop(channels: List, host: String?) { - val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } viewModelScope.launch { - 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 - } + 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 stats = statsMap.toMap() rebuildRows(host) - snapshotContext?.let { ctx -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) - } - } + delay(120) } } } 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 dc9e192..07b3c7f 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,7 +23,6 @@ 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 @@ -34,7 +33,6 @@ 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 @@ -659,60 +657,30 @@ private fun PausedControls( .focusable() .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) Row(verticalAlignment = Alignment.CenterVertically) { - val avatarUrl = detail.channel.avatars.maxByOrNull { it.width }?.let { - it.fileUrl ?: AppGraph.repository.absoluteUrl(it.path) - } - if (avatarUrl != null) { + val avatar = detail.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { coil.compose.AsyncImage( - model = avatarUrl, + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), 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 = 14.dp)) { + Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) { Text( text = detail.name, color = Color.White, style = MaterialTheme.typography.titleLarge, maxLines = 2, ) - 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, - ) - } + Text( + text = detail.channel.displayName + " · " + formatViews(detail.views) + + " · " + formatPublished(detail.publishedAt), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 2.dp), + ) } } // The seek bar is the only playback control: focused = scrub, OK toggles. @@ -742,14 +710,13 @@ private fun PausedControls( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 6.dp), + .padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerActionButton( - icon = Icons.Filled.Groups, + icon = if (subscribed == true) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsNone, desc = if (subscribed == true) "Subscribed" else "Subscribe", filled = subscribed == true, - flushLeft = true, focusRequester = actionBarFocus, ) { done -> scope.launch { @@ -822,7 +789,6 @@ private fun PausedControls( formatDuration((durationMs / 1000).toInt()), color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 3.dp), ) } } @@ -946,35 +912,7 @@ private fun formatDuration(seconds: Int): String { } } -/** 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); +/** Pill button of the video action row (issue #10): text or emoji-labelled; the * subscribed/liked states get a solid accent background. */ @Composable private fun PlayerActionButton( @@ -982,9 +920,6 @@ 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. @@ -1023,12 +958,7 @@ private fun PlayerActionButton( // Click only for real pointer input; the key activation above is guarded. .pointerInput(Unit) { detectTapGestures { activate() } } Row( - modifier = Modifier.padding( - start = if (flushLeft) 0.dp else 12.dp, - top = 8.dp, - end = 12.dp, - bottom = 8.dp, - ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.Icon(