home: Make the "New local videos" row reachable by DOWN

On the TV, DOWN from "Continue watching" often skipped the row below
and landed on "New from your subscriptions": the new-videos row is
rendered as a non-focusable "Loading…" while its items are still in
flight, and every FocusScrollRow auto-focused its first card when its
items arrived, so rows loaded out of order stole focus in a race.

Start fetching new local videos eagerly with the rest of the home data,
restrict the load-time auto-focus to the first row only, and cancel it
when the user has already pressed a D-pad key meanwhile. Also rename
the row to "New local videos" to distinguish it from the federated
subscription feed.

Refs #3
This commit is contained in:
Jeena 2026-10-12 09:49:15 +00:00
parent 20e6b07107
commit 1c8fff7eca

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -76,6 +77,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
if (loaded) return if (loaded) return
loaded = true loaded = true
fetchContinue() fetchContinue()
// Populate "New local videos" while the (slow, 6 detail calls) continue row is
// still resolving (#3): a user pressing DOWN too early skipped the row while it
// was empty and landed on the row below it.
recent.loadMore()
} }
/** Re-fetch after playback changed server-side positions (UX report BUG-7). /** Re-fetch after playback changed server-side positions (UX report BUG-7).
@ -159,10 +164,21 @@ fun HomeRowsScreen(
) { ) {
val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
// Any D-pad press before the (slow) continue row resolves means the user is already
// navigating: suppress the then-pending auto-focus of the first row (#3).
var userNavigated by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
val recentItems = viewModel.recent.items val recentItems = viewModel.recent.items
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(
modifier = Modifier
.fillMaxSize()
// Peek at D-pad input without consuming it: marks that the user is nav.
.onPreviewKeyEvent {
userNavigated = true
false
},
) {
item { item {
Text( Text(
text = "Continue watching", text = "Continue watching",
@ -191,12 +207,14 @@ fun HomeRowsScreen(
clickedIndex = continueRestore, clickedIndex = continueRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
upToTopBar = upToTopBar, upToTopBar = upToTopBar,
autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated },
) )
} }
} }
item { item {
Text( Text(
text = "New videos", text = "New local videos",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color.White, color = Color.White,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
@ -261,6 +279,11 @@ fun FocusScrollRow(
clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click
restoreToken: Int = 0, // bumped when playback ends restoreToken: Int = 0, // bumped when playback ends
upToTopBar: (() -> Unit)? = null, // first content row only upToTopBar: (() -> Unit)? = null, // first content row only
// Only the FIRST row auto-focuses its first card when items arrive — every other
// row would otherwise steal focus whenever it finishes loading (#3).
autoFocusOnLoad: Boolean = false,
// Screen-level gate: false while the user is already navigating.
autoFocusAllowed: () -> Boolean = { true },
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
@ -280,11 +303,14 @@ fun FocusScrollRow(
} }
} }
// On app start and destination switch: focus the FIRST card automatically // On app start and destination switch: focus the FIRST card automatically
// (user request — otherwise invisible focus confuses "where am I"). // (user request — otherwise invisible focus confuses "where am I"). Rows that
// finish loading later must not steal focus from the user (#3).
LaunchedEffect(videos.isNotEmpty()) { LaunchedEffect(videos.isNotEmpty()) {
if (videos.isNotEmpty() && focusedIndex == -1) { if (videos.isNotEmpty() && autoFocusOnLoad && focusedIndex == -1) {
kotlinx.coroutines.delay(150) // let images/measuring settle kotlinx.coroutines.delay(150) // let images/measuring settle
runCatching { focusRequesters[0]?.requestFocus() } if (autoFocusAllowed()) {
runCatching { focusRequesters[0]?.requestFocus() }
}
} }
} }
LaunchedEffect(focusedIndex) { LaunchedEffect(focusedIndex) {