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:
parent
44a39b83e9
commit
1f8616d26f
3 changed files with 101 additions and 16 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue