mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Add Steam and QAM touch controls
This commit is contained in:
1 parent
a7aee50ea8
commit
b3cdc14800
6 files changed
+136
-30
No files matched your search
@@ -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 ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user