A drawer on Back: HUD, frame generation, on-screen controls, background, stop

Back used to background the app outright. It now opens a slim panel on the
right with the switches a player reaches for mid-game — the performance HUD,
the frame-generation picker (shared with the main screen), the on-screen
controls' mode (auto / always / never, replacing the file-only override) —
and the two things that leave the session: send it to the background, or stop
it. Back again, or a tap outside, closes it.
This commit is contained in:
The412Banner committed 2026-09-19 15:37:37 -04:00
1 parent 3582186951
commit 51303fa342
9 files changed
+260 -42

No files matched your search

+4 -3
View File
@@ -70,9 +70,10 @@ Pick an engine and 2×/3×/4× on the main screen; it applies immediately, mid-g
A session belongs to a foreground service, not to the activity, so leaving Big Picture does not
end it: the process stays at perceptible priority, a partial wake lock keeps the CPU from dropping
the guest's threads, and a high-performance WiFi lock keeps a backgrounded download from being
throttled to nothing. Back puts the app away and leaves Steam running; the (silent) notification
brings it back. A session ends only when you say so — the notification's **Stop session** action —
or when the app is swiped out of recents.
throttled to nothing. **Back opens the drawer**: the performance HUD switch, frame generation, on-screen controls
(auto / always / never), *Send to background* and *Stop session*. Backgrounded, Steam keeps
running and the (silent) notification brings it back. A session ends only when you say so — the
drawer or the notification's **Stop session** — or when the app is swiped out of recents.
## Device switches
@@ -44,7 +44,7 @@ class MainActivity : Activity() {
playButton = findViewById(R.id.play)
runtimeButton = findViewById(R.id.runtime)
frameGenButton = findViewById(R.id.frame_gen)
frameGenButton.setOnClickListener { pickFrameGen() }
frameGenButton.setOnClickListener { FrameGen.showPicker(this) { render() } }
playButton.setOnClickListener { startActivity(Intent(this, SessionActivity::class.java)) }
runtimeButton.setOnClickListener { onRuntimeButton() }
@@ -123,34 +123,6 @@ class MainActivity : Activity() {
ui.post { render() }
}
/** Off, or an engine at 2x/3x/4x. Saved, and pushed to a running session on the next resume. */
private fun pickFrameGen() {
val choices = arrayOf("Off",
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
"LSFG 2× (needs Lossless Scaling)", "LSFG 3× (needs Lossless Scaling)", "LSFG 4× (needs Lossless Scaling)")
val engine = FrameGen.engine(this)
val current = when (engine) {
FrameGen.ENGINE_WINFG -> FrameGen.multiplier(this) - 1
FrameGen.ENGINE_LSFG -> FrameGen.multiplier(this) + 2
else -> 0
}
AlertDialog.Builder(this)
.setTitle(R.string.frame_gen_title)
.setSingleChoiceItems(choices, current) { dialog, which ->
when (which) {
0 -> FrameGen.set(this, FrameGen.ENGINE_OFF, 2)
in 1..3 -> FrameGen.set(this, FrameGen.ENGINE_WINFG, which + 1)
else -> FrameGen.set(this, FrameGen.ENGINE_LSFG, which - 2)
}
dialog.dismiss()
render()
}
.setNeutralButton(android.R.string.ok, null)
.setMessage(null)
.show()
Log.i(TAG, "frame generation picker: " + FrameGen.label(this))
}
private fun render() {
frameGenButton.text = getString(R.string.frame_gen, FrameGen.label(this))
val installed = LinuxRuntimeInstaller.installedVersion(this)
@@ -24,6 +24,8 @@ import com.steamdeck.launcher.input.PadBridge
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.session.LoadingPanel
import com.steamdeck.launcher.session.PerfHud
import com.steamdeck.launcher.session.SessionDrawer
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.wayland.CompositorHost
@@ -44,6 +46,7 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var loading: LoadingPanel
private lateinit var hud: PerfHud
private lateinit var drawer: SessionDrawer
private var padBridge: PadBridge? = null
private var onScreenControls: OnScreenControls? = null
private var watching = true
@@ -83,6 +86,13 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
// client's first frame, since before that there is nothing to count.
hud = PerfHud(this)
root.addView(hud.view, root.indexOfChild(panel))
// Back opens this; it sits above everything, the loading panel included.
drawer = SessionDrawer(this, root,
onHudChanged = { hud.refresh() },
onOscChanged = { updateOnScreenControls() },
onFrameGenChanged = { applyFrameGen() },
onBackground = { moveTaskToBack(true) },
onStop = { SessionService.stop(this); finish() })
// A session that has already drawn is past its milestones; do not cover its picture.
if (SessionState.running && SessionState.firstFrameSeen) {
loading.hide()
@@ -259,26 +269,27 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
/**
* Back leaves the session running and puts the app behind whatever the user wants next; the
* notification brings it back. Ending a session is done deliberately — from the
* notification's Stop action, or by swiping the app out of recents.
* Back opens the drawer (and closes it again). Leaving the session running in the background
* and ending it are both actions in there, so neither can happen by accident from a button
* a game might also be reading.
*/
@Suppress("DEPRECATION")
override fun onBackPressed() {
moveTaskToBack(true)
drawer.toggle()
}
/**
* `steamdeck-osc` in Downloads forces the matter either way ("always" / "never"); with no such
* file the controls follow what is attached.
* The drawer's mode ("always" / "never") decides outright; on "auto" the controls follow
* what is attached. `steamdeck-osc` in Downloads still overrides, for a device we cannot reach.
*/
private fun updateOnScreenControls() {
val forced = File(Environment.getExternalStorageDirectory(), "Download/steamdeck-osc")
.takeIf { it.isFile }
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
val show = when {
forced == "always" -> true
forced == "never" -> false
?: SessionPrefs.oscMode(this)
val show = when (forced) {
SessionPrefs.OSC_ALWAYS -> true
SessionPrefs.OSC_NEVER -> false
else -> !PadBridge.anyControllerConnected()
}
val controls = onScreenControls ?: return
@@ -82,5 +82,30 @@ object FrameGen {
return problem
}
/** Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. */
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
val choices = arrayOf("Off",
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
"LSFG 2× (needs Lossless Scaling)", "LSFG 3× (needs Lossless Scaling)", "LSFG 4× (needs Lossless Scaling)")
val current = when (engine(activity)) {
ENGINE_WINFG -> multiplier(activity) - 1
ENGINE_LSFG -> multiplier(activity) + 2
else -> 0
}
android.app.AlertDialog.Builder(activity)
.setTitle(com.steamdeck.launcher.R.string.frame_gen_title)
.setSingleChoiceItems(choices, 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"
}
@@ -49,7 +49,10 @@ class PerfHud(context: Context) {
private var lastTick = 0L
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val enabled = !File(Environment.getExternalStorageDirectory(), "Download/steamdeck-no-hud").exists()
/** The session's own switch (drawer), with the Downloads file as a device-side override. */
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
&& !File(Environment.getExternalStorageDirectory(), "Download/steamdeck-no-hud").exists()
private val listener = object : WaylandCompositor.GameListener {
override fun onGameSurface(window: String?, gpuName: String?) {}
@@ -68,6 +71,11 @@ class PerfHud(context: Context) {
}
}
/** Re-reads the switch: starts or stops to match it. */
fun refresh() {
if (enabled) start() else stop()
}
fun start() {
if (!enabled || running) return
running = true
@@ -0,0 +1,89 @@
package com.steamdeck.launcher.session
import android.app.Activity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.Switch
import com.steamdeck.launcher.R
import com.steamdeck.launcher.gpu.FrameGen
/**
* The drawer Back opens over a running session. It owns nothing: every control reads and writes
* a preference or calls back into the activity, so closing it changes nothing by itself.
*/
class SessionDrawer(
private val activity: Activity,
parent: ViewGroup,
private val onHudChanged: (Boolean) -> Unit,
private val onOscChanged: () -> Unit,
private val onFrameGenChanged: () -> Unit,
private val onBackground: () -> Unit,
private val onStop: () -> Unit,
) {
private val root: View = activity.layoutInflater.inflate(R.layout.session_drawer, parent, false)
private val hud = root.findViewById<Switch>(R.id.drawer_hud)
private val frameGen = root.findViewById<Button>(R.id.drawer_frame_gen)
private val osc = root.findViewById<Button>(R.id.drawer_osc)
init {
root.visibility = View.GONE
parent.addView(root)
// The dimmed area closes; the panel itself swallows its touches.
root.setOnClickListener { close() }
root.findViewById<View>(R.id.drawer_panel).setOnClickListener { }
hud.setOnCheckedChangeListener { _, on ->
SessionPrefs.setHudEnabled(activity, on)
onHudChanged(on)
}
frameGen.setOnClickListener {
FrameGen.showPicker(activity) {
refresh()
onFrameGenChanged()
}
}
osc.setOnClickListener {
val next = when (SessionPrefs.oscMode(activity)) {
SessionPrefs.OSC_AUTO -> SessionPrefs.OSC_ALWAYS
SessionPrefs.OSC_ALWAYS -> SessionPrefs.OSC_NEVER
else -> SessionPrefs.OSC_AUTO
}
SessionPrefs.setOscMode(activity, next)
refresh()
onOscChanged()
}
root.findViewById<View>(R.id.drawer_background).setOnClickListener {
close()
onBackground()
}
root.findViewById<View>(R.id.drawer_stop).setOnClickListener {
close()
onStop()
}
}
val isOpen: Boolean get() = root.visibility == View.VISIBLE
fun open() {
refresh()
root.visibility = View.VISIBLE
root.bringToFront()
}
fun close() {
root.visibility = View.GONE
}
fun toggle() = if (isOpen) close() else open()
private fun refresh() {
hud.isChecked = SessionPrefs.hudEnabled(activity)
frameGen.text = activity.getString(R.string.frame_gen, FrameGen.label(activity))
val mode = when (SessionPrefs.oscMode(activity)) {
SessionPrefs.OSC_ALWAYS -> activity.getString(R.string.osc_always)
SessionPrefs.OSC_NEVER -> activity.getString(R.string.osc_never)
else -> activity.getString(R.string.osc_auto)
}
osc.text = activity.getString(R.string.drawer_osc, mode)
}
}
@@ -0,0 +1,24 @@
package com.steamdeck.launcher.session
import android.content.Context
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_NEVER = "never"
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hud", on).apply()
}
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
fun setOscMode(context: Context, mode: String) {
prefs(context).edit().putString("osc", mode).apply()
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The in-session drawer, opened with Back. A tap on the dimmed area or Back again closes it.
Small on purpose: the switches a player reaches for mid-game, nothing that needs the main
screen.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/drawer_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:id="@+id/drawer_panel"
android:layout_width="264dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:orientation="vertical"
android:background="#F2151A22"
android:clickable="true"
android:focusable="true"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#FFFFFFFF"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<Switch
android:id="@+id/drawer_hud"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:text="@string/drawer_hud"
android:textColor="#FFDDDDDD"
android:textSize="13sp" />
<Button
android:id="@+id/drawer_frame_gen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:minHeight="40dp"
android:textSize="13sp" />
<Button
android:id="@+id/drawer_osc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/drawer_background"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:textSize="13sp"
android:text="@string/drawer_background" />
<Button
android:id="@+id/drawer_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp"
android:textColor="#FFFF8A80"
android:text="@string/stop_session" />
</LinearLayout>
</FrameLayout>
+6
View File
@@ -36,6 +36,12 @@
<string name="frame_gen">Frame generation: %1$s</string>
<string name="frame_gen_title">Frame generation</string>
<string name="frame_gen_help">Extra frames are generated between the real ones on the way to the screen, so a game running at 30 fps looks like 60. Win-FG is built in. LSFG uses the shaders from your own copy of Lossless Scaling — install it from the Steam client in this app first. Takes effect immediately, even mid-game.</string>
<string name="drawer_hud">Performance HUD</string>
<string name="drawer_osc">On-screen controls: %1$s</string>
<string name="drawer_background">Send to background</string>
<string name="osc_auto">auto</string>
<string name="osc_always">always</string>
<string name="osc_never">never</string>
<string name="credits_line">by The412Banner and maxjivi05 · credits</string>
<string name="credits_title">Credits</string>
<string name="credits_body">SteamDeck\n\nThe412Banner — the app, the Wayland compositor, the runtime and the Steam session.\n\nmaxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.\n\nGPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors\' own.</string>