Merge pull request #41 from Droid-Deck/fix/ui-ux-critique

Fix/UI ux critique
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-27 16:41:18 -04:00
commit 9aed4e7a5c
20 files changed
+301 -180

No files matched your search

@@ -365,7 +365,7 @@ class MainActivity : ComponentActivity() {
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB pairing failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
@@ -378,7 +378,7 @@ class MainActivity : ComponentActivity() {
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB command failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -388,7 +388,7 @@ class MainActivity : ComponentActivity() {
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB is unavailable" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -518,6 +518,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshPhantomStatus()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
@@ -273,6 +273,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun handleOnBackPressed() {
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
when {
::loading.isInitialized && loading.ended -> finish()
drawerOpen -> drawerOpen = false
pcKeyboardOpen -> pcKeyboardOpen = false
else -> routeBackAction()
@@ -299,6 +300,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
@@ -599,6 +604,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
focusEndedScreen()
return@post
}
loading.percent = -1
@@ -888,19 +894,30 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun showEnded(status: Int, hint: String?) {
run {
if (status != 0) {
loading.showEnded(
"The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" +
(if (hint != null) "\n\n$hint" else "")
)
// A moment on screen, so a failure is readable rather than a flash of black;
// longer when there is advice to read.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000)
} else {
finish()
}
if (status == 0) {
finish()
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
loading.showEnded(
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
}
/** The ended screen's buttons take the pad: it is no longer driving a game. */
private fun focusEndedScreen() {
drawerOpen = false
padBridge?.releaseAll()
uiHandler.post { sessionOverlay.requestFocus() }
}
/** The same start again, in a fresh activity: the ended one keeps no session to reuse. */
private fun retrySession() {
val again = Intent(intent)
finish()
startActivity(again)
}
/**
@@ -956,6 +973,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
// The ended screen is a page of buttons: the pad drives them, and B leaves.
if (::loading.isInitialized && loading.ended) {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) finish()
return true
}
return super.dispatchKeyEvent(event)
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
@@ -1443,6 +1468,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
@@ -40,9 +40,11 @@ object FrameGen {
}
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
fun label(context: Context): String = when (engine(context)) {
ENGINE_WINFG -> "Win-FG ${multiplier(context)}×"
ENGINE_LSFG -> "LSFG ${multiplier(context)}×"
fun label(context: Context): String = label(engine(context), multiplier(context))
fun label(engine: String, multiplier: Int): String = when (engine) {
ENGINE_WINFG -> "Win-FG $multiplier×"
ENGINE_LSFG -> "LSFG $multiplier×"
else -> "Off"
}
@@ -82,47 +84,5 @@ object FrameGen {
return problem
}
/**
* Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed
* and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot
* run without its DLL, so offering it would only produce a setting that silently does nothing.
*/
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
val lsfgReady = LsfgNative.isInstalled(activity)
val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam"
val choices = listOf("Off",
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
"LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote")
val current = when (engine(activity)) {
ENGINE_WINFG -> multiplier(activity) - 1
ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0
else -> 0
}
val adapter = object : android.widget.ArrayAdapter<String>(
activity, android.R.layout.simple_list_item_single_choice, choices) {
override fun isEnabled(position: Int) = lsfgReady || position < 4
override fun areAllItemsEnabled() = lsfgReady
override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
val view = super.getView(position, convertView, parent)
view.isEnabled = isEnabled(position)
view.alpha = if (isEnabled(position)) 1f else 0.38f
return view
}
}
android.app.AlertDialog.Builder(activity)
.setTitle(com.droiddeck.launcher.R.string.frame_gen_title)
.setSingleChoiceItems(adapter, current) { dialog, which ->
when (which) {
0 -> set(activity, ENGINE_OFF, 2)
in 1..3 -> set(activity, ENGINE_WINFG, which + 1)
else -> set(activity, ENGINE_LSFG, which - 2)
}
dialog.dismiss()
onChanged()
}
.setNeutralButton(android.R.string.ok, null)
.show()
}
private const val TAG = "FrameGen"
}
@@ -24,6 +24,8 @@ class LoadingState(context: Context) {
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
var ended by mutableStateOf(false)
/** The technical side of an ended session (exit status, log path), under the advice in [step]. */
var endedDetail by mutableStateOf<String?>(null)
private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints)
private val startedAt = SystemClock.elapsedRealtime()
@@ -35,10 +37,11 @@ class LoadingState(context: Context) {
hint = hints[((seconds / 8) % hints.size).toInt()]
}
fun showEnded(message: String) {
fun showEnded(message: String, detail: String? = null) {
visible = true
ended = true
step = message
endedDetail = detail
}
/** Re-reads the end of the log and updates the line and the bar. */
@@ -325,7 +325,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(10.dp))
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
@@ -41,12 +41,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) {
SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus ->
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Color") { firstItemFocus ->
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTint(color)
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centers where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton("Edit") { a.onEditLayout() }
@@ -68,7 +68,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
}
val remapped = c.mapping.count { (id, target) -> id != target }
ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping)
ActionRow("Reset controller", "Restore the default colour, opacity, size, stick behaviour, mapping and layout", "Reset", a.onResetAll)
ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll)
}
@Composable
@@ -0,0 +1,22 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import com.droiddeck.launcher.gpu.FrameGen
/**
* The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup,
* a game's launch settings and the session drawer, so they say the same thing.
*/
@Composable
internal fun FrameGenMenu(
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam"
fun pick(e: String, m: Int) { onPick(e, m); host.open = null }
MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) }
}
}
@@ -223,10 +223,11 @@ private fun Content(
if (e.installed) {
Rise(3) {
Actions {
PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) }
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
BusyChip(s)
SecondaryButton("ROMs folder", onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") "Remove" else "Forget",
if (pkg.kind == "appimage") "Remove" else "Hide",
enabled = s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
@@ -274,7 +275,7 @@ private fun Content(
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
if (pkg?.kind == "tar") Rise(5) { Note("Forgetting this package hides it from Desktop; its files remain in the Linux runtime.") }
if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") }
}
}
}
@@ -296,8 +297,8 @@ private fun Content(
Spacer(Modifier.height(14.dp))
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) }
ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
@@ -315,7 +316,7 @@ private fun Content(
}
}
}
else -> Note("Select an item.")
else -> Note("That page is gone. Pick a section on the left.")
}
}
}
@@ -331,6 +332,7 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
BusyChip(s)
}
}
Column(
@@ -62,16 +62,29 @@ import com.droiddeck.launcher.frontend.Library
@Composable
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() ?: return
val host = rememberMenuHost()
val narrow = LocalNarrowPane.current
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader("Games") }
Rise(1) { Note("Games you install in Steam show up here. Open Steam, install one, and it appears after the session.") }
Rise(2) {
Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
return
}
val host = rememberMenuHost()
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
BusyChip(s)
}
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
@@ -92,7 +105,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
BusyChip(s)
}
}
SectionTitle("Launch settings", null)
LaunchSettings(s, a, host)
@@ -183,6 +199,12 @@ internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit
}
}
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
@Composable
internal fun BusyChip(s: FrontEndState) {
if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false)
}
/** Whether Steam can start, said where Play is rather than only in Setup. */
@Composable
internal fun RuntimeChip(s: FrontEndState) = when {
@@ -53,6 +53,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -78,7 +80,7 @@ import com.droiddeck.launcher.core.PhantomProcessLimit
/** What the pad's face buttons do here, along the bottom edge as on a console. */
@Composable
internal fun ControllerHints() {
internal fun ControllerHints(select: String = "Select", tabs: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(modifier = Modifier.fillMaxWidth()) {
@@ -88,7 +90,8 @@ internal fun ControllerHints() {
horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End),
modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp),
) {
HintGlyph("A", "Select")
if (tabs) HintGlyph("LB RB", "Tabs")
HintGlyph("A", select)
HintGlyph("B", "Back")
}
}
@@ -98,7 +101,12 @@ internal fun ControllerHints() {
private fun HintGlyph(button: String, action: String) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
// Face buttons are round; bumpers are a wider keycap.
val shape = if (button.length == 1) CircleShape else RoundedCornerShape(6.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(20.dp).widthIn(min = 20.dp).clip(shape).background(colors.onBackground).padding(horizontal = if (button.length == 1) 0.dp else 6.dp),
) {
Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant)
@@ -133,12 +141,13 @@ internal fun SideRail(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
) {
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Games appears once there is one: an empty list is no place to land.
if (s.steamGames.isNotEmpty()) RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
}
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
@@ -222,7 +231,9 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pulse = rememberInfiniteTransition(label = "pulse")
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring")
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(Motion.ms(1600).coerceAtLeast(1), easing = Motion.Ease), RepeatMode.Restart), label = "ring")
// Animations off: the live dot alone, no pulsing ring.
val still = Motion.scale == 0f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
@@ -238,7 +249,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
.padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp),
) {
Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
if (!still) Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good))
}
// Icons only: the live dot alone says something is running; its description says what.
@@ -71,6 +71,7 @@ import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.roundToInt
import kotlinx.coroutines.withTimeoutOrNull
class FrontEndState(
val installed: String?,
@@ -147,10 +148,10 @@ class FrontEndActions(
val onCheckLatestBuild: () -> Unit = {},
val onRefreshPhantomStatus: () -> Unit = {},
val onOpenDeveloperOptions: (Int?) -> Unit = {},
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") },
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") },
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless ADB is unavailable") },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
val onCopyPhantomCommand: (Boolean) -> Unit = {},
val onDismissPhantomGate: () -> Unit = {},
val controller: ControllerActions? = null,
@@ -158,6 +159,10 @@ class FrontEndActions(
internal object Motion {
var scale = 1f
/** The system's animator scale, re-read on each resume so "Remove animations" applies without a restart. */
fun refresh(context: android.content.Context) {
scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
}
val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
fun ms(base: Int) = (base * scale).roundToInt()
fun <T> tw(base: Int, delay: Int = 0): FiniteAnimationSpec<T> = if (scale == 0f) snap() else tween(ms(base), ms(delay), Ease)
@@ -229,7 +234,8 @@ internal class FrontFocus {
// and the last one focused.
val items = HashMap<String, FocusRequester>()
val attached = HashMap<String, Int>()
var last: String? = null
// State, so the controller hints follow it: A launches from a game row but selects elsewhere.
var last by mutableStateOf<String?>(null)
// The first tile of the page's grid: Down from the page's buttons goes to it, not to whichever
// tile happens to sit under the button.
val firstTile = FocusRequester()
@@ -278,6 +284,22 @@ internal fun Modifier.paneItem(id: String): Modifier {
internal val LocalFrontFocus = staticCompositionLocalOf<FrontFocus?> { null }
/** The pane's exit animation (170ms) and a frame's margin: a new page is in after this. */
private const val PAGE_EXIT_MS = 200
/**
* Asks [target] for focus once a frame until [done] (or a focus request lands), for at most half a
* second: a control can take focus only once it is laid out, and that is a frame or two, not a
* fixed number of milliseconds.
*/
internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusRequester) {
repeat(30) {
androidx.compose.runtime.withFrameNanos { }
if (done()) return
if (runCatching { target().requestFocus() }.isSuccess && done()) return
}
}
@Composable
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
val frontFocus = remember { FrontFocus() }
@@ -323,11 +345,11 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':')
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The last game uninstalled takes the Games tab with it.
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && (selected == "games" || selected.startsWith("app:"))) selected = "steam"
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
}
remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true }
remember { Motion.refresh(ctx); true }
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
@@ -367,32 +389,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
LaunchedEffect(processSettingsPageVisible) {
if (processSettingsPageVisible) return@LaunchedEffect
snapshotFlow { window.isWindowFocused }.first { it }
repeat(20) {
if (anyFocused) return@LaunchedEffect
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
val target = if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection)
runCatching { target.requestFocus() }
kotlinx.coroutines.delay(100)
}
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) }
}
// A tile or button that opens a page goes away with the page it was on, and focus with it;
// the pad then had nothing to move from (a press landed back on the rail's first item). So
// once the new page is in, a controller lands on its main button.
LaunchedEffect(selected, s.pageKey, processSettingsPageVisible) {
if (processSettingsPageVisible) return@LaunchedEffect
kotlinx.coroutines.delay(450)
if (!anyFocused && inputModeManager.inputMode == InputMode.Keyboard) runCatching {
if (frontFocus.primaryAttached > 0) frontFocus.primary.requestFocus()
else frontFocus.railFor(railSelection).requestFocus()
}
// Past the old page's exit (170ms), then the first frame the new page takes focus.
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) }
}
// A rail item picked with a controller moves on into its page, once the page is in.
LaunchedEffect(railPicks) {
if (railPicks == 0 || processSettingsPageVisible) return@LaunchedEffect
kotlinx.coroutines.delay(450)
if (inputModeManager.inputMode == InputMode.Keyboard && frontFocus.primaryAttached > 0) runCatching {
frontFocus.paneEntry().requestFocus()
}
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
// Waits for the new page's main button rather than a fixed guess at how long it takes.
withTimeoutOrNull(1_000) { snapshotFlow { frontFocus.primaryAttached > 0 }.first { it } } ?: return@LaunchedEffect
focusWithinFrames({ frontFocus.focusedRail == null && anyFocused }) { frontFocus.paneEntry() }
}
val paneFocus = Modifier
.focusProperties { enter = { frontFocus.paneEntry() } }
@@ -417,7 +434,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
AnimatedVisibility(
inputModeManager.inputMode == InputMode.Keyboard,
enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(),
) { ControllerHints() }
) {
val onGame = frontFocus.focusedRail == null && frontFocus.last?.startsWith("game:") == true
ControllerHints(
select = if (onGame) "Launch" else "Select",
tabs = railSelection == "setup" && s.pageKey == null && frontFocus.focusedRail == null,
)
}
}
}
@@ -65,12 +65,10 @@ import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -209,8 +207,8 @@ internal fun SetupPanel(
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
if (limitBlocks) "Android may close Steam" else "Android process limit",
if (limitBlocks) "“Restrict child processes” is on - it takes a minute to turn off" else PhantomProcessLimit.title(s.phantomProcessStatus),
"Child-process limit",
if (limitBlocks) "On · Android may close Steam. Turning it off takes a minute" else PhantomProcessLimit.title(s.phantomProcessStatus),
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
@@ -218,32 +216,25 @@ internal fun SetupPanel(
}
}
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
// One sentence and at most three buttons. The computer route and its
// raw command live on the full page Wireless debugging opens.
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
PhantomProcessLimit.instructions(s.phantomProcessStatus),
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) {
SecondaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
SecondaryButton("Copy ADB command", compact = true, onClick = { a.onCopyPhantomCommand(false) })
}
Text(
PhantomProcessLimit.ADB_COMMAND, fontSize = 12.sp, fontFamily = FontFamily.Monospace,
color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
Text(
"With wireless debugging paired, DroidDeck can switch the limit itself. Turning it off is recommended for Steam sessions.",
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) {
SecondaryButton("Turn limit off", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) }
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) }
Actions {
if (limitBlocks) {
PrimaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
CheckRow(
@@ -278,15 +269,13 @@ internal fun SetupPanel(
FrameGenMenu(s, a, host)
}
}
SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} · logs are saved after each session") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() }
SecondaryButton("Share latest") { a.onShareLogs() }
}
}
SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") {
SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() }
}
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
ToggleRow(
host, "offline", "Offline mode",
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
@@ -337,7 +326,7 @@ private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles),
ToolSpec(Icons.Outlined.Extension, "Compatibility tools", "Install ARM64 Proton builds", a.onProtons),
ToolSpec(Icons.Outlined.Extension, "Proton versions", "Install ARM64 Proton builds", a.onProtons),
ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms),
)
@@ -380,12 +369,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam"
MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
}
/**
@@ -398,7 +382,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
val columns = if (LocalNarrowPane.current) 2 else 3
val controller = a.controller
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard("Compatibility", "Proton & components", "card:components", m) { a.onComponents(true) } }
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
add { m ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
@@ -111,7 +111,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un
val shape = RoundedCornerShape(22.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 36.dp else 44.dp)
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp)
.clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
@@ -166,6 +166,8 @@ internal fun PrimaryButton(
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.then(track)
// 48dp to touch on a handheld; compact rows keep 44.
.heightIn(min = if (compact) 44.dp else 48.dp)
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
@@ -202,7 +204,8 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
modifier = modifier.paneItem("btn:$text").downToFirstTile().graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
contentAlignment = Alignment.Center,
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -211,7 +214,7 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
}
@Composable
internal fun Cog(size: androidx.compose.ui.unit.Dp = 42.dp, onClick: () -> Unit) {
internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
@@ -94,7 +94,7 @@ private fun CompactGateInstructions(
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use on-device ADB", compact = true, onClick = onSetUpWirelessAdb)
SecondaryButton("Use Wireless debugging", compact = true, onClick = onSetUpWirelessAdb)
}
}
}
@@ -133,7 +133,7 @@ private fun GateInstructions(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions)
SecondaryButton("Use on-device ADB", onClick = onSetUpWirelessAdb)
SecondaryButton("Use Wireless debugging", onClick = onSetUpWirelessAdb)
}
}
}
@@ -33,7 +33,7 @@ fun ProtonPage(
val colors = MaterialTheme.colorScheme
SettingsPage(
host,
title = "Compatibility tools",
title = "Proton versions",
eyebrow = "Setup",
lede = "Download and install an ARM64 Proton build now, then select it per game in Steam > Properties > Compatibility.",
onBack = onBack,
@@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
@@ -125,6 +126,11 @@ private fun readableStep(step: String): Boolean {
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
/** An ended session's exit status and log path, shown small under the advice. */
endedDetail: String? = null,
onRetry: (() -> Unit)? = null,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
val stages = remember(steam) { loadStages(steam) }
@@ -176,6 +182,12 @@ fun LoadingOverlay(
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
)
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
if (ended && endedDetail != null) Text(
endedDetail, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant.copy(alpha = 0.8f),
textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp),
)
if (ended) EndedActions(onRetry, onShareLogs, onClose)
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
@@ -186,13 +198,31 @@ fun LoadingOverlay(
}
}
/** What to do about an ended session: nothing leaves on a timer, and the pad starts on Try again. */
@Composable
private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
val first = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { first.requestFocus() }
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton("Try again", modifier = claim(), onClick = onRetry)
if (onShareLogs != null) SecondaryButton("Share logs", modifier = claim(), onClick = onShareLogs)
if (onClose != null) SecondaryButton("Back", modifier = claim(), onClick = onClose)
}
}
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
@Composable
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
val run = rememberInfiniteTransition(label = "segment")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(Motion.ms(1300).coerceAtLeast(1), easing = FastOutSlowInEasing)), label = "run")
val still = Motion.scale == 0f
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
@@ -204,6 +234,7 @@ private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifi
.drawBehind {
if (i != active) return@drawBehind
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
else if (still) drawRect(pal.signal.copy(alpha = 0.5f))
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
},
)
@@ -316,20 +316,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = when (a.frameGenEngine) {
FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×"
FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×"
else -> "Off"
}
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
SettingsRow("Frame generation", null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (a.lsfgReady) null else "Requires Lossless Scaling"
MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
}
}
}
@@ -349,16 +340,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
SettingsGroup("Keyboard") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
DrawerOutlineButton("Hardware", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "hardware"))) {
DrawerOutlineButton("PC keyboard", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) {
host.open = null; a.onHardwareKeyboard()
}
DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "android"))) {
DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "android"))) {
host.open = null; a.onKeyboard()
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
ChoiceRow(host, "second-screen-mode", "Controls", null,
ChoiceRow(host, "second-screen-mode", "Shows", null,
listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null,
@@ -420,7 +411,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
SettingsGroup("Support") {
SettingsRow("Session logs", "Send this session's logs with a bug report") {
DrawerOutlineButton("Send logs", modifier = focus.track(page, "share-logs")) {
DrawerOutlineButton("Share logs", modifier = focus.track(page, "share-logs")) {
host.open = null
a.onShareLogs()
}
@@ -509,7 +500,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
val shape = RoundedCornerShape(20.dp)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 40.dp).semantics { contentDescription = "Stop session" }
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
.clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -149,7 +151,7 @@ internal fun TabStrip(
.border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 9.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
@@ -301,6 +303,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.widthIn(min = LocalChipMinWidth.current)
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
.border(1.dp, edge, RoundedCornerShape(10.dp))
@@ -385,7 +388,46 @@ fun <T> ChoiceRow(
@Composable
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
ChoiceRow(host, key, label, hint, listOf(true to "On", false to "Off"), checked, enabled, chipModifier = chipModifier, onPick = onChange)
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
}
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
@Composable
fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = null, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge")
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
val flip = { onChange(!checked) }
val shape = RoundedCornerShape(99.dp)
// 48dp tall to touch; the visible track sits inside it.
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.heightIn(min = 48.dp).widthIn(min = 64.dp)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src)
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onClick = flip)
.controllerConfirm(enabled = enabled, onClick = flip)
.semantics { stateDescription = if (checked) "On" else "Off"; if (label != null) contentDescription = label },
) {
Box(
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
.border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape),
) {
Box(
Modifier.padding(4.dp).size(22.dp)
.graphicsLayer { translationX = knob * 22.dp.toPx() }
.clip(CircleShape)
.background(if (checked) pal.onSignal else colors.onSurfaceVariant),
)
}
}
}
@Composable
fun MultiRow(
@@ -172,9 +172,9 @@ fun WirelessAdbFixPage(
SettingsPage(
host = rememberMenuHost(),
title = when {
compactSplit && step < 2 -> "Wireless ADB"
step == 2 -> "Wireless ADB ready"
else -> "Set up on-device ADB"
compactSplit && step < 2 -> "Wireless debugging"
step == 2 -> "Child-process limit updated"
else -> "Change the child-process limit"
},
eyebrow = "Setup",
lede = when {
@@ -201,7 +201,7 @@ fun WirelessAdbFixPage(
) {
SettingsGroup("Child-process limit", compact = compactSplit) {
Text(
"Android confirmed the limit is ${if (desiredEnabled) "enabled" else "disabled"}.",
"Android confirmed the limit is ${if (desiredEnabled) "on" else "off"}.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(14.dp),
)
@@ -332,7 +332,7 @@ private fun WirelessStepForm(
StatusMessage(busy, message, Modifier.weight(1f), isError = messageIsError)
SecondaryButton("Pair again", compact = compact, enabled = !busy, onClick = onPairAgain)
PrimaryButton(
if (desiredEnabled) "Enable limit" else "Disable limit",
if (desiredEnabled) "Turn limit on" else "Turn limit off",
compact = compact,
enabled = !busy && parseAdbAddress(connectionAddress) != null,
onClick = onApply,
@@ -24,7 +24,7 @@ class HdrProbe(
) {
/** Why HDR cannot be offered, or null when it can. */
val reason: String? = when {
Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a colour space)"
Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a color space)"
!hdr10 -> if (formats.isEmpty()) "this display reports no HDR support" else "this display supports $formats, not HDR10"
else -> null
}