Compare commits
No commits in common. "ab1890b9311a9d560a791d99f372ad0a57a7e689" and "ddacf4890627dcadbfe626b81a13c9b058cf33be" have entirely different histories.
ab1890b931
...
ddacf48906
3 changed files with 23 additions and 46 deletions
|
|
@ -245,11 +245,7 @@ private fun DescriptionScroll(
|
||||||
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
// One press moves a third of a screenful, so consecutive stops
|
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
|
||||||
// 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
|
val code = ke.nativeKeyEvent.keyCode
|
||||||
// Match Compose's normalised key AND the raw keycode: remotes don't all
|
// 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
|
// report the same native codes, and matching only the raw one left UP
|
||||||
|
|
|
||||||
|
|
@ -358,14 +358,6 @@ fun ChannelsScreen(
|
||||||
// as stats stream in the rows re-sort, and re-focusing their first card
|
// 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").
|
// would steal focus mid-navigation (user: "up jumps to the first row").
|
||||||
val focusHandedOut = remember { mutableStateOf(false) }
|
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()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
viewModel.userRows.forEach { group ->
|
viewModel.userRows.forEach { group ->
|
||||||
item(key = "hdr-" + group.ownerName) {
|
item(key = "hdr-" + group.ownerName) {
|
||||||
|
|
@ -387,7 +379,7 @@ fun ChannelsScreen(
|
||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
focused = !focusHandedOut.value && (
|
focused = !focusHandedOut.value && (
|
||||||
if (focusRowExists) isTargetRow
|
if (focusHandle != null) isTargetRow
|
||||||
else group == viewModel.userRows.firstOrNull()
|
else group == viewModel.userRows.firstOrNull()
|
||||||
),
|
),
|
||||||
focusIndex = if (isTargetRow) target else 0,
|
focusIndex = if (isTargetRow) target else 0,
|
||||||
|
|
@ -487,11 +479,6 @@ private fun ChannelRow(
|
||||||
return@LaunchedEffect
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,29 +78,6 @@ fun VideoListScreen(
|
||||||
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
||||||
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
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.
|
// Pull initial focus out of the sidebar rail and into the content grid.
|
||||||
val initialFocus = remember { FocusRequester() }
|
val initialFocus = remember { FocusRequester() }
|
||||||
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
||||||
|
|
@ -109,9 +86,6 @@ fun VideoListScreen(
|
||||||
// With a running search the first RESULT gets focus; without one the
|
// With a running search the first RESULT gets focus; without one the
|
||||||
// field does (the SearchHeader's own effect) — never both (issue #20).
|
// field does (the SearchHeader's own effect) — never both (issue #20).
|
||||||
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
|
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() }
|
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +100,26 @@ fun VideoListScreen(
|
||||||
lastOpenedIndex = -1
|
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
|
// 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
|
// (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
|
// zero spacing, and the content pane eats 40dp (8 start + 32 end), so the
|
||||||
|
|
@ -223,7 +217,7 @@ fun VideoListScreen(
|
||||||
// Placeholder cards while the page's first chunk loads (issue #19):
|
// Placeholder cards while the page's first chunk loads (issue #19):
|
||||||
// shaped like the real cards, with the faint shimmer, on every
|
// shaped like the real cards, with the faint shimmer, on every
|
||||||
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
|
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
|
||||||
item(key = "initial-placeholders") {
|
item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||||
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
||||||
// A single loading card, not a full fake grid (issue #31).
|
// A single loading card, not a full fake grid (issue #31).
|
||||||
ShimmerCard(
|
ShimmerCard(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue