channel page: metadata strip between header and videos

The page knew little more than the display name. Between the identity
row and the video cards it now shows subscribers, the video count,
creation date and the owning user — all from the channel payload the
header already fetches, plus an owner/platform line and a short
description snippet. The per-channel detail response carries no video
count, so the paged list's first-page total feeds it instead of a
default zero.

Closes #14
This commit is contained in:
Jeena 2026-10-14 00:09:15 +00:00
parent 44a39b83e9
commit 1f8616d26f
3 changed files with 101 additions and 16 deletions

View file

@ -83,6 +83,7 @@ data class VideoChannelDto(
val banners: List<Avatar> = emptyList(),
val videosCount: Long = 0,
val followersCount: Long = 0,
val createdAt: String? = null,
val ownerAccount: ChannelOwnerAccount? = null,
)

View file

@ -42,6 +42,13 @@ class VideoListViewModel(
)
private set
/**
* First page total for the current source — the per-channel detail endpoint
* does not carry a videos count, so the channel header uses this (issue #14).
*/
var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
private set
// Snapshot caching (issue #9): live content lists only — Search results and
// History rows (which carry extra watch metadata) must not persist.
private val shouldSkipSnapshot =
@ -113,7 +120,10 @@ class VideoListViewModel(
repository.localVideos(start, count, search = q).map { it.data.map { v -> VideoListItem.Video(v) } }
}
is ListSource.Channel ->
repository.channelVideos(s.handle, start, count).map { it.data.map { v -> VideoListItem.Video(v) } }
repository.channelVideos(s.handle, start, count).map { resp ->
if (start == 0L) videoTotal = resp.total
resp.data.map { v -> VideoListItem.Video(v) }
}
}
}

View file

@ -1,4 +1,8 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -14,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
@ -150,7 +155,10 @@ fun VideoListScreen(
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
when (source) {
is ListSource.Search -> SearchHeader(viewModel)
is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader)
is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
)
else -> {}
}
}
@ -220,10 +228,16 @@ private fun SearchHeader(viewModel: VideoListViewModel) {
}
}
/** Channel page header: banner strip, then avatar + display name + handle + counts. */
/** Channel page header: banner strip, then avatar + display name + handle,
* a metadata strip (subscribers/videos/created + owner, issue #14) and the
* channel description snippet above the videos. */
@Composable
private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?) {
private fun ChannelHeader(
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
videoTotal: Long = 0,
) {
if (channelHeader == null) return
val metaColor = Color(0xFF9A9A9A)
androidx.compose.foundation.layout.Column {
val banner = channelHeader.banners.maxByOrNull { it.width }
if (banner != null) {
@ -258,13 +272,73 @@ private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannel
maxLines = 1,
)
Text(
text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") +
channelHeader.name + "@" + channelHeader.host,
text = channelHeader.name + "@" + channelHeader.host,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
// Metadata strip between the identity and the videos (issue #14): the
// counts SmartTube shows, plus creation date and the owning user.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
) {
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
if (videoCount > 0) {
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
}
if (channelHeader.createdAt != null) {
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
}
}
val owner = channelHeader.ownerAccount
if (owner != null) {
val host = channelHeader.host
val platform = AppGraph.tokenStore.instanceName ?: host
Text(
text = "Owned by " + (owner.displayName ?: owner.name) +
(if (platform != null) " - " + platform else ""),
color = metaColor,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
)
}
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
Text(
text = description,
color = Color(0xFFB8B8B8),
style = MaterialTheme.typography.bodySmall,
maxLines = 3,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
)
}
}
}
@Composable
private fun headerStat(
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
color: Color,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(14.dp),
)
Text(
text = value,
color = color,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp),
)
}
}