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:
parent
f8a36a73e5
commit
105e1c82fe
1 changed files with 85 additions and 38 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package net.jeena.jtubetv.ui.screens.list
|
||||
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.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
|
|
@ -108,6 +109,15 @@ fun VideoListScreen(
|
|||
if (source is ListSource.Channel) {
|
||||
// Fetch the banner/avatar/counts for the channel page header.
|
||||
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
|
||||
|
|
@ -162,29 +172,27 @@ 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(
|
||||
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),
|
||||
) {
|
||||
// Page headers scroll WITH the content (issue #27 review: a pinned header
|
||||
// stayed put and the videos slid behind it). The header is compact enough
|
||||
// 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) }) {
|
||||
when (source) {
|
||||
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
|
||||
is ListSource.Channel -> ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
handle = source.handle,
|
||||
onLeftEdge = onOpenDrawer,
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
|
||||
) {
|
||||
// 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) {
|
||||
|
|
@ -253,7 +261,6 @@ fun VideoListScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -399,8 +406,11 @@ private fun ScopeChip(
|
|||
/**
|
||||
* 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.
|
||||
* and a reachable Subscribe button laid over it. It scrolls with the video grid.
|
||||
*
|
||||
* 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
|
||||
private fun ChannelHeader(
|
||||
|
|
@ -409,10 +419,10 @@ private fun ChannelHeader(
|
|||
handle: String,
|
||||
onLeftEdge: () -> Unit = {},
|
||||
) {
|
||||
if (channelHeader == null) return
|
||||
val metaColor = Color(0xFFCFCFCF)
|
||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||
val description = channelHeader.description?.takeIf { it.isNotBlank() }
|
||||
val banner = channelHeader?.banners?.maxByOrNull { it.width }
|
||||
val description = channelHeader?.description?.takeIf { it.isNotBlank() }
|
||||
val accent = remember(handle) { channelAccent(handle) }
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -421,13 +431,12 @@ private fun ChannelHeader(
|
|||
.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).
|
||||
// bottom; the description sits in the black strip below it.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(178.dp)
|
||||
.background(Color(0xFF1C1C1C)),
|
||||
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
||||
) {
|
||||
if (banner != null) {
|
||||
ShimmerAsyncImage(
|
||||
|
|
@ -451,7 +460,9 @@ private fun ChannelHeader(
|
|||
),
|
||||
)
|
||||
// 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(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
|
|
@ -469,6 +480,22 @@ private fun ChannelHeader(
|
|||
.size(56.dp)
|
||||
.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)) {
|
||||
Text(
|
||||
|
|
@ -524,19 +551,39 @@ private fun ChannelHeader(
|
|||
.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.
|
||||
}
|
||||
// Description strip in the black beneath the banner. Fixed height so the
|
||||
// header measures the same before and after the channel loads (#27 review).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
) {
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
color = Color(0xFFCCCCCC),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
maxLines = 2,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue