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:
parent
1f8616d26f
commit
33abf61cd1
4 changed files with 136 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue