From 1f8616d26f5452bddd60dc2bf82d28d798070dc4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 14 Oct 2026 00:09:15 +0000 Subject: [PATCH] channel page: metadata strip between header and videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 1 + .../jtubetv/ui/list/VideoListViewModel.kt | 12 +- .../ui/screens/list/VideoListScreen.kt | 104 +++++++++++++++--- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 6ca85b5..62e6979 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -83,6 +83,7 @@ data class VideoChannelDto( val banners: List = emptyList(), val videosCount: Long = 0, val followersCount: Long = 0, + val createdAt: String? = null, val ownerAccount: ChannelOwnerAccount? = null, ) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 4535e21..71e4d5f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -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) } + } } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index b77ce0e..227eb0e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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,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 -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) { - AsyncImage( - model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), - ) - } + AsyncImage( + model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), + ) + } Row(verticalAlignment = Alignment.CenterVertically) { val avatar = channelHeader.avatars.maxByOrNull { it.width } if (avatar != 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), + ) } }