Compare commits
No commits in common. "3d4de90671e21c2616f2601669fe87ba582aaf59" and "01c08e1dbb94f255d3d81bfeb6a2b48b1bf672cf" have entirely different histories.
3d4de90671
...
01c08e1dbb
4 changed files with 26 additions and 143 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue