diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index 11c7e9824..ac5bcaae3 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -651,25 +651,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } - /** - * 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. - */ + /** The mode decides which controls appear; `steamdeck-osc` in Downloads still overrides. */ private fun updateOnScreenControls() { val forced = File(Environment.getExternalStorageDirectory(), "Download/steamdeck-osc") .takeIf { it.isFile } ?.let { FileUtils.readString(it)?.trim()?.lowercase() } ?: SessionPrefs.oscMode(this) + val controls = onScreenControls ?: return + val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM + controls.setButtonsOnly(buttonsOnly) val show = when (forced) { SessionPrefs.OSC_ALWAYS -> true + SessionPrefs.OSC_STEAM_QAM -> true SessionPrefs.OSC_NEVER -> false else -> !PadBridge.anyControllerConnected() } - val controls = onScreenControls ?: return if (show == (controls.visibility == View.VISIBLE)) return if (!show) controls.releaseAll() controls.visibility = if (show) View.VISIBLE else View.GONE - Log.i(TAG, "on-screen controls " + (if (show) "shown" else "hidden")) + Log.i(TAG, "on-screen controls " + when { + !show -> "hidden" + buttonsOnly -> "Steam + QAM" + else -> "full pad" + }) } // ── Lifecycle ─────────────────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt index 0aa635284..7ffea3209 100644 --- a/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt @@ -46,7 +46,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont var ky = 0f /** A stick that moved or was let go: its axes are written once, then left alone. */ var dirty = false - fun contains(x: Float, y: Float): Boolean { + fun contains(x: Float, y: Float, radius: Float = this.radius): Boolean { val dx = x - cx val dy = y - cy // A generous hit area: a finger on glass is not a mouse, and a miss in Big Picture @@ -84,8 +84,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont Control("start", "☰", 7, -1, -1, dp(20f)), // The client's own in-game menu; the interposer publishes it as BTN_MODE. Control("guide", "◉", GamepadState.IDX_BUTTON_MODE.toInt(), -1, -1, dp(22f)), + Control("qam", "⋯", -1, -1, -1, dp(22f)), ) + private var buttonsOnly = false + private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE @@ -107,6 +110,14 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont if (width > 0 && height > 0) { layoutControls(width.toFloat(), height.toFloat()); invalidate() } } + fun setButtonsOnly(enabled: Boolean) { + if (buttonsOnly == enabled) return + releaseAll() + buttonsOnly = enabled + if (width > 0 && height > 0) layoutControls(width.toFloat(), height.toFloat()) + invalidate() + } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) layoutControls(w.toFloat(), h.toFloat()) @@ -118,6 +129,12 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont * the bottom corners in every one; nothing is placed where a game's own HUD usually is. */ private fun layoutControls(w: Float, h: Float) { + if (buttonsOnly) { + val inset = dp(44f) + place("guide", inset, h - inset) + place("qam", w - inset, h - inset) + return + } val p = picture val sideBar = if (p != null) minOf(p.left, w - p.right) else 0f val bottomBand = if (p != null) h - p.bottom else 0f @@ -195,14 +212,16 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont override fun onDraw(canvas: Canvas) { for (control in controls) { + if (!isVisible(control)) continue val held = control.pressedBy != -1 + val radius = controlRadius(control) if (control.stick >= 0) { // The base, then the knob where the finger holds it. fill.color = if (held) Color.argb(60, 199, 125, 255) else Color.argb(50, 20, 12, 30) - canvas.drawCircle(control.cx, control.cy, control.radius, fill) + canvas.drawCircle(control.cx, control.cy, radius, fill) stroke.color = if (held) Color.argb(200, 235, 212, 255) else Color.argb(90, 201, 160, 255) - canvas.drawCircle(control.cx, control.cy, control.radius, stroke) - val knob = control.radius * 0.46f + canvas.drawCircle(control.cx, control.cy, radius, stroke) + val knob = radius * 0.46f fill.color = if (held) Color.argb(170, 199, 125, 255) else Color.argb(110, 60, 40, 90) canvas.drawCircle(control.cx + control.kx, control.cy + control.ky, knob, fill) stroke.color = if (held) Color.argb(230, 245, 230, 255) else Color.argb(140, 201, 160, 255) @@ -213,12 +232,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont continue } fill.color = if (held) Color.argb(150, 199, 125, 255) else Color.argb(70, 20, 12, 30) - canvas.drawCircle(control.cx, control.cy, control.radius, fill) + canvas.drawCircle(control.cx, control.cy, radius, fill) stroke.color = if (held) Color.argb(220, 235, 212, 255) else Color.argb(110, 201, 160, 255) - canvas.drawCircle(control.cx, control.cy, control.radius, stroke) + canvas.drawCircle(control.cx, control.cy, radius, stroke) text.color = if (held) Color.WHITE else Color.argb(190, 225, 210, 245) - text.textSize = control.radius * 0.85f - canvas.drawText(control.label, control.cx, control.cy + text.textSize * 0.35f, text) + val label = if (buttonsOnly && control.id == "guide") "Steam" else control.label + text.textSize = if (buttonsOnly && control.id == "guide") dp(11f) else radius * 0.85f + canvas.drawText(label, control.cx, control.cy + text.textSize * 0.35f, text) } } @@ -277,18 +297,32 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont return false } - private fun controlAt(x: Float, y: Float): Control? = controls.firstOrNull { it.contains(x, y) } + private fun controlAt(x: Float, y: Float): Control? = + controls.firstOrNull { isVisible(it) && it.contains(x, y, controlRadius(it)) } + + private fun isVisible(control: Control): Boolean = + !buttonsOnly || control.id == "guide" || control.id == "qam" + + private fun controlRadius(control: Control): Float = + if (buttonsOnly && (control.id == "guide" || control.id == "qam")) dp(30f) else control.radius private fun apply() { - pad.applyTouch { state -> - for (control in controls) { - val held = control.pressedBy != -1 - when { - control.stick >= 0 && !control.dirty -> {} - control.stick == 0 -> { state.thumbLX = control.kx / control.radius; state.thumbLY = control.ky / control.radius; control.dirty = false } - control.stick == 1 -> { state.thumbRX = control.kx / control.radius; state.thumbRY = control.ky / control.radius; control.dirty = false } - control.dpad >= 0 -> state.dpad[control.dpad] = held - control.button >= 0 -> state.setPressed(control.button, held) + if (buttonsOnly) { + val guidePressed = controls.first { it.id == "guide" }.pressedBy != -1 + val qamPressed = controls.first { it.id == "qam" }.pressedBy != -1 + pad.setSystemButtons(guidePressed, qamPressed) + } else { + pad.setSystemButtons(false, false) + pad.applyTouch { state -> + for (control in controls) { + val held = control.pressedBy != -1 + when { + control.stick >= 0 && !control.dirty -> {} + control.stick == 0 -> { state.thumbLX = control.kx / control.radius; state.thumbLY = control.ky / control.radius; control.dirty = false } + control.stick == 1 -> { state.thumbRX = control.kx / control.radius; state.thumbRY = control.ky / control.radius; control.dirty = false } + control.dpad >= 0 -> state.dpad[control.dpad] = held + control.button >= 0 -> state.setPressed(control.button, held) + } } } } @@ -297,7 +331,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont /** Everything up, for when the controls are hidden mid-press. */ fun releaseAll() { - if (controls.none { it.pressedBy != -1 }) return + if (controls.none { it.pressedBy != -1 } && !buttonsOnly) return controls.forEach { it.pressedBy = -1; if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true; it.kx = 0f; it.ky = 0f } apply() } diff --git a/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java b/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java index faa24de66..bab50b595 100644 --- a/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java @@ -1,5 +1,7 @@ package com.steamdeck.launcher.input; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import android.view.InputDevice; import android.view.KeyEvent; @@ -24,10 +26,20 @@ public final class PadBridge { /** Slot 0 is the one the session exports; extra slots would each show as another pad. */ private static final int SLOT = 0; private static final float DEAD_ZONE = 0.12f; + private static final long QAM_GUIDE_LEAD_MS = 80; + private static final long QAM_A_HOLD_MS = 120; + private static final long QAM_GUIDE_TAIL_MS = 40; private final FakeInputWriter writer; private final GamepadState state = new GamepadState(); + private final GamepadState effectiveState = new GamepadState(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; + private boolean systemGuidePressed; + private boolean systemQamPressed; + private boolean qamChordActive; + private boolean qamSyntheticAPressed; + private int qamChordGeneration; public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); @@ -43,6 +55,11 @@ public final class PadBridge { } public synchronized void stop() { + systemGuidePressed = false; + systemQamPressed = false; + qamChordActive = false; + qamSyntheticAPressed = false; + qamChordGeneration++; if (open) { state.reset(); writer.writeGamepadState(state); @@ -139,9 +156,54 @@ public final class PadBridge { publish(); } + /** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */ + public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) { + if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return; + boolean qamStarted = qamPressed && !systemQamPressed; + systemGuidePressed = guidePressed; + systemQamPressed = qamPressed; + if (qamStarted && !qamChordActive) { + qamChordActive = true; + int generation = ++qamChordGeneration; + publish(); + mainHandler.postDelayed(() -> pressQamA(generation), QAM_GUIDE_LEAD_MS); + } else { + publish(); + } + } + + private synchronized void pressQamA(int generation) { + if (generation != qamChordGeneration || !qamChordActive) return; + qamSyntheticAPressed = true; + publish(); + mainHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS); + } + + private synchronized void releaseQamA(int generation) { + if (generation != qamChordGeneration || !qamChordActive) return; + qamSyntheticAPressed = false; + publish(); + mainHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS); + } + + private synchronized void releaseQamGuide(int generation) { + if (generation != qamChordGeneration || !qamChordActive) return; + qamChordActive = false; + publish(); + } + private void publish() { if (!open && !start()) return; - writer.writeGamepadState(state); + if (systemGuidePressed || qamChordActive) { + effectiveState.copy(state); + effectiveState.setPressed(GamepadState.IDX_BUTTON_MODE, true); + if (qamSyntheticAPressed) { + effectiveState.setPressed(0, state.isPressed(0) || qamSyntheticAPressed); + } + writer.writeGamepadState(effectiveState); + } else { + writer.writeGamepadState(state); + } } private static float axis(MotionEvent event, int axis) { 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 2c0f34461..9a5262051 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -6,6 +6,7 @@ import android.content.Context object SessionPrefs { const val OSC_AUTO = "auto" const val OSC_ALWAYS = "always" + const val OSC_STEAM_QAM = "steam-qam" const val OSC_NEVER = "never" private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE) 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 67fccf8ed..609a95d1b 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -128,8 +128,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) if (steam && s.oscMode != null) ChoiceRow( host, "osc", "On-screen controls", "The virtual pad drawn over a game.", - listOf("auto" to "Auto", "always" to "Always", "never" to "Never"), s.oscMode, - note = "Auto shows it when no controller is attached.", onPick = a.onOsc, + listOf( + SessionPrefs.OSC_AUTO to "Auto", + SessionPrefs.OSC_ALWAYS to "Always", + SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", + SessionPrefs.OSC_NEVER to "Never", + ), s.oscMode, + note = "Auto shows the full pad when no controller is attached. Steam + QAM shows only those two buttons.", onPick = a.onOsc, ) } if (steam && s.fexPreset != null) SettingsGroup("Games") { 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 6cc351b09..8a53e15bc 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt @@ -205,8 +205,8 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { ) 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, + listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"), a.oscMode, + note = "Auto shows the full pad when no controller is attached. Steam + QAM shows only those two buttons.", 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() }