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 banners: List<Avatar> = emptyList(),
val videosCount: Long = 0, val videosCount: Long = 0,
val followersCount: Long = 0, val followersCount: Long = 0,
val createdAt: String? = null,
val ownerAccount: ChannelOwnerAccount? = null, val ownerAccount: ChannelOwnerAccount? = null,
) )

View file

@ -42,6 +42,13 @@ class VideoListViewModel(
) )
private set 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 // Snapshot caching (issue #9): live content lists only — Search results and
// History rows (which carry extra watch metadata) must not persist. // History rows (which carry extra watch metadata) must not persist.
private val shouldSkipSnapshot = private val shouldSkipSnapshot =
@ -113,7 +120,10 @@ class VideoListViewModel(
repository.localVideos(start, count, search = q).map { it.data.map { v -> VideoListItem.Video(v) } } repository.localVideos(start, count, search = q).map { it.data.map { v -> VideoListItem.Video(v) } }
} }
is ListSource.Channel -> 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 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.Box
import androidx.compose.foundation.layout.Column 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -150,7 +155,10 @@ fun VideoListScreen(
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
when (source) { when (source) {
is ListSource.Search -> SearchHeader(viewModel) is ListSource.Search -> SearchHeader(viewModel)
is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader) is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
)
else -> {} else -> {}
} }
} }
@ -220,23 +228,29 @@ 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 @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 if (channelHeader == null) return
val metaColor = Color(0xFF9A9A9A)
androidx.compose.foundation.layout.Column { androidx.compose.foundation.layout.Column {
val banner = channelHeader.banners.maxByOrNull { it.width } val banner = channelHeader.banners.maxByOrNull { it.width }
if (banner != null) { if (banner != null) {
AsyncImage( AsyncImage(
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(120.dp) .height(120.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = channelHeader.avatars.maxByOrNull { it.width } val avatar = channelHeader.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
@ -258,13 +272,73 @@ private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannel
maxLines = 1, maxLines = 1,
) )
Text( Text(
text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") + text = channelHeader.name + "@" + channelHeader.host,
channelHeader.name + "@" + channelHeader.host,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, 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),
)
} }
} }