Compare commits

...

3 commits

Author SHA1 Message Date
ab1890b931 player: scroll the info text in thirds of a screen (#23)
One D-pad press moved 85% of the panel, so consecutive stops barely overlapped
and the middle of a long description was never on screen — on
tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN Jeena could not read the passage about
the credit-card outlier at all (#23 review). A press now moves about a third of
a screenful, so each stop repeats two thirds of the previous one and every line
gets shown on the way down.
2026-10-16 13:04:37 +00:00
d338e50375 channels: never open the screen without a selection (#32)
Coming back from a channel page hands that channel's card the focus, but the
handle only wins when the card is still in the rows. A channel that was
renamed, unsubscribed, or simply never carried in the rows left every row
asking for no focus at all, so opening Channels from the drawer put the remote
nowhere: "when I click on Channels, none is selected, it should behave like
video cards" (#32 review). The fallback row is now chosen by whether the
handle exists anywhere in the rows, and a row whose wanted card never became
focusable takes its first card instead of staying unselected.
2026-10-16 13:03:28 +00:00
06a0fabf33 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.
2026-10-16 13:03:04 +00:00
3 changed files with 46 additions and 23 deletions

View file

@ -245,7 +245,11 @@ private fun DescriptionScroll(
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
.onPreviewKeyEvent { ke ->
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
// One press moves a third of a screenful, so consecutive stops
// overlap and every line of a long description gets shown (issue
// #23 review: the old 85% step jumped past the middle of the text
// on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN).
val step = (viewportPx * 0.34f).coerceAtLeast(150f)
val code = ke.nativeKeyEvent.keyCode
// Match Compose's normalised key AND the raw keycode: remotes don't all
// report the same native codes, and matching only the raw one left UP

View file

@ -358,6 +358,14 @@ fun ChannelsScreen(
// as stats stream in the rows re-sort, and re-focusing their first card
// would steal focus mid-navigation (user: "up jumps to the first row").
val focusHandedOut = remember { mutableStateOf(false) }
// The channel we came back from only wins the focus while it is still
// in the rows. When it isn't (renamed, unsubscribed, or the rows simply
// never carried it) every row used to ask for no focus at all — "when I
// click on Channels, none is selected" (#32 review). Fall back to the
// first row in that case, like the video cards do.
val focusRowExists = focusHandle != null && viewModel.userRows.any {
group -> group.channels.any { it.name + "@" + it.host == focusHandle }
}
LazyColumn(Modifier.fillMaxSize()) {
viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) {
@ -379,7 +387,7 @@ fun ChannelsScreen(
onOpenDrawer,
drawerPulse = drawerPulse,
focused = !focusHandedOut.value && (
if (focusHandle != null) isTargetRow
if (focusRowExists) isTargetRow
else group == viewModel.userRows.firstOrNull()
),
focusIndex = if (isTargetRow) target else 0,
@ -479,6 +487,11 @@ private fun ChannelRow(
return@LaunchedEffect
}
}
// The wanted card never became focusable (its row scrolled past it,
// or the handle went stale): take the first card instead, so the
// screen is never left with nothing selected (#32 review).
val first = focusRequesters[0]
if (first != null && runCatching { first.requestFocus() }.isSuccess) onFocused()
}
}
}

View file

@ -78,6 +78,29 @@ fun VideoListScreen(
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
}
// 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(