diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a46c5d946..a6fa7947f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,11 +67,13 @@
(null)
private var resolutionCap by mutableStateOf(1080)
+ private var fexPreset by mutableStateOf("")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var hdrReason by mutableStateOf(null)
@@ -350,6 +351,7 @@ class MainActivity : ComponentActivity() {
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
+ fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
@@ -378,6 +380,7 @@ class MainActivity : ComponentActivity() {
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
},
+ onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onDismiss = { settingsMode = null },
),
)
@@ -411,6 +414,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
+ fexPreset = SessionPrefs.fexPreset(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt
index d0f2438ba..646b6812c 100644
--- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt
@@ -44,7 +44,6 @@ import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.ui.CursorOverlay
import com.steamdeck.launcher.ui.DrawerActions
-import com.steamdeck.launcher.ui.FrameGenDialog
import com.steamdeck.launcher.ui.HudText
import com.steamdeck.launcher.ui.LoadingOverlay
import com.steamdeck.launcher.ui.ProtonDialog
@@ -84,11 +83,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
- private var showFrameGen by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
private var protonRows by mutableStateOf>(emptyList())
private var hudOn by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
+ private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
+ private var frameGenMultiplier by mutableStateOf(2)
+ private var fexPreset by mutableStateOf("")
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
@@ -157,15 +158,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
- if (drawerOpen) SessionDrawer(DrawerActions(
- hudOn = hudOn, frameGenLabel = frameGenLabel, oscMode = oscMode,
- touchMode = when (touchMode) {
- SessionPrefs.TOUCH_PAD -> "touchpad"; SessionPrefs.TOUCH_DIRECT -> "direct"
- else -> "auto (" + (if (usingTouchpad()) "touchpad" else "direct") + ")"
- },
- shapeMode = if (shapeMode == SessionPrefs.SHAPE_WIDE) "16:9" else "panel",
+ SessionDrawer(drawerOpen, DrawerActions(
+ steam = SessionState.mode == SessionService.MODE_STEAM,
+ hudOn = hudOn,
+ frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
+ lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
+ oscMode = oscMode, touchMode = touchMode,
+ touchAuto = if (usingTouchpad()) "touchpad" else "direct",
+ shapeMode = shapeMode, fexPreset = fexPreset,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
- onFrameGen = { showFrameGen = true },
+ onFrameGenPick = { engine, multiplier ->
+ FrameGen.set(this@SessionActivity, engine, multiplier)
+ readPrefs()
+ applyFrameGen()
+ },
onKeyboard = { drawerOpen = false; keyboard?.toggle() },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({
// The Guide button, the way the on-screen ◉ sends it: a device with no
@@ -178,31 +184,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, 90)
}) else null,
onProtons = { refreshProtons(); showProtons = true },
- onOsc = {
- val next = when (SessionPrefs.oscMode(this@SessionActivity)) {
- SessionPrefs.OSC_AUTO -> SessionPrefs.OSC_ALWAYS
- SessionPrefs.OSC_ALWAYS -> SessionPrefs.OSC_NEVER
- else -> SessionPrefs.OSC_AUTO
- }
- SessionPrefs.setOscMode(this@SessionActivity, next)
- readPrefs()
- updateOnScreenControls()
- },
- onTouch = {
- val next = when (SessionPrefs.touchMode(this@SessionActivity)) {
- SessionPrefs.TOUCH_AUTO -> SessionPrefs.TOUCH_PAD
- SessionPrefs.TOUCH_PAD -> SessionPrefs.TOUCH_DIRECT
- else -> SessionPrefs.TOUCH_AUTO
- }
- SessionPrefs.setTouchMode(this@SessionActivity, next)
- readPrefs()
- },
- onShape = {
- val next = if (SessionPrefs.shapeMode(this@SessionActivity) == SessionPrefs.SHAPE_WIDE)
- SessionPrefs.SHAPE_AUTO else SessionPrefs.SHAPE_WIDE
- SessionPrefs.setShapeMode(this@SessionActivity, next)
- readPrefs()
- },
+ onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
+ onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
+ onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
+ onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
@@ -214,18 +199,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onRemove = { id -> ProtonExtras.tools.first { it.id == id }.let { ProtonExtras.remove(this@SessionActivity, it) }; refreshProtons() },
onDismiss = { showProtons = false },
)
- if (showFrameGen) FrameGenDialog(
- engine = FrameGen.engine(this@SessionActivity),
- multiplier = FrameGen.multiplier(this@SessionActivity),
- lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
- onPick = { engine, multiplier ->
- FrameGen.set(this@SessionActivity, engine, multiplier)
- showFrameGen = false
- readPrefs()
- applyFrameGen()
- },
- onDismiss = { showFrameGen = false },
- )
}
}
})
@@ -265,6 +238,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
hudOn = SessionPrefs.hudEnabled(this)
touchMode = SessionPrefs.touchMode(this)
frameGenLabel = FrameGen.label(this)
+ frameGenEngine = FrameGen.engine(this)
+ frameGenMultiplier = FrameGen.multiplier(this)
+ fexPreset = SessionPrefs.fexPreset(this)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
}
diff --git a/app/src/main/java/com/steamdeck/launcher/core/FexPreset.kt b/app/src/main/java/com/steamdeck/launcher/core/FexPreset.kt
new file mode 100644
index 000000000..00a99c6d6
--- /dev/null
+++ b/app/src/main/java/com/steamdeck/launcher/core/FexPreset.kt
@@ -0,0 +1,42 @@
+package com.steamdeck.launcher.core
+
+/**
+ * FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
+ * so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
+ * session's environment; the games under FEX inherit them from the client. The default is FEX's
+ * own defaults - no variables at all - which is what every session has run on so far.
+ *
+ * TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
+ * block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
+ * anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
+ * checks at full and hides the hypervisor bit, which its anti-tamper looks for.
+ */
+object FexPreset {
+ class Preset(val id: String, val label: String, val detail: String, val env: List)
+
+ private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
+ "FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
+ )
+
+ val all: List = listOf(
+ Preset("", "FEX defaults", "No variables set; what every session ran on until now.", emptyList()),
+ Preset("STABILITY", "Stability", "Every TSO emulation on, single-block: the slowest and the surest.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
+ Preset("COMPATIBILITY", "Compatibility", "Every TSO emulation on, multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
+ Preset("INTERMEDIATE", "Intermediate", "Main and half-barrier TSO on, vector and memcpy off, reduced x87.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
+ Preset("PERFORMANCE", "Performance", "All TSO off, reduced x87, multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
+ Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance with the main store ordering kept on, for the many games that need it.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
+ Preset("EXTREME", "Extreme", "Performance plus no self-modifying-code checks and FEX's block caches retuned. JIT-heavy titles and DRM can break.",
+ tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
+ Preset("EXTREME_TSO", "Extreme + TSO", "Extreme with the main store ordering kept on.",
+ tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
+ Preset("EXTREME_GN", "Extreme-gn", "Performance plus the small TSC scale and volatile-metadata hints; SMC checks stay on. Try before Extreme.",
+ tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
+ Preset("DENUVO", "Denuvo", "Performance with full SMC checks and the hypervisor bit hidden, for titles whose anti-tamper looks for either.",
+ tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
+ )
+
+ fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
+
+ /** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
+ fun env(id: String): List = byId(id).env
+}
diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt
index 155182b57..f3f894a1b 100644
--- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt
@@ -194,6 +194,13 @@ object SessionPrefs {
*/
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
+ /** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
+ fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
+
+ fun setFexPreset(context: Context, id: String) {
+ prefs(context).edit().putString("fexPreset", id).apply()
+ }
+
/** What a mode gets when nothing was chosen. */
@Suppress("UNUSED_PARAMETER")
fun defaultResolutionCap(mode: String): Int = 720
diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt
index f48037627..9751ad0b6 100644
--- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt
@@ -199,6 +199,15 @@ class SessionService : Service() {
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
+ // The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
+ // here, before the script, so every game process inherits them from the client. The
+ // default preset sets nothing, which is what every session ran on before.
+ if (SessionState.mode == MODE_STEAM) {
+ val preset = SessionPrefs.fexPreset(this)
+ val vars = com.steamdeck.launcher.core.FexPreset.env(preset)
+ vars.forEach { guest.add(it) }
+ if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
+ }
// Anything else, for a device that cannot be reached with a debugger: Downloads/steamdeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt
deleted file mode 100644
index de3e715fb..000000000
--- a/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.steamdeck.launcher.ui
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.unit.dp
-import com.steamdeck.launcher.gpu.FrameGen
-
-/**
- * Off, or an engine at 2x/3x/4x. The LSFG rows are dimmed 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 quietly does nothing.
- */
-@Composable
-fun FrameGenDialog(
- engine: String,
- multiplier: Int,
- lsfgReady: Boolean,
- onPick: (engine: String, multiplier: Int) -> Unit,
- onDismiss: () -> Unit,
-) {
- data class Row(val label: String, val engine: String, val multiplier: Int, val enabled: Boolean)
- val note = if (lsfgReady) "" else " — install Lossless Scaling in Steam"
- val rows = listOf(Row("Off", FrameGen.ENGINE_OFF, 2, true)) +
- (2..4).map { Row("Win-FG ${it}×", FrameGen.ENGINE_WINFG, it, true) } +
- (2..4).map { Row("LSFG ${it}×$note", FrameGen.ENGINE_LSFG, it, lsfgReady) }
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Frame generation") },
- text = {
- Column {
- Text(
- "Extra frames are generated between the real ones on the way to the screen, so a game running at 30 fps looks like 60. Takes effect at once, mid-game included.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 8.dp),
- )
- for (row in rows) {
- val selected = row.engine == engine && (row.engine == FrameGen.ENGINE_OFF || row.multiplier == multiplier)
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .alpha(if (row.enabled) 1f else 0.38f)
- .clickable(enabled = row.enabled) { onPick(row.engine, row.multiplier) }
- .padding(vertical = 2.dp),
- ) {
- RadioButton(selected = selected, onClick = null, enabled = row.enabled)
- Text(row.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 4.dp))
- }
- }
- }
- },
- confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
- )
-}
diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt
index 448fc14ab..67fccf8ed 100644
--- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt
+++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.steamdeck.launcher.core.FexPreset
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionService
@@ -39,6 +40,8 @@ class ModeSettings(
/** Steam only: the second library's root ("" = internal only) and what this device offers. */
val gameStorage: String? = null,
val storageOptions: List> = emptyList(),
+ /** Steam only: the FEXCore preset for the games the client launches. */
+ val fexPreset: String? = null,
)
class ModeSettingsActions(
@@ -58,6 +61,7 @@ class ModeSettingsActions(
val onRenderer: (String) -> Unit,
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
val onPickGameStorageFolder: () -> Unit = {},
+ val onFexPreset: (String) -> Unit = {},
val onDismiss: () -> Unit,
)
@@ -128,6 +132,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = "Auto shows it when no controller is attached.", onPick = a.onOsc,
)
}
+ if (steam && s.fexPreset != null) SettingsGroup("Games") {
+ ChoiceRow(
+ host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch. Also in the session's drawer.",
+ FexPreset.all.map { it.id to it.label }, s.fexPreset,
+ note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
+ )
+ }
if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") {
ToggleRow(host, "da", "DirectAudio for games", "Games play straight to the device, bypassing PulseAudio: lower latency. Off = PulseAudio for everything.", s.directAudio, onChange = a.onDirectAudio)
ToggleRow(host, "mic", "Microphone", "The device's microphone for voice chat, as the client's input device. Asks for the permission once.", s.mic, onChange = a.onMic)
diff --git a/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt
index 226248c4f..b3ba93fe2 100644
--- a/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt
+++ b/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt
@@ -33,6 +33,31 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.steamdeck.launcher.R
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.border
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.font.FontWeight
+import com.steamdeck.launcher.core.FexPreset
+import com.steamdeck.launcher.gpu.FrameGen
+import com.steamdeck.launcher.session.SessionPrefs
/** A line of numbers in the top-right corner. It takes no touches: everything goes to the game. */
@Composable
@@ -86,83 +111,139 @@ fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, en
/** Everything the drawer shows and does. */
class DrawerActions(
+ val steam: Boolean,
val hudOn: Boolean,
- val frameGenLabel: String,
+ val frameGenEngine: String,
+ val frameGenMultiplier: Int,
+ val lsfgReady: Boolean,
val oscMode: String,
val touchMode: String,
+ /** What "auto" resolves to right now: "touchpad" or "direct". */
+ val touchAuto: String,
val shapeMode: String,
+ val fexPreset: String,
val onHud: (Boolean) -> Unit,
- val onFrameGen: () -> Unit,
+ val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onKeyboard: () -> Unit,
/** Sends the Guide button (the client's menu); null on the desktop, where there is none. */
val onSteamMenu: (() -> Unit)?,
val onProtons: () -> Unit,
- val onOsc: () -> Unit,
- val onTouch: () -> Unit,
- val onShape: () -> Unit,
+ val onOsc: (String) -> Unit,
+ val onTouch: (String) -> Unit,
+ val onShape: (String) -> Unit,
+ val onFexPreset: (String) -> Unit,
val onBackground: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
)
/**
- * The drawer Back opens over a running session: the switches a player reaches for mid-game, and
- * the two things that leave the session. A tap on the dimmed area closes it.
+ * The drawer Back opens over a running session, in the front end's own dress: a panel that
+ * slides in from the right with the switches a player reaches for mid-game as rows whose values
+ * open in place, what only applies at the next session under its own heading, and the two ways
+ * out at the end. Steam and the desktop share the rows that mean the same on both; the client's
+ * own (its menu, the virtual pad, the FEX preset, the compatibility tools) show only there.
*/
@Composable
-fun SessionDrawer(a: DrawerActions) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color(0x66000000))
- .clickable(interactionSource = MutableInteractionSource(), indication = null, onClick = a.onClose),
- contentAlignment = Alignment.CenterEnd,
- ) {
- Column(
- modifier = Modifier
- .fillMaxHeight()
- .width(264.dp)
- .background(Color(0xF2151A22))
- // The panel swallows its own touches so they do not close it.
- .clickable(interactionSource = MutableInteractionSource(), indication = null) {}
- // More entries than a landscape phone has height for: the panel scrolls, and the
- // two session-leaving actions ride at the end of the list rather than pinned.
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
+fun SessionDrawer(open: Boolean, a: DrawerActions) {
+ val colors = MaterialTheme.colorScheme
+ val host = rememberMenuHost()
+ val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
+ if (open || veil > 0.01f) Box(
+ modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
+ .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
+ )
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
+ AnimatedVisibility(
+ open,
+ enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
+ exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)),
) {
- Text("SteamDeck", color = Color.White, fontSize = 16.sp, modifier = Modifier.padding(bottom = 12.dp))
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
- Text("Performance HUD", color = Color(0xFFDDDDDD), fontSize = 13.sp, modifier = Modifier.weight(1f))
- Switch(checked = a.hudOn, onCheckedChange = a.onHud)
- }
- OutlinedButton(onClick = a.onKeyboard, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
- Text("Keyboard", fontSize = 13.sp)
- }
- if (a.onSteamMenu != null) OutlinedButton(onClick = a.onSteamMenu, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Steam menu ◉", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onFrameGen, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Frame generation: ${a.frameGenLabel}", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onProtons, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Compatibility tools", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onOsc, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("On-screen controls: ${a.oscMode}", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onTouch, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Touch: ${a.touchMode}", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onShape, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Display shape: ${a.shapeMode} · next session", fontSize = 13.sp)
- }
- Spacer(Modifier.height(18.dp))
- OutlinedButton(onClick = a.onBackground, modifier = Modifier.fillMaxWidth()) {
- Text("Send to background", fontSize = 13.sp)
- }
- OutlinedButton(onClick = a.onStop, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
- Text("Stop session", fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(340.dp)
+ .background(Color(0xF7101418))
+ // The panel swallows its own touches so they do not close it.
+ .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 14.dp, vertical = 16.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)) {
+ Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, Color(0xFF7B4DFF)))))
+ Spacer(Modifier.width(10.dp))
+ Column {
+ Text("SteamDeck", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
+ Text(if (a.steam) "Steam session" else "Desktop session", fontSize = 11.sp, color = colors.onSurfaceVariant)
+ }
+ }
+
+ SettingsGroup("Now") {
+ ToggleRow(host, "hud", "Performance HUD", "The frame counter in the corner.", a.hudOn, onChange = a.onHud)
+ 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"
+ }
+ SettingsRow("Frame generation", "Extra frames between the real ones; takes effect at once, mid-game included.", highlighted = fgOpen) {
+ Box {
+ ValueChip(fgLabel, fgOpen) { host.open = if (fgOpen) null else "fg" }
+ AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") {
+ val need = if (a.lsfgReady) null else "install Lossless Scaling in Steam"
+ MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF) { 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 }
+ }
+ }
+ }
+ ChoiceRow(
+ host, "touch", "Touch", "How a finger drives the pointer.",
+ listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"), a.touchMode,
+ note = "Touchpad: drag moves, tap clicks. Direct: the pointer jumps under the finger.", onPick = a.onTouch,
+ )
+ if (a.steam) ChoiceRow(
+ host, "osc", "On-screen controls", "The virtual pad drawn over a game.",
+ listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"), a.oscMode,
+ note = "Auto shows it when no controller is attached.", onPick = a.onOsc,
+ )
+ ActionRow("Keyboard", "The on-screen keyboard, for a field the client or a program is waiting on.", "Show") { host.open = null; a.onKeyboard() }
+ if (a.onSteamMenu != null) ActionRow("Steam menu", "The Guide button: the client's own overlay, for a pad without one.", "Open ◉") { host.open = null; a.onSteamMenu.invoke() }
+ }
+
+ SettingsGroup("Next session") {
+ ChoiceRow(
+ host, "shape", "Display shape", "gamescope sizes its display once, when a session starts.",
+ listOf(SessionPrefs.SHAPE_AUTO to "The panel's shape", SessionPrefs.SHAPE_WIDE to "16:9 with bars"), a.shapeMode, onPick = a.onShape,
+ )
+ if (a.steam) ChoiceRow(
+ host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch.",
+ FexPreset.all.map { it.id to it.label }, a.fexPreset, note = FexPreset.byId(a.fexPreset).detail, onPick = a.onFexPreset,
+ )
+ if (a.steam) ActionRow("Compatibility tools", "The Proton builds the client can run games with.", "Manage") { host.open = null; a.onProtons() }
+ }
+
+ Spacer(Modifier.height(18.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ SecondaryButton("Send to background") { host.open = null; a.onBackground() }
+ DangerButton("Stop session") { host.open = null; a.onStop() }
+ }
+ Spacer(Modifier.height(12.dp))
}
}
}
}
+
+/** The one button that ends things: outlined in the error colour, filled on focus. */
+@Composable
+private fun DangerButton(text: String, onClick: () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val src = remember { MutableInteractionSource() }
+ val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
+ val fill by animateColorAsState(if (hot) colors.error.copy(alpha = 0.18f) else Color.Transparent, Motion.tw(220), label = "dangerFill")
+ Box(
+ modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(fill).border(1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.5f), RoundedCornerShape(12.dp))
+ .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 11.dp),
+ ) { Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.error, maxLines = 1) }
+}