channels: load at launch, keep the opened channel selected (#32, #34)

Assembling the channel rows is slow, so it now starts when the app launches
instead of when the view is first opened; opening the view usually finds the
rows already there, and a second pass is skipped while one is in flight or the
data is still fresh (#32).

Coming back from a channel page re-selects the channel that was opened, scrolling
its row back to it, instead of snapping to the first one (#34).

Also drops the custom focusRestorer fallback added for #29: handing back a
requester whose card had scrolled out of composition crashed the app with
"FocusRequester is not initialized" when focus re-entered a scrolled row (hit on
the Channels screen). The plain restorer keeps the #29 behaviour without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeena 2026-10-15 14:21:10 +00:00
parent 105e1c82fe
commit 654963436f
3 changed files with 60 additions and 9 deletions

View file

@ -127,11 +127,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
// its shimmer placeholder rows until the (fast-path, few-request) load lands, // its shimmer placeholder rows until the (fast-path, few-request) load lands,
// instead of painting a stale snapshot that reshuffled as stats streamed in. // instead of painting a stale snapshot that reshuffled as stats streamed in.
/** Guards against a second pass while the first is still running (issue #32:
* the load now starts at app launch, so opening the view can overlap it). */
private var inFlight = false
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window: // Re-entry (destination switch away + back) inside the freshness window:
// repaint the in-memory rows and skip the network (issue #13) — rows are // repaint the in-memory rows and skip the network (issue #13) — rows are
// never torn down on re-entry; an account switch builds a new ViewModel. // never torn down on re-entry; an account switch builds a new ViewModel.
if (inFlight) return
if (!force && userRows.isNotEmpty() && fresh()) return if (!force && userRows.isNotEmpty() && fresh()) return
inFlight = true
loading = true loading = true
error = null error = null
viewModelScope.launch { viewModelScope.launch {
@ -160,6 +166,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {
loading = false loading = false
inFlight = false
} }
} }
} }
@ -310,6 +317,8 @@ fun ChannelsScreen(
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0, drawerPulse: Int = 0,
/** Channel opened last — gets the focus back on return (issue #34). */
focusHandle: String? = null,
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() }
@ -358,13 +367,22 @@ fun ChannelsScreen(
) )
} }
item(key = "row-" + group.ownerName) { item(key = "row-" + group.ownerName) {
// Coming back from a channel page, focus the card we opened
// instead of the very first one (issue #34).
val target = group.channels.indexOfFirst {
it.name + "@" + it.host == focusHandle
}
val isTargetRow = target >= 0
ChannelRow( ChannelRow(
group.channels, group.channels,
onOpenChannel, onOpenChannel,
onOpenDrawer, onOpenDrawer,
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
focused = !focusHandedOut.value && focused = !focusHandedOut.value && (
group == viewModel.userRows.firstOrNull(), if (focusHandle != null) isTargetRow
else group == viewModel.userRows.firstOrNull()
),
focusIndex = if (isTargetRow) target else 0,
onFocused = { focusHandedOut.value = true }, onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] }, stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] }, platformName = { viewModel.platformNames[it.host ?: ""] },
@ -419,6 +437,8 @@ private fun ChannelRow(
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0, drawerPulse: Int = 0,
focused: Boolean, focused: Boolean,
/** Which card to hand the initial focus to (issue #34). */
focusIndex: Int = 0,
onFocused: () -> Unit = {}, onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?, platformName: (VideoChannelDto) -> String?,
@ -445,17 +465,30 @@ private fun ChannelRow(
} }
} }
if (focused) { if (focused) {
LaunchedEffect(channels.isNotEmpty()) { LaunchedEffect(channels.isNotEmpty(), focusIndex) {
if (channels.isNotEmpty()) { if (channels.isNotEmpty()) {
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() val target = focusIndex.coerceIn(0, channels.size - 1)
// Bring it on screen first; a card further right isn't composed yet,
// so retry until its focus requester is attached (issue #34).
if (target > 0) runCatching { rowState.scrollToItem(target) }
repeat(8) {
delay(80)
val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull()
if (ok != null) {
onFocused()
return@LaunchedEffect
}
}
} }
} }
} }
LazyRow( LazyRow(
state = rowState, state = rowState,
// Land on the last-focused card when moving between rows (issue #29). // Land on the last-focused card when moving between rows (issue #29). No
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, // custom fallback: a requester whose card has scrolled out of composition
// crashed with "FocusRequester is not initialized".
modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {

View file

@ -457,8 +457,10 @@ fun FocusScrollRow(
state = rowState, state = rowState,
// Jumping between rows lands on the card this row last had focus on, not // Jumping between rows lands on the card this row last had focus on, not
// the geometrically-nearest one (issue #29): the rows stop shifting // the geometrically-nearest one (issue #29): the rows stop shifting
// left/right as you move up and down between them. // left/right as you move up and down between them. No custom fallback —
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, // handing back a requester whose card has scrolled out of composition
// crashed with "FocusRequester is not initialized".
modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {

View file

@ -87,6 +87,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var destination by rememberSaveable { mutableStateOf(Destination.Home) } var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
// Last channel opened from the Channels screen — refocused on return (#34).
var lastOpenedChannel by rememberSaveable { mutableStateOf<String?>(null) }
// Channels are expensive to assemble, so start that work at app launch rather
// than when the view is first opened (issue #32); the view then usually finds
// the rows already there, and load() no-ops while fresh.
val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
key = "channels$epoch",
factory = ChannelsViewModel.factory,
)
LaunchedEffect(epoch) { channelsViewModel.load() }
// NOT saveable: launching the app should never resume a half-watched player session. // NOT saveable: launching the app should never resume a half-watched player session.
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
// Non-null while the playing video lives on ANOTHER instance (vidiverse results). // Non-null while the playing video lives on ANOTHER instance (vidiverse results).
@ -220,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle ->
channelDetails = handle
lastOpenedChannel = handle
},
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
// Back from a channel page re-selects it (issue #34).
focusHandle = lastOpenedChannel,
viewModel = channelsViewModel,
) )
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(