home: rows grow on demand; refocus content when the drawer closes

The three home rows were capped: continue at six, subscriptions at
twelve, with no way to move past. Continue and subscriptions now page
in place — each new page fetches only its own entries (the continue
row's resume info costs one detail call per entry, so a page enriches
what it appends and never re-reads earlier ones; the "New videos" row
already paged). Rows stay lazy lists and the image cache is LRU, so a
user scrolling far does not pile up memory.

Closing the drawer without a selection (RIGHT, tap-outside, BACK) also
turned out to orphan the D-pad — the drawer's focused item left the
composition and nothing took its place; every later press died. The
closing now bumps a pulse the screens observe to re-focus the last
focused card.

Closes #18
This commit is contained in:
Jeena 2026-10-14 00:18:57 +00:00
parent 1f8616d26f
commit 33abf61cd1
4 changed files with 136 additions and 17 deletions

View file

@ -333,6 +333,7 @@ fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit,
epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) {
LaunchedEffect(epoch) { viewModel.load() }
@ -374,6 +375,7 @@ fun ChannelsScreen(
group.channels,
onOpenChannel,
onOpenDrawer,
drawerPulse = drawerPulse,
focused = !focusHandedOut.value &&
group == viewModel.userRows.firstOrNull(),
onFocused = { focusHandedOut.value = true },
@ -427,6 +429,7 @@ private fun ChannelRow(
channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
focused: Boolean,
onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
@ -436,6 +439,13 @@ private fun ChannelRow(
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { mutableStateOf(-1) }
// Drawer closed without a selection → give the row's last card focus back.
LaunchedEffect(drawerPulse) {
if (drawerPulse > 0 && focusedIndex >= 0) {
runCatching { focusRequesters[focusedIndex]?.requestFocus() }
}
}
LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) {
val visible = rowState.layoutInfo.visibleItemsInfo

View file

@ -66,6 +66,20 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private set
private var loaded = false
// On-demand paging of the home rows (issue #18). Rows grow by one page per
// trigger, nothing is re-fetched for pages the user never scrolls to, and
// memory stays bounded: rows are lazy Lists (off-screen cards decompose)
// and Coil's image cache is LRU.
private var continueStart = 0L
private var continueInFlight = false
var continueExhausted by mutableStateOf(false)
private set
private var subsStart = 0L
private var subsInFlight = false
var subsExhausted by mutableStateOf(false)
private set
private var subsLoading = false
val recent = RecentVideosViewModel(repository)
init {
@ -91,8 +105,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private fun fetchSubs() {
viewModelScope.launch {
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subsLoading = true
val subs = repository.subscriptionVideos(0, SUB_PAGE).getOrNull()?.data ?: emptyList()
subsStart = subs.size.toLong() // for subsMore() (issue #18)
subsExhausted = subs.size < SUB_PAGE
subItems = subs
subsLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
// Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx ->
@ -119,23 +137,26 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
* the rows and killed the focus-restore effect before it could run, #2). */
fun refresh() {
loaded = true
// fetchContinue kicks the subs fetch too — one pass, not two (the double
// call used to fetch subscriptions twice per refresh).
fetchContinue()
fetchSubs()
recent.refresh()
}
private fun fetchContinue() {
fetchSubs()
viewModelScope.launch {
continueLoading = true
val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() }
if (history == null) {
continueLoading = false
return@launch
}
// Resume info needs one detail call with ?userHistory=true per entry;
// keep it small — only the first six videos.
val items = history.take(6).map { entry ->
/**
* One history page, enriched: each entry's resume info needs its own detail
* call (parallel), so a page only fetches what it appends (issue #18).
* Advances [continueStart]; marks exhaustion on a short page.
*/
private suspend fun fetchContinuePage(): List<ContinueItem> = kotlinx.coroutines.coroutineScope {
val entries = repository.history(continueStart, CONTINUE_PAGE).getOrNull()
if (entries.isNullOrEmpty()) {
if (entries != null) continueExhausted = true
emptyList()
} else {
continueStart += entries.size
if (entries.size < CONTINUE_PAGE) continueExhausted = true
entries.map { entry ->
async {
val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull()
val watched = detail?.userHistory?.currentTime ?: 0
@ -145,6 +166,44 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
ContinueItem(entry.video, progress)
}
}.awaitAll()
}
}
/** Append the next page to an already-rendered row. */
fun continueMore() {
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
continueInFlight = true
viewModelScope.launch {
val page = fetchContinuePage()
if (page.isNotEmpty()) {
continueItems = continueItems + page
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
}
continueInFlight = false
}
}
/** Second+ subscription page (see continueMore). */
fun subsMore() {
if (subsInFlight || subsExhausted || subItems.isEmpty()) return
subsInFlight = true
viewModelScope.launch {
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
subsStart += page.size
if (page.size < SUB_PAGE) subsExhausted = true
if (page.isNotEmpty()) {
subItems = subItems + page
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
}
}
private fun fetchContinue() {
fetchSubs()
viewModelScope.launch {
continueLoading = true
val items = fetchContinuePage()
continueItems = items
continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
@ -158,6 +217,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
}
companion object {
/** Page sizes for the on-demand row growing (continue page = 6 detail calls). */
private const val CONTINUE_PAGE = 6
private const val SUB_PAGE = 12
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
@ -217,10 +280,12 @@ fun HomeRowsScreen(
restoreToken: Int = 0,
epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) {
val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
val subRestore = 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) }
@ -265,7 +330,13 @@ fun HomeRowsScreen(
restoreToken = restoreToken,
autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated },
)
refocusPulse = drawerPulse,
) { index, video ->
// Grow the row on demand (issue #18), same hook as the recent row.
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.continueMore() }
}
}
}
}
item {
@ -294,6 +365,7 @@ fun HomeRowsScreen(
onLeftAtStart = onOpenDrawer,
clickedIndex = recentRestore,
restoreToken = restoreToken,
refocusPulse = drawerPulse,
) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
@ -316,7 +388,14 @@ fun HomeRowsScreen(
progressOf = { null },
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
)
clickedIndex = subRestore,
restoreToken = restoreToken,
refocusPulse = drawerPulse,
) { index, video ->
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.subsMore() }
}
}
}
}
}
@ -339,11 +418,21 @@ fun FocusScrollRow(
autoFocusOnLoad: Boolean = false,
// Screen-level gate: false while the user is already navigating.
autoFocusAllowed: () -> Boolean = { true },
// Incremented by the home drawer closing without a selection: re-focus the
// row's last-focused card, otherwise the D-pad is orphaned (issue #16 bug).
// NOTE: stays before [afterItem] — the trailing lambda binds to the last
// declared parameter.
refocusPulse: Int = 0,
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) {
val rowState = rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) }
LaunchedEffect(refocusPulse) {
if (refocusPulse > 0 && focusedIndex >= 0) {
runCatching { focusRequesters[focusedIndex]?.requestFocus() }
}
}
// BACK from playback: scroll to + focus the card that was opened (user request).
LaunchedEffect(restoreToken) {
if (restoreToken > 0) {

View file

@ -95,7 +95,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var lastBackAt by rememberSaveable { mutableStateOf(0L) }
/** Close the drawer; focus falls back into the content pane. */
val dismissDrawer = { drawerOpen = false }
// Closing the drawer without a selection removes the drawer's focused item
// and orphans the D-pad; the pulse re-focuses the screen's last card (#16).
var drawerPulse by rememberSaveable { mutableStateOf(0) }
val dismissDrawer = { drawerOpen = false; drawerPulse++ }
// After BACK from the player: bump the restore token so lists re-focus the opened
// card and re-fetch positions (UX report BUG-3 + BUG-7).
@ -174,6 +177,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
else -> when (destination) {
Destination.Home ->
@ -182,6 +186,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
// UP over the first row opens the drawer.
)
Destination.MyVideos ->
@ -191,11 +196,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
Destination.Search ->
VideoListScreen(
@ -204,6 +211,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
Destination.Subscriptions ->
VideoListScreen(
@ -212,6 +220,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
Destination.History ->
VideoListScreen(
@ -220,6 +229,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
)
}
}

View file

@ -56,6 +56,7 @@ fun VideoListScreen(
restoreToken: Int = 0,
epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: VideoListViewModel = viewModel(
key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source),
@ -107,6 +108,14 @@ fun VideoListScreen(
(rowCardWidth().value - 12)
).toInt(),
)
// Last focused card for the drawer-close refocus (issue #16 bug): closing
// the drawer without a selection orphans the D-pad — re-focus the card.
var lastFocusedIndex by remember { mutableStateOf(-1) }
LaunchedEffect(drawerPulse) {
if (drawerPulse > 0 && lastFocusedIndex >= 0) {
runCatching { requesters[lastFocusedIndex]?.requestFocus() }
}
}
if (viewModel.loadingInitial && items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -176,6 +185,7 @@ fun VideoListScreen(
showPrivacy = source is ListSource.MyVideos,
compact = true,
onLeftAtStart = if (index % cardsPerRow == 0) onOpenDrawer else null,
onFocused = { lastFocusedIndex = index },
modifier = Modifier
.focusRequester(fr),
)