Compare commits
2 commits
01c08e1dbb
...
3d4de90671
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4de90671 | |||
| 3d5fd44a59 |
4 changed files with 143 additions and 26 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue