Compare commits

..

No commits in common. "3d4de90671e21c2616f2601669fe87ba582aaf59" and "01c08e1dbb94f255d3d81bfeb6a2b48b1bf672cf" have entirely different histories.

4 changed files with 26 additions and 143 deletions

View file

@ -37,8 +37,6 @@ data class VideoChannelSummary(
@SerialName("displayName") val displayName: String,
val host: String,
val avatars: List<Avatar> = 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,

View file

@ -67,30 +67,6 @@ object Snapshots {
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 {
write(
context,

View file

@ -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<VideoChannelDto>, host: String?) {
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) }
viewModelScope.launch {
missing.chunked(6).forEach { batch ->
batch.map { channel ->
async {
for (channel in channels) {
val key = channel.name + "@" + channel.host
if (statsMap.containsKey(key)) continue
val cs = repo.channelStats(key).getOrNull()
if (cs != null) Pair(key, cs) else null
}
}.awaitAll().filterNotNull().forEach { (key, cs) ->
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)
}
}
}

View file

@ -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,
text = detail.channel.displayName + " · " + formatViews(detail.views) +
" · " + formatPublished(detail.publishedAt),
color = Color(0xFFCCCCCC),
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.
@ -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(