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..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) } } } @@ -370,6 +371,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 +390,7 @@ private fun DownloadsTabRow( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(16.dp)) + .focusRequester(focusRequesters.getValue(section)) .selectable( selected = isSelected, onClick = { onSectionSelected(section) }, @@ -425,6 +432,21 @@ private fun DownloadsTabRow( } } } + + LaunchedEffect(selectedSection, requestedInitialFocus) { + if (requestedInitialFocus) return@LaunchedEffect + val focusRequester = focusRequesters.getValue(selectedSection) + repeat(3) { + try { + if (focusRequester.requestFocus()) { + requestedInitialFocus = true + return@LaunchedEffect + } + } catch (_: Exception) { + } + delay(80) + } + } } @Composable 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/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/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()) { 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..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 @@ -616,6 +616,18 @@ 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. + // Retry: the field may not be attached on the first frame. + LaunchedEffect(Unit) { + repeat(5) { + try { + if (usernameFocusRequester.requestFocus()) return@LaunchedEffect + } catch (_: IllegalStateException) { + } + delay(32) + } + } + 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..d8bb1677ce --- /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(focusRequester, enabled) { + if (!enabled) return@LaunchedEffect + // 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 { + if (focusRequester.requestFocus()) return@LaunchedEffect + } catch (_: IllegalStateException) { + } + delay(32) + } + } + return this.focusRequester(focusRequester) +}