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).
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { if (source is ListSource.Search) {
when (source) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer) 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,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 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)),
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { // Darkening overlay, fading to solid black at the bottom so the banner
val avatar = channelHeader.avatars.maxByOrNull { it.width } // blends seamlessly into the description strip beneath it.
if (avatar != null) { Box(
AsyncImage( Modifier
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), .matchParentSize()
contentDescription = null, .background(
contentScale = ContentScale.Crop, androidx.compose.ui.graphics.Brush.verticalGradient(
modifier = Modifier 0f to Color(0x33000000),
.padding(start = 16.dp, top = 10.dp) 0.5f to Color(0x99000000),
.size(52.dp) 1f to Color(0xFF000000),
.clip(androidx.compose.foundation.shape.CircleShape), ),
) ),
} )
Column(modifier = Modifier.padding(start = 16.dp)) { // Identity + stats, bottom-left. The avatar sits beside the title (as it
Text( // did before the redesign — user request).
text = channelHeader.displayName, Column(
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier
style = MaterialTheme.typography.titleLarge, .align(Alignment.BottomStart)
maxLines = 1, .padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
)
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),
) { ) {
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) Row(verticalAlignment = Alignment.CenterVertically) {
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount val avatar = channelHeader.avatars.maxByOrNull { it.width }
if (videoCount > 0) { if (avatar != null) {
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor) 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) { Row(
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) 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 // Subscribe button, top-right over the banner; LEFT opens the sidebar so
if (owner != null) { // the header is never a dead end (issue #27).
val host = channelHeader.host SubscribeButton(
val platform = AppGraph.tokenStore.instanceName ?: host handle = handle,
Text( onLeftEdge = onLeftEdge,
text = "Owned by " + (owner.displayName ?: owner.name) + modifier = Modifier
(if (platform != null) " - " + platform else ""), .align(Alignment.TopEnd)
color = metaColor, .padding(16.dp),
style = MaterialTheme.typography.bodySmall, )
maxLines = 1, }
modifier = Modifier.padding(start = 16.dp, top = 4.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) {
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description -> Text(
Text( text = description,
text = description, color = Color(0xFFCCCCCC),
color = Color(0xFFB8B8B8), 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 = 12.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.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,
)
} }
} }