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, onOpenChannel: (handle: String) -> Unit,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) { ) {
LaunchedEffect(epoch) { viewModel.load() } LaunchedEffect(epoch) { viewModel.load() }
@ -374,6 +375,7 @@ fun ChannelsScreen(
group.channels, group.channels,
onOpenChannel, onOpenChannel,
onOpenDrawer, onOpenDrawer,
drawerPulse = drawerPulse,
focused = !focusHandedOut.value && focused = !focusHandedOut.value &&
group == viewModel.userRows.firstOrNull(), group == viewModel.userRows.firstOrNull(),
onFocused = { focusHandedOut.value = true }, onFocused = { focusHandedOut.value = true },
@ -427,6 +429,7 @@ private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
focused: Boolean, focused: Boolean,
onFocused: () -> Unit = {}, onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
@ -436,6 +439,13 @@ private fun ChannelRow(
val focusRequesters = remember { HashMap<Int, FocusRequester>() } val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { mutableStateOf(-1) } 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) { LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) { if (focusedIndex > 0) {
val visible = rowState.layoutInfo.visibleItemsInfo val visible = rowState.layoutInfo.visibleItemsInfo

View file

@ -66,6 +66,20 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private set private set
private var loaded = false 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) val recent = RecentVideosViewModel(repository)
init { init {
@ -91,8 +105,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private fun fetchSubs() { private fun fetchSubs() {
viewModelScope.launch { 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 subItems = subs
subsLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
// Snapshot for the next cold start (issue #9). // Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx -> 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). */ * the rows and killed the focus-restore effect before it could run, #2). */
fun refresh() { fun refresh() {
loaded = true loaded = true
// fetchContinue kicks the subs fetch too — one pass, not two (the double
// call used to fetch subscriptions twice per refresh).
fetchContinue() fetchContinue()
fetchSubs()
recent.refresh() recent.refresh()
} }
private fun fetchContinue() { /**
fetchSubs() * One history page, enriched: each entry's resume info needs its own detail
viewModelScope.launch { * call (parallel), so a page only fetches what it appends (issue #18).
continueLoading = true * Advances [continueStart]; marks exhaustion on a short page.
val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } */
if (history == null) { private suspend fun fetchContinuePage(): List<ContinueItem> = kotlinx.coroutines.coroutineScope {
continueLoading = false val entries = repository.history(continueStart, CONTINUE_PAGE).getOrNull()
return@launch if (entries.isNullOrEmpty()) {
} if (entries != null) continueExhausted = true
// Resume info needs one detail call with ?userHistory=true per entry; emptyList()
// keep it small — only the first six videos. } else {
val items = history.take(6).map { entry -> continueStart += entries.size
if (entries.size < CONTINUE_PAGE) continueExhausted = true
entries.map { entry ->
async { async {
val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull()
val watched = detail?.userHistory?.currentTime ?: 0 val watched = detail?.userHistory?.currentTime ?: 0
@ -145,6 +166,44 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
ContinueItem(entry.video, progress) ContinueItem(entry.video, progress)
} }
}.awaitAll() }.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 continueItems = items
continueLoading = false continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
@ -158,6 +217,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
} }
companion object { 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 { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
@ -217,10 +280,12 @@ fun HomeRowsScreen(
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) { ) {
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) }
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 // 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). // navigating: suppress the then-pending auto-focus of the first row (#3).
var userNavigated by remember { mutableStateOf(false) } var userNavigated by remember { mutableStateOf(false) }
@ -265,7 +330,13 @@ fun HomeRowsScreen(
restoreToken = restoreToken, restoreToken = restoreToken,
autoFocusOnLoad = true, autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated }, 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 { item {
@ -294,6 +365,7 @@ fun HomeRowsScreen(
onLeftAtStart = onOpenDrawer, onLeftAtStart = onOpenDrawer,
clickedIndex = recentRestore, clickedIndex = recentRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
refocusPulse = drawerPulse,
) { index, video -> ) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() } LaunchedEffect(index) { viewModel.recent.loadMore() }
@ -316,7 +388,14 @@ fun HomeRowsScreen(
progressOf = { null }, progressOf = { null },
onOpenVideo = onOpenVideo, onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer, 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, autoFocusOnLoad: Boolean = false,
// Screen-level gate: false while the user is already navigating. // Screen-level gate: false while the user is already navigating.
autoFocusAllowed: () -> Boolean = { true }, 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 = { _, _ -> }, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() } val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } 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). // BACK from playback: scroll to + focus the card that was opened (user request).
LaunchedEffect(restoreToken) { LaunchedEffect(restoreToken) {
if (restoreToken > 0) { if (restoreToken > 0) {

View file

@ -95,7 +95,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var lastBackAt by rememberSaveable { mutableStateOf(0L) } var lastBackAt by rememberSaveable { mutableStateOf(0L) }
/** Close the drawer; focus falls back into the content pane. */ /** 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 // 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). // 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, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
else -> when (destination) { else -> when (destination) {
Destination.Home -> Destination.Home ->
@ -182,6 +186,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
// UP over the first row opens the drawer. // UP over the first row opens the drawer.
) )
Destination.MyVideos -> Destination.MyVideos ->
@ -191,11 +196,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(
@ -204,6 +211,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( VideoListScreen(
@ -212,6 +220,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
@ -220,6 +229,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
) )
} }
} }

View file

@ -56,6 +56,7 @@ fun VideoListScreen(
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
viewModel: VideoListViewModel = viewModel( viewModel: VideoListViewModel = viewModel(
key = "v$epoch:" + sourceKey(source), key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source), factory = VideoListViewModel.factory(source),
@ -107,6 +108,14 @@ fun VideoListScreen(
(rowCardWidth().value - 12) (rowCardWidth().value - 12)
).toInt(), ).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()) { if (viewModel.loadingInitial && items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -176,6 +185,7 @@ fun VideoListScreen(
showPrivacy = source is ListSource.MyVideos, showPrivacy = source is ListSource.MyVideos,
compact = true, compact = true,
onLeftAtStart = if (index % cardsPerRow == 0) onOpenDrawer else null, onLeftAtStart = if (index % cardsPerRow == 0) onOpenDrawer else null,
onFocused = { lastFocusedIndex = index },
modifier = Modifier modifier = Modifier
.focusRequester(fr), .focusRequester(fr),
) )