fix: keep controller focus after installing a boot movie; Xbox and itch artwork covered (#1229)

This commit is contained in:
FX1922 authored and GitHub committed 2026-10-21 13:13:31 -04:00
1 parent ff4a7943f5
commit 7bb66de192
6 files changed
+65 -22

No files matched your search

@@ -40,6 +40,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
@@ -502,7 +508,7 @@ private fun ArtChooserDialog(
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
@@ -624,14 +630,23 @@ private fun SgdbSearchDialog(
}
}
LaunchedEffect(Unit) { search() }
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
val firstItem = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val leaveField = { focusManager.clearFocus(); keyboardController?.hide() }
val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard
LaunchedEffect(picked?.id, matches?.size, images.size, busy) {
val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty()
if (keyboard && !busy && ready) focusSoon { firstItem }
}
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) {
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
})
androidx.compose.material3.OutlinedTextField(
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
@@ -643,24 +658,24 @@ private fun SgdbSearchDialog(
val chosenGame = picked
val list = matches.orEmpty()
if (chosenGame == null) {
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } }
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
} else {
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in images) key(option.full) {
images.forEachIndexed { i, option -> key(option.full) {
ArtOption(
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null,
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
) { onPick(option) }
}
} }
}
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null }
}
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
@@ -69,6 +69,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInputModeManager
@@ -558,13 +559,37 @@ private fun repoInfo(post: SteamDeckRepo.Post): String =
listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "")
.filter { it.isNotEmpty() }.joinToString(" · ")
private const val AFTER_OPEN = 3
private const val AFTER_INSTALL = 1
private const val AFTER_USE = 2
/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */
@Composable
private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) {
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse)
private fun RepoAction(
installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit,
takeFocus: Boolean = false,
) {
val requester = remember { FocusRequester() }
val focused = remember { booleanArrayOf(false) }
var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) }
val inputMode = LocalInputModeManager.current
LaunchedEffect(installed, inUse, busy, percent != null, pending) {
if (pending == 0) return@LaunchedEffect
if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect }
focusWithinFrames({ focused[0] }) { requester }
when (pending) {
AFTER_OPEN -> pending = 0
AFTER_INSTALL -> if (installed && focused[0]) pending = 0
AFTER_USE -> if (inUse && focused[0]) pending = 0
else -> Unit
}
}
val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused }
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() })
else SecondaryButton(
if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install),
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() },
)
}
@@ -576,9 +601,6 @@ private fun PreviewDialog(
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val action = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } }
val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp
AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) {
DialogTitleRow(post.title, close)
@@ -586,7 +608,7 @@ private fun PreviewDialog(
MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f))
}
Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true)
}
}
@@ -3,6 +3,9 @@ package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -105,7 +108,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) {
}
@Composable
private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
@@ -113,7 +116,7 @@ private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(8.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.clip(shape)
modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal)
@@ -140,6 +143,9 @@ internal fun LogoPositionDialog(
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) }
val startPin = remember { value.pin }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusSoon { first } }
var failed by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val save: (LogoPosition.Value?) -> Unit = { next ->
@@ -172,7 +178,7 @@ internal fun LogoPositionDialog(
)
Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) }
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) }
}
ValueSlider(
value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size),
@@ -65,7 +65,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
)
Text(
stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -36,7 +36,7 @@ class StoreGameArtTest {
}
@Test fun anArtChoiceOnAStoreGameIsKeptShownAndResetsWithoutTouchingItsTitle() {
for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"))) {
for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"), Triple(Store.XBOX, "9NBLGGH4X1", "Forza Horizon"), Triple(Store.ITCH, "12345", "Night in the Woods"))) {
val folder = install(store, id, title)
val image = File(tmp.root, "$id.png").apply { writeBytes(ByteArray(9) { 1 }) }
val option = AddedGameArt.Option(AddedGameArt.FILE, image.path, image.path)
+1 -1
View File
@@ -95,7 +95,7 @@ scan (`Library.launchableGames`) compares the folder with what was last written
(`grid-<slot>` in the game's art folder): a file Steam changed becomes the choice (source Steam),
a file removed in Steam drops it, and files the shortcuts writer placed itself
(`.droiddeck-art.json`) are not mistaken for choices. A chosen image is stored as
`chosen-<slot>-<time>.<ext>`, so a new choice has a new path and shows at once. Installed GOG, Epic and Amazon games get the same rows through `StoreGameArtEditor`.
`chosen-<slot>-<time>.<ext>`, so a new choice has a new path and shows at once. Installed GOG, Epic, Amazon, Xbox and itch games get the same rows through `StoreGameArtEditor`.
"Adjust logo" (last row of every art editor, `LogoPositionDialog`) shows the game's Background with
its Logo on top and lets the logo be pinned to one of Steam's five places (top left / top center /