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), + ) } }