From 33abf61cd16a97af536f17709724ee72ae8fb71e Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 14 Oct 2026 00:18:57 +0000 Subject: [PATCH] home: rows grow on demand; refocus content when the drawer closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jtubetv/ui/screens/ChannelsScreen.kt | 10 ++ .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 121 +++++++++++++++--- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 12 +- .../ui/screens/list/VideoListScreen.kt | 10 ++ 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 302cfbb..0b50bae 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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, 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() } 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 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index c3d23da..b397537 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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 = 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 create(modelClass: Class): 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() } 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) { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index b589e53..a83fbd2 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 227eb0e..1310324 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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), )