mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
fix: keep controller focus after installing a boot movie; Xbox and itch artwork covered (#1229)
This commit is contained in:
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)
|
||||
|
||||
@@ -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 /
|
||||
|
||||
Reference in new issue
Block a user