diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt index 9cdc38d..7666884 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -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 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 57d4fa3..4207968 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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() } } } 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(