From 0fb33124e3bd47a536ce78f273bfdd4c16dce23e Mon Sep 17 00:00:00 2001 From: VinceBT Date: Sun, 28 Jun 2026 00:37:01 +0200 Subject: [PATCH 1/4] fix: request initial focus on screen entry for controller nav On most screens nothing was focused on entry, so the first D-pad/controller press was consumed just to seed focus ('press A in the void'). Add a reusable Modifier.requestInitialFocus(focusRequester, enabled) helper that attaches the requester and requests focus once the node is laid out (with retry), and apply it on: - SettingsScreen: the back button - UserLoginScreen: the username field - RecommendedGameScreen: the primary action button - LibraryScreen: content focus once the list loads (one-shot, reusing the existing requestContentFocusOrDefer with the same overlay guards) - DownloadsScreen: the portrait tab row (the landscape sidebar already did this) --- .../ui/screen/downloads/DownloadsScreen.kt | 20 +++++++++++ .../ui/screen/library/LibraryScreen.kt | 18 ++++++++++ .../screen/library/RecommendedGameScreen.kt | 7 +++- .../ui/screen/login/UserLoginScreen.kt | 5 +++ .../ui/screen/settings/SettingsScreen.kt | 4 +++ .../app/gamenative/ui/util/FocusModifiers.kt | 34 +++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt diff --git a/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt index 554c468667..0144756e4a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt @@ -370,6 +370,11 @@ private fun DownloadsTabRow( onSectionSelected: (DownloadsSection) -> Unit, modifier: Modifier = Modifier, ) { + val focusRequesters = remember { + sections.associateWith { FocusRequester() } + } + var requestedInitialFocus by remember { mutableStateOf(false) } + Row( modifier = modifier .fillMaxWidth() @@ -384,6 +389,7 @@ private fun DownloadsTabRow( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(16.dp)) + .focusRequester(focusRequesters.getValue(section)) .selectable( selected = isSelected, onClick = { onSectionSelected(section) }, @@ -425,6 +431,20 @@ private fun DownloadsTabRow( } } } + + LaunchedEffect(selectedSection, requestedInitialFocus) { + if (requestedInitialFocus) return@LaunchedEffect + val focusRequester = focusRequesters.getValue(selectedSection) + repeat(3) { + try { + focusRequester.requestFocus() + requestedInitialFocus = true + return@LaunchedEffect + } catch (_: Exception) { + delay(80) + } + } + } } @Composable diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index c0f06215aa..4f07ae2165 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -352,6 +352,8 @@ private fun LibraryScreenContent( // buttons would otherwise light up for ~100ms and then lose focus). var tabBarHasFocus by remember { mutableStateOf(false) } var lastBootstrapAtMs by remember { mutableLongStateOf(0L) } + // One-shot guard so initial-entry focus is requested only once and doesn't fight the tab-change effect. + var didInitialContentFocus by remember { mutableStateOf(false) } fun firstVisibleContentIndex(): Int { val lastIndex = state.appInfoList.lastIndex @@ -614,6 +616,22 @@ private fun LibraryScreenContent( previousAppCount = currentCount } + // Focus content on first entry once the library has loaded (one-shot). + LaunchedEffect(state.appInfoList.isNotEmpty()) { + if (!didInitialContentFocus && + state.appInfoList.isNotEmpty() && + selectedAppId == null && + !isSystemMenuOpen && + !state.isOptionsPanelOpen && + !state.isSearching + ) { + didInitialContentFocus = true + // Brief delay to let the list lay out before requesting focus. + kotlinx.coroutines.delay(150) + requestContentFocusOrDefer(targetListIndex = 0) + } + } + // Restore focus when System Menu or Options Panel closes LaunchedEffect(isSystemMenuOpen, state.isOptionsPanelOpen) { val systemMenuJustClosed = wasSystemMenuOpen && !isSystemMenuOpen diff --git a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt index 5dc7c42a0a..96d1ecfe24 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -52,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import app.gamenative.R import app.gamenative.data.RecommendedGame import app.gamenative.ui.screen.library.components.VideoHero +import app.gamenative.ui.util.requestInitialFocus import app.gamenative.PrefManager import com.posthog.PostHog import com.skydoves.landscapist.ImageOptions @@ -67,6 +69,7 @@ internal fun RecommendedGameScreen( ) { val context = LocalContext.current val scrollState = rememberScrollState() + val focusRequester = remember { FocusRequester() } val media = remember(game) { val list = mutableListOf>() @@ -399,7 +402,9 @@ internal fun RecommendedGameScreen( val browserIntent = Intent(Intent.ACTION_VIEW, game.affiliateUrl.toUri()) context.startActivity(browserIntent) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .requestInitialFocus(focusRequester), shape = RoundedCornerShape(12.dp), ) { Icon( diff --git a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt index 4d4b32b4a7..fca4141dc7 100644 --- a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt @@ -616,6 +616,11 @@ private fun CredentialsForm( val passwordFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } + // Focus the username field on entry so the first controller/D-pad press is not wasted. + LaunchedEffect(Unit) { + runCatching { usernameFocusRequester.requestFocus() } + } + Column( modifier = Modifier .fillMaxWidth(), diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt index 2b8f83fccf..2ab2df76bb 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -58,6 +59,7 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.enums.AppTheme import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.requestInitialFocus import com.materialkolor.PaletteStyle @Composable @@ -231,6 +233,7 @@ private fun BackButton( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val focusRequester = remember { FocusRequester() } val scale by animateFloatAsState( targetValue = if (isFocused) 1.1f else 1f, @@ -268,6 +271,7 @@ private fun BackButton( ) }, ) + .requestInitialFocus(focusRequester) .selectable( selected = isFocused, interactionSource = interactionSource, diff --git a/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt new file mode 100644 index 0000000000..ce9448895c --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt @@ -0,0 +1,34 @@ +package app.gamenative.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import kotlinx.coroutines.delay + +/** + * Attaches [focusRequester] and requests focus once laid out, so a freshly opened screen already + * has a focused element. Apply BEFORE `.focusable()`/`.selectable()`/`.clickable()`. + * + * [enabled] gates the request; focus is (re)requested each time it becomes `true`. + */ +@Composable +fun Modifier.requestInitialFocus( + focusRequester: FocusRequester, + enabled: Boolean = true, +): Modifier { + LaunchedEffect(enabled) { + if (!enabled) return@LaunchedEffect + // Node may not be placed on the first frame; retry briefly until requestFocus() succeeds. + repeat(5) { + try { + focusRequester.requestFocus() + return@LaunchedEffect + } catch (_: IllegalStateException) { + delay(32) + } + } + } + return this.focusRequester(focusRequester) +} From 3462653d31f582c8549314b8ca4a66140ff566ec Mon Sep 17 00:00:00 2001 From: VinceBT Date: Sun, 28 Jun 2026 22:45:00 +0200 Subject: [PATCH 2/4] Address review feedback: retry username focus, key focus effect on requester --- .../app/gamenative/ui/screen/login/UserLoginScreen.kt | 10 +++++++++- .../main/java/app/gamenative/ui/util/FocusModifiers.kt | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt index fca4141dc7..65d5d6f3c6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt @@ -617,8 +617,16 @@ private fun CredentialsForm( val usernameFocusRequester = remember { FocusRequester() } // Focus the username field on entry so the first controller/D-pad press is not wasted. + // Retry: the field may not be attached on the first frame. LaunchedEffect(Unit) { - runCatching { usernameFocusRequester.requestFocus() } + repeat(5) { + try { + usernameFocusRequester.requestFocus() + return@LaunchedEffect + } catch (_: IllegalStateException) { + delay(32) + } + } } Column( diff --git a/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt index ce9448895c..ec0f2aed7a 100644 --- a/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt +++ b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt @@ -18,7 +18,7 @@ fun Modifier.requestInitialFocus( focusRequester: FocusRequester, enabled: Boolean = true, ): Modifier { - LaunchedEffect(enabled) { + LaunchedEffect(focusRequester, enabled) { if (!enabled) return@LaunchedEffect // Node may not be placed on the first frame; retry briefly until requestFocus() succeeds. repeat(5) { From d523a8e536a8008ec638762c6fd8fc5c146008dc Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 29 Jun 2026 00:13:52 +0200 Subject: [PATCH 3/4] Retry initial focus until requestFocus actually succeeds --- .../ui/screen/downloads/DownloadsScreen.kt | 18 ++++++++++-------- .../ui/screen/login/UserLoginScreen.kt | 5 ++--- .../app/gamenative/ui/util/FocusModifiers.kt | 8 ++++---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt index 0144756e4a..9b97ed08fe 100644 --- a/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt @@ -353,12 +353,13 @@ private fun DownloadsSidebar( val focusRequester = focusRequesters.getValue(selectedSection) repeat(3) { try { - focusRequester.requestFocus() - requestedInitialFocus = true - return@LaunchedEffect + if (focusRequester.requestFocus()) { + requestedInitialFocus = true + return@LaunchedEffect + } } catch (_: Exception) { - delay(80) } + delay(80) } } } @@ -437,12 +438,13 @@ private fun DownloadsTabRow( val focusRequester = focusRequesters.getValue(selectedSection) repeat(3) { try { - focusRequester.requestFocus() - requestedInitialFocus = true - return@LaunchedEffect + if (focusRequester.requestFocus()) { + requestedInitialFocus = true + return@LaunchedEffect + } } catch (_: Exception) { - delay(80) } + delay(80) } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt index 65d5d6f3c6..5091e458e9 100644 --- a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt @@ -621,11 +621,10 @@ private fun CredentialsForm( LaunchedEffect(Unit) { repeat(5) { try { - usernameFocusRequester.requestFocus() - return@LaunchedEffect + if (usernameFocusRequester.requestFocus()) return@LaunchedEffect } catch (_: IllegalStateException) { - delay(32) } + delay(32) } } diff --git a/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt index ec0f2aed7a..d8bb1677ce 100644 --- a/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt +++ b/app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt @@ -20,14 +20,14 @@ fun Modifier.requestInitialFocus( ): Modifier { LaunchedEffect(focusRequester, enabled) { if (!enabled) return@LaunchedEffect - // Node may not be placed on the first frame; retry briefly until requestFocus() succeeds. + // Node may not be placed on the first frame, and requestFocus() returns false when the + // request is not honored yet; retry until it actually takes. repeat(5) { try { - focusRequester.requestFocus() - return@LaunchedEffect + if (focusRequester.requestFocus()) return@LaunchedEffect } catch (_: IllegalStateException) { - delay(32) } + delay(32) } } return this.focusRequester(focusRequester) From 289ff51d8a68da79efbdd1e36cf1a6467c47abc5 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Sun, 12 Jul 2026 21:58:26 +0200 Subject: [PATCH 4/4] Request initial focus on game detail and 2FA screens with retry --- .../ui/screen/library/LibraryAppScreen.kt | 13 +++++++++++-- .../ui/screen/login/TwoFactorAuthScreen.kt | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 0e74b9f4ca..fcc1f02839 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -132,6 +132,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.roundToInt +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -594,8 +595,16 @@ internal fun AppScreenContent( scrollState.animateScrollTo(0) } - LaunchedEffect(Unit) { - playButtonFocusRequester.requestFocus() + LaunchedEffect(displayInfo.appId) { + // Node may not be placed on the first frame, and requestFocus() returns false until it is; + // retry until it takes so the controller lands on Play (re-runs when switching games). + repeat(5) { + try { + if (playButtonFocusRequester.requestFocus()) return@LaunchedEffect + } catch (_: IllegalStateException) { + } + delay(32) + } } // Button state calculations (needed by key event handler) diff --git a/app/src/main/java/app/gamenative/ui/screen/login/TwoFactorAuthScreen.kt b/app/src/main/java/app/gamenative/ui/screen/login/TwoFactorAuthScreen.kt index 239470b08b..9de046623a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/login/TwoFactorAuthScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/login/TwoFactorAuthScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.delay import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -130,8 +131,15 @@ private fun TwoFactorTextField( val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(true) { - focusRequester.requestFocus() + LaunchedEffect(Unit) { + // Node may not be placed on the first frame; retry until requestFocus() takes. + repeat(5) { + try { + if (focusRequester.requestFocus()) return@LaunchedEffect + } catch (_: IllegalStateException) { + } + delay(32) + } } Column(modifier = Modifier.fillMaxWidth()) {