From 3854c6999a170d0834083d0be5ec16468065396c Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Oct 2026 13:03:04 +0000 Subject: [PATCH] channel page: park the grid before the first card takes focus (#27, #31) Two things were still wrong on the channel page and the list screens. The header is a grid item again, so it scrolls with the videos (#27 review), but handing the first video card the focus makes the framework pull that card fully into view: the grid animates up, and the old park-at-top effect then snapped it back down. Jeena still saw that jump "when the first video gets focus". The park now runs before the focus handover, and only while the grid is actually displaced, so there is nothing left to undo. The first loading card on My videos, Search, Subscriptions, History and the channel pages used to span the whole line, which read as a wide band across four columns; Jeena wanted the plain single card in one column of one row (#31 review), so the placeholder item no longer spans the line. --- .../ui/screens/list/VideoListScreen.kt | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) 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 4057eab..ff7a4e7 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 @@ -78,6 +78,29 @@ fun VideoListScreen( val requesters = androidx.compose.runtime.remember(items.size, epoch) { androidx.compose.runtime.mutableStateMapOf() } + // Scroll position survives destination switches (per source), not just player exits. + val gridState = androidx.compose.runtime.saveable.rememberSaveable( + inputs = arrayOf(epoch), + key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + ) { androidx.compose.foundation.lazy.grid.LazyGridState() } + + if (source is ListSource.Channel) { + // Fetch the banner/avatar/counts for the channel page header. + LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } + // Park the grid at the top as soon as the page has content, BEFORE the + // first card takes focus (the effect below). It used to run after it: + // handing the first card the focus animates the grid up (a focused card + // that sits partly off-screen is pulled into view), and this effect then + // snapped it back down — the jump that was still reported on the channel + // page when the first video got focus (#27 review). Doing the park first, + // and only while the grid is actually displaced, leaves nothing to undo. + LaunchedEffect(items.isNotEmpty(), source) { + if (items.isNotEmpty() && gridState.firstVisibleItemIndex > 0) { + runCatching { gridState.scrollToItem(0) } + } + } + } + // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } // Which card was opened last — re-focus it when playback ends (UX report BUG-3). @@ -86,6 +109,9 @@ fun VideoListScreen( // With a running search the first RESULT gets focus; without one the // field does (the SearchHeader's own effect) — never both (issue #20). if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) { + // The channel page parks its grid at the top just above; wait for that + // beat so the focus handover can never leave a scroll to undo (#27). + if (source is ListSource.Channel) kotlinx.coroutines.delay(250) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } } } @@ -100,26 +126,6 @@ fun VideoListScreen( lastOpenedIndex = -1 } } - // Scroll position survives destination switches (per source), not just player exits. - val gridState = androidx.compose.runtime.saveable.rememberSaveable( - inputs = arrayOf(epoch), - key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, - ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - - 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 // (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with // zero spacing, and the content pane eats 40dp (8 start + 32 end), so the @@ -217,7 +223,7 @@ fun VideoListScreen( // Placeholder cards while the page's first chunk loads (issue #19): // shaped like the real cards, with the faint shimmer, on every // VideoListScreen (My videos, Search, Subscriptions, History, channel pages). - item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + item(key = "initial-placeholders") { LaunchedEffect(source) { viewModel.loadMore(force = true) } // A single loading card, not a full fake grid (issue #31). ShimmerCard(