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 8496cb8..4057eab 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,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( - 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.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); - // the channel banner is pinned above the grid instead (issue #27). - if (source is ListSource.Search) { + // 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) }) { - 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) { @@ -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( @@ -523,22 +550,42 @@ private fun ChannelHeader( .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), - ) + // 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 = 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 { + 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 * player's action: the server's follow-exists check can lag for federated