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

@ -1,8 +1,11 @@
package net.jeena.jtubetv.ui.screens.list
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.Check
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.VideoLibrary
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -159,22 +162,29 @@ fun VideoListScreen(
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(
// Same card size as the Home rows: ~4 fit per row (user request).
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
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
// they scroll with the content and never stack under the cards.
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
when (source) {
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
)
else -> {}
// Search field still scrolls with its results (its own header item);
// 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) }) {
SearchHeader(viewModel, onOpenDrawer)
}
}
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@ -393,95 +403,216 @@ 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 description snippet above the videos. */
/**
* Channel page header (issue #27): the banner is the backdrop of a fixed-height
* 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
private fun ChannelHeader(
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
videoTotal: Long = 0,
handle: String,
onLeftEdge: () -> Unit = {},
) {
if (channelHeader == null) return
val metaColor = Color(0xFF9A9A9A)
androidx.compose.foundation.layout.Column {
val banner = channelHeader.banners.maxByOrNull { it.width }
val metaColor = Color(0xFFCFCFCF)
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) {
AsyncImage(
ShimmerAsyncImage(
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)),
modifier = Modifier.matchParentSize(),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = channelHeader.avatars.maxByOrNull { it.width }
if (avatar != null) {
AsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 16.dp, top = 10.dp)
.size(52.dp)
.clip(androidx.compose.foundation.shape.CircleShape),
)
}
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
text = channelHeader.displayName,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
)
Text(
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),
// 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),
) {
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)
Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = channelHeader.avatars.maxByOrNull { it.width }
if (avatar != null) {
ShimmerAsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null,
contentScale = ContentScale.Crop,
base = Color(0xFF333333),
modifier = Modifier
.size(56.dp)
.clip(androidx.compose.foundation.shape.CircleShape),
)
}
Column(modifier = Modifier.padding(start = 14.dp)) {
Text(
text = channelHeader.displayName,
color = Color.White,
style = MaterialTheme.typography.titleLarge,
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 = byline,
color = Color(0xFFBDBDBD),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
}
}
if (channelHeader.createdAt != null) {
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 6.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),
)
}
// Subscribe button, top-right over the banner; LEFT opens the sidebar so
// the header is never a dead end (issue #27).
SubscribeButton(
handle = handle,
onLeftEdge = onLeftEdge,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp),
)
}
// 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 = description,
color = Color(0xFFCCCCCC),
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 = 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,
)
}
}