From 654963436fda6da3c796886fcadf288650dcddc9 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 15 Oct 2026 14:21:10 +0000 Subject: [PATCH] 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 --- .../jtubetv/ui/screens/ChannelsScreen.kt | 45 ++++++++++++++++--- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 6 ++- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 18 +++++++- 3 files changed, 60 insertions(+), 9 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 194c71e..57d4fa3 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 @@ -127,11 +127,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { // its shimmer placeholder rows until the (fast-path, few-request) load lands, // 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) { // Re-entry (destination switch away + back) inside the freshness window: // 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. + if (inFlight) return if (!force && userRows.isNotEmpty() && fresh()) return + inFlight = true loading = true error = null viewModelScope.launch { @@ -160,6 +166,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { error = e.message ?: "Could not load channels" } finally { loading = false + inFlight = false } } } @@ -310,6 +317,8 @@ fun ChannelsScreen( epoch: Int = 0, onOpenDrawer: () -> Unit = {}, 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), ) { LaunchedEffect(epoch) { viewModel.load() } @@ -358,13 +367,22 @@ fun ChannelsScreen( ) } 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( group.channels, onOpenChannel, onOpenDrawer, drawerPulse = drawerPulse, - focused = !focusHandedOut.value && - group == viewModel.userRows.firstOrNull(), + focused = !focusHandedOut.value && ( + if (focusHandle != null) isTargetRow + else group == viewModel.userRows.firstOrNull() + ), + focusIndex = if (isTargetRow) target else 0, onFocused = { focusHandedOut.value = true }, stats = { viewModel.stats[it.name + "@" + it.host] }, platformName = { viewModel.platformNames[it.host ?: ""] }, @@ -419,6 +437,8 @@ private fun ChannelRow( onOpenDrawer: () -> Unit = {}, drawerPulse: Int = 0, focused: Boolean, + /** Which card to hand the initial focus to (issue #34). */ + focusIndex: Int = 0, onFocused: () -> Unit = {}, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformName: (VideoChannelDto) -> String?, @@ -445,17 +465,30 @@ private fun ChannelRow( } } if (focused) { - LaunchedEffect(channels.isNotEmpty()) { + LaunchedEffect(channels.isNotEmpty(), focusIndex) { 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( state = rowState, - // Land on the last-focused card when moving between rows (issue #29). - modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, + // Land on the last-focused card when moving between rows (issue #29). No + // 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), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { 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 7ebda20..f8a14a9 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 @@ -457,8 +457,10 @@ fun FocusScrollRow( state = rowState, // Jumping between rows lands on the card this row last had focus on, not // the geometrically-nearest one (issue #29): the rows stop shifting - // left/right as you move up and down between them. - modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, + // left/right as you move up and down between them. No custom fallback — + // 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), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { 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 5f8726b..1e28d40 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 @@ -87,6 +87,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var channelName by rememberSaveable { mutableStateOf(null) } // display name + // Last channel opened from the Channels screen — refocused on return (#34). + var lastOpenedChannel by rememberSaveable { mutableStateOf(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. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID // 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, ) Destination.Channels -> ChannelsScreen( - onOpenChannel = { handle -> channelDetails = handle }, + onOpenChannel = { handle -> + channelDetails = handle + lastOpenedChannel = handle + }, epoch = epoch, onOpenDrawer = { drawerOpen = true }, drawerPulse = drawerPulse, + // Back from a channel page re-selects it (issue #34). + focusHandle = lastOpenedChannel, + viewModel = channelsViewModel, ) Destination.Search -> VideoListScreen(