channel: header scrolls with the videos again, reserved height, fallbacks (#27)

Review follow-ups on the channel page header:

- It is a grid item again instead of being pinned, so scrolling the videos moves
  it out of the way rather than leaving them to slide behind it. The redesigned
  header is compact enough to stay fully visible when the first card takes
  focus, and the page parks itself at the top on entry so the Subscribe button
  is on screen.
- Its height is reserved before the channel loads, so the cards no longer jump
  down when it arrives.
- A channel with no banner gets a gradient derived from a hash of its handle
  instead of flat black, and one with no icon gets a stand-in avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeena 2026-10-15 14:21:00 +00:00
parent f8a36a73e5
commit 105e1c82fe

View file

@ -1,5 +1,6 @@
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.AccountCircle
import androidx.compose.material.icons.filled.Add 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.Check
@ -108,6 +109,15 @@ fun VideoListScreen(
if (source is ListSource.Channel) { if (source is ListSource.Channel) {
// Fetch the banner/avatar/counts for the channel page header. // Fetch the banner/avatar/counts for the channel page header.
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
// Focusing the first card nudges the grid down far enough to clip the
// header's top (and its Subscribe button); the header and the first row
// both fit, so park the grid back at the top when the page opens.
LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { gridState.scrollToItem(0) }
}
}
} }
// Row starts of the grid: LEFT from a row's first card opens the drawer // Row starts of the grid: LEFT from a row's first card opens the drawer
@ -162,29 +172,27 @@ 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.fillMaxWidth().weight(1f).padding(vertical = 8.dp), modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) { ) {
// Search field still scrolls with its results (its own header item); // Page headers scroll WITH the content (issue #27 review: a pinned header
// the channel banner is pinned above the grid instead (issue #27). // stayed put and the videos slid behind it). The header is compact enough
if (source is ListSource.Search) { // that it's still fully visible when the first card takes focus.
if (source is ListSource.Search || source is ListSource.Channel) {
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
SearchHeader(viewModel, onOpenDrawer) when (source) {
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
handle = source.handle,
onLeftEdge = onOpenDrawer,
)
else -> {}
}
} }
} }
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@ -253,7 +261,6 @@ fun VideoListScreen(
} }
} }
} }
}
} }
@Composable @Composable
@ -399,8 +406,11 @@ private fun ScopeChip(
/** /**
* Channel page header (issue #27): the banner is the backdrop of a fixed-height * 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 * 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 * and a reachable Subscribe button laid over it. It scrolls with the video grid.
* it never scrolls away, and the video cards start directly beneath it. *
* Its height is the same before and after the channel loads, so the cards never
* get pushed down when it arrives (issue #27 review), and a channel with no
* banner gets a colour gradient derived from its handle instead of flat black.
*/ */
@Composable @Composable
private fun ChannelHeader( private fun ChannelHeader(
@ -409,10 +419,10 @@ private fun ChannelHeader(
handle: String, handle: String,
onLeftEdge: () -> Unit = {}, onLeftEdge: () -> Unit = {},
) { ) {
if (channelHeader == null) return
val metaColor = Color(0xFFCFCFCF) val metaColor = Color(0xFFCFCFCF)
val banner = channelHeader.banners.maxByOrNull { it.width } val banner = channelHeader?.banners?.maxByOrNull { it.width }
val description = channelHeader.description?.takeIf { it.isNotBlank() } val description = channelHeader?.description?.takeIf { it.isNotBlank() }
val accent = remember(handle) { channelAccent(handle) }
androidx.compose.foundation.layout.Column( androidx.compose.foundation.layout.Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -421,13 +431,12 @@ private fun ChannelHeader(
.background(Color.Black), .background(Color.Black),
) { ) {
// Banner area: a fixed height with the identity + stats anchored to its // 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 // bottom; the description sits in the black strip below it.
// being pushed up over the banner or clipped (user request).
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(178.dp) .height(178.dp)
.background(Color(0xFF1C1C1C)), .background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
) { ) {
if (banner != null) { if (banner != null) {
ShimmerAsyncImage( ShimmerAsyncImage(
@ -451,7 +460,9 @@ private fun ChannelHeader(
), ),
) )
// Identity + stats, bottom-left. The avatar sits beside the title (as it // Identity + stats, bottom-left. The avatar sits beside the title (as it
// did before the redesign — user request). // did before the redesign — user request). Only once the channel loaded;
// the box above keeps the height reserved in the meantime.
if (channelHeader != null) {
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@ -469,6 +480,22 @@ private fun ChannelHeader(
.size(56.dp) .size(56.dp)
.clip(androidx.compose.foundation.shape.CircleShape), .clip(androidx.compose.foundation.shape.CircleShape),
) )
} else {
// A channel with no icon gets a stand-in, not a gap (#27 review).
Box(
Modifier
.size(56.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0x40FFFFFF)),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
tint = Color(0xFFE8E8E8),
modifier = Modifier.size(42.dp),
)
}
} }
Column(modifier = Modifier.padding(start = 14.dp)) { Column(modifier = Modifier.padding(start = 14.dp)) {
Text( Text(
@ -523,22 +550,42 @@ private fun ChannelHeader(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(16.dp), .padding(16.dp),
) )
}
} }
// Description in the black beneath the banner: a long one grows the header // Description strip in the black beneath the banner. Fixed height so the
// downward, fading out of the banner that already darkens into black. // header measures the same before and after the channel loads (#27 review).
if (description != null) { Box(
Text( Modifier
text = description, .fillMaxWidth()
color = Color(0xFFCCCCCC), .height(42.dp)
style = MaterialTheme.typography.bodySmall, .padding(start = 16.dp, end = 16.dp, top = 6.dp),
maxLines = 3, ) {
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, if (description != null) {
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp), Text(
) text = description,
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
} }
} }
} }
/**
* Deterministic backdrop colours for a channel without a banner (#27 review):
* the handle's hash picks a hue, so every channel keeps the same recognisable
* gradient instead of a flat black header.
*/
private fun channelAccent(handle: String): List<Color> {
val hue = ((handle.hashCode() ushr 8) % 360 + 360) % 360
return listOf(
Color.hsv(hue.toFloat(), 0.45f, 0.42f),
Color.hsv(((hue + 38) % 360).toFloat(), 0.55f, 0.16f),
)
}
/** /**
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the * Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
* player's action: the server's follow-exists check can lag for federated * player's action: the server's follow-exists check can lag for federated