channel: redesign the header, reachable Subscribe button (#27)

Without a mouse the channel banner was invisible: opening a channel focused the
first video card, which scrolled the header (a grid item) off-screen. The header
is now pinned above the grid and never scrolls away. The banner is its backdrop,
darkened to black at the bottom; the avatar sits beside the title with a
"by <owner> on <platform>" byline, the subscriber/video/created stats, and the
description flowing into the black beneath (a long one grows the header down
instead of being clipped). A Subscribe button sits on the banner, reachable with
UP from the first card, wired to the same follow/unfollow as the player.

AGENTS.md: subscribing is in scope (already on the player, now the channel page
too), so drop it from the excluded list.
This commit is contained in:
Jeena 2026-10-15 12:23:36 +00:00
parent b2af6fc1a5
commit 21d8290f1d
2 changed files with 220 additions and 88 deletions

View file

@ -113,10 +113,11 @@ the workflow is strict:
## Scope discipline ## Scope discipline
v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
uploading, editing, subscriptions management, deletion or content creation — the user git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md` creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
first, then implement. in scope and already implemented — on the player and the channel page.) If the user asks
for a scope change, confirm it with them before implementing.
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route). guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).

View file

@ -1,8 +1,11 @@
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.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.VideoLibrary
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -159,22 +162,29 @@ fun VideoListScreen(
return return
} }
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
// Channel page: the header is PINNED above the grid (issue #27) rather than
// scrolling away as the first grid item — auto-focusing the first video card
// no longer hides the banner, and the Subscribe button on it stays reachable.
if (source is ListSource.Channel) {
ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
handle = source.handle,
onLeftEdge = onOpenDrawer,
)
}
LazyVerticalGrid( LazyVerticalGrid(
// Same card size as the Home rows: ~4 fit per row (user request). // Same card size as the Home rows: ~4 fit per row (user request).
columns = GridCells.Adaptive(rowCardWidth() - 12.dp), columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
) { ) {
// Page headers (search field / channel banner) live INSIDE the grid so // Search field still scrolls with its results (its own header item);
// they scroll with the content and never stack under the cards. // the channel banner is pinned above the grid instead (issue #27).
if (source is ListSource.Search) {
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) { SearchHeader(viewModel, onOpenDrawer)
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
)
else -> {}
} }
} }
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@ -393,62 +403,113 @@ private fun ScopeChip(
} }
} }
/** Channel page header: banner strip, then avatar + display name + handle, /**
* a metadata strip (subscribers/videos/created + owner, issue #14) and the * Channel page header (issue #27): the banner is the backdrop of a fixed-height
* channel description snippet above the videos. */ * header, darkened with a bottom-up gradient, with the channel identity + stats
* and a reachable Subscribe button laid over it. It's pinned above the grid so
* it never scrolls away, and the video cards start directly beneath it.
*/
@Composable @Composable
private fun ChannelHeader( private fun ChannelHeader(
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?, channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
videoTotal: Long = 0, videoTotal: Long = 0,
handle: String,
onLeftEdge: () -> Unit = {},
) { ) {
if (channelHeader == null) return if (channelHeader == null) return
val metaColor = Color(0xFF9A9A9A) val metaColor = Color(0xFFCFCFCF)
androidx.compose.foundation.layout.Column {
val banner = channelHeader.banners.maxByOrNull { it.width } val banner = channelHeader.banners.maxByOrNull { it.width }
val description = channelHeader.description?.takeIf { it.isNotBlank() }
androidx.compose.foundation.layout.Column(
Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
.background(Color.Black),
) {
// Banner area: a fixed height with the identity + stats anchored to its
// bottom. A long description flows into the black strip BELOW it rather than
// being pushed up over the banner or clipped (user request).
Box(
Modifier
.fillMaxWidth()
.height(178.dp)
.background(Color(0xFF1C1C1C)),
) {
if (banner != null) { if (banner != null) {
AsyncImage( ShimmerAsyncImage(
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.matchParentSize(),
.fillMaxWidth()
.height(120.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
) )
} }
// Darkening overlay, fading to solid black at the bottom so the banner
// blends seamlessly into the description strip beneath it.
Box(
Modifier
.matchParentSize()
.background(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color(0x33000000),
0.5f to Color(0x99000000),
1f to Color(0xFF000000),
),
),
)
// Identity + stats, bottom-left. The avatar sits beside the title (as it
// did before the redesign — user request).
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, end = 16.dp, bottom = 10.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) {
AsyncImage( ShimmerAsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333),
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, top = 10.dp) .size(56.dp)
.size(52.dp)
.clip(androidx.compose.foundation.shape.CircleShape), .clip(androidx.compose.foundation.shape.CircleShape),
) )
} }
Column(modifier = Modifier.padding(start = 16.dp)) { Column(modifier = Modifier.padding(start = 14.dp)) {
Text( Text(
text = channelHeader.displayName, text = channelHeader.displayName,
color = MaterialTheme.colorScheme.onSurface, color = Color.White,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
// "by <owner> on <platform>" instead of the raw handle (user
// request): the handle read as noise on a personal instance.
val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name }
val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host
val byline = when {
owner != null && platform != null -> "by $owner on $platform"
owner != null -> "by $owner"
platform != null -> "on $platform"
else -> ""
}
if (byline.isNotBlank()) {
Text( Text(
text = channelHeader.name + "@" + channelHeader.host, text = byline,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = Color(0xFFBDBDBD),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
} }
} }
// Metadata strip between the identity and the videos (issue #14): the }
// counts SmartTube shows, plus creation date and the owning user.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp), modifier = Modifier.padding(top = 6.dp),
) { ) {
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
@ -459,32 +520,102 @@ private fun ChannelHeader(
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
} }
} }
val owner = channelHeader.ownerAccount }
if (owner != null) { // Subscribe button, top-right over the banner; LEFT opens the sidebar so
val host = channelHeader.host // the header is never a dead end (issue #27).
val platform = AppGraph.tokenStore.instanceName ?: host SubscribeButton(
Text( handle = handle,
text = "Owned by " + (owner.displayName ?: owner.name) + onLeftEdge = onLeftEdge,
(if (platform != null) " - " + platform else ""), modifier = Modifier
color = metaColor, .align(Alignment.TopEnd)
style = MaterialTheme.typography.bodySmall, .padding(16.dp),
maxLines = 1,
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
) )
} }
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description -> // Description in the black beneath the banner: a long one grows the header
// downward, fading out of the banner that already darkens into black.
if (description != null) {
Text( Text(
text = description, text = description,
color = Color(0xFFB8B8B8), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 3, maxLines = 3,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
) )
} }
} }
} }
/**
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
* player's action: the server's follow-exists check can lag for federated
* channels, so a successful toggle trusts the user's action and re-syncs on the
* next screen entry. One request at a time ([busy]).
*/
@Composable
private fun SubscribeButton(
handle: String,
onLeftEdge: () -> Unit,
modifier: Modifier = Modifier,
) {
val repository = AppGraph.repository
val scope = androidx.compose.runtime.rememberCoroutineScope()
var subscribed by remember(handle) { mutableStateOf<Boolean?>(null) }
var busy by remember(handle) { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
LaunchedEffect(handle) {
repository.subscriptionExists(handle).onSuccess { subscribed = it }
}
val isSub = subscribed == true
val bg = when {
focused -> Color.White
isSub -> Color(0x33CE2B2B)
else -> Color(0xFFCE2B2B)
}
val fg = if (focused) Color(0xFF101010) else Color.White
Row(
modifier = modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(bg)
.onFocusChanged { focused = it.isFocused }
.onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
) {
onLeftEdge(); true
} else false
}
.dpadCenterActivatable {
if (busy) return@dpadCenterActivatable
busy = true
scope.launch {
val wasSub = subscribed == true
val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
else repository.subscribe(handle).isSuccess
if (ok) subscribed = !wasSub
busy = false
}
}
.focusable()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
) {
androidx.compose.material3.Icon(
imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
contentDescription = null,
tint = fg,
modifier = Modifier.size(18.dp),
)
Text(
text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
color = fg,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
}
@Composable @Composable
private fun headerStat( private fun headerStat(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,