From 21d8290f1d51254898e4e10961b2c44c855f6415 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 15 Oct 2026 12:23:36 +0000 Subject: [PATCH] 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 on " 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. --- AGENTS.md | 9 +- .../ui/screens/list/VideoListScreen.kt | 299 +++++++++++++----- 2 files changed, 220 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58ee33e..b08eb14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,10 +113,11 @@ the workflow is strict: ## Scope discipline -v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in -uploading, editing, subscriptions management, deletion or content creation — the user -explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md` -first, then implement. +Shipped features live in `README.md`; anything else is tracked as Forgejo issues at +git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content +creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is +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 guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route). \ No newline at end of file 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 6e9f037..cedb561 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,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 on " 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(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, + ) } }