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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@ -76,6 +77,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
if (loaded) return
loaded = true
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).
@ -159,10 +164,21 @@ fun HomeRowsScreen(
) {
val continueRestore = 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(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
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 {
Text(
text = "Continue watching",
@ -191,12 +207,14 @@ fun HomeRowsScreen(
clickedIndex = continueRestore,
restoreToken = restoreToken,
upToTopBar = upToTopBar,
autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated },
)
}
}
item {
Text(
text = "New videos",
text = "New local videos",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
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
restoreToken: Int = 0, // bumped when playback ends
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 = { _, _ -> },
) {
val rowState = rememberLazyListState()
@ -280,13 +303,16 @@ fun FocusScrollRow(
}
}
// 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()) {
if (videos.isNotEmpty() && focusedIndex == -1) {
if (videos.isNotEmpty() && autoFocusOnLoad && focusedIndex == -1) {
kotlinx.coroutines.delay(150) // let images/measuring settle
if (autoFocusAllowed()) {
runCatching { focusRequesters[0]?.requestFocus() }
}
}
}
LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) {
val visible = rowState.layoutInfo.visibleItemsInfo