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) } +}