diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0fe036288..ecf4d68c5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -84,6 +84,14 @@
fixed-shape window instead and STRETCHES it onto the other panel - the app never sees a
size change, so nothing it does can help.
-->
+
+
guard(controller_mutex());
auto controller = controller_map().find(fd);
@@ -1323,7 +1325,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
int result = nanosleep(&sleep_time, nullptr);
guard.lock();
if (result < 0) return -1;
- if (backoff_ns < 16 * 1000 * 1000) backoff_ns *= 2;
+ if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
}
}
@@ -1469,7 +1471,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout,
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
return 0;
- if (backoff_ms < 16)
+ if (backoff_ms < kMaxRingWaitMs)
backoff_ms *= 2;
}
}
@@ -1644,7 +1646,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
return 0;
- if (backoff_ms < 16)
+ if (backoff_ms < kMaxRingWaitMs)
backoff_ms *= 2;
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
index 1d50ac50a..972e363a0 100644
--- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
@@ -45,6 +45,10 @@ import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.ConfirmDialog
+import com.droiddeck.launcher.ui.ControllerActions
+import com.droiddeck.launcher.ui.ControllerMappingPage
+import com.droiddeck.launcher.input.ControllerPrefs
+import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.CreditsDialog
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
@@ -85,6 +89,8 @@ class MainActivity : ComponentActivity() {
private var showFrameGen by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
+ private var showMapping by mutableStateOf(false)
+ private var controllerSettings by mutableStateOf(null)
private var catalog by mutableStateOf?>(null)
private var catalogLoading by mutableStateOf(false)
private var packageRows by mutableStateOf?>(null)
@@ -234,6 +240,7 @@ class MainActivity : ComponentActivity() {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
+ showMapping -> { { MappingHost() } }
else -> null
}
FrontEndScreen(
@@ -246,7 +253,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
- pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else null,
+ pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -261,6 +268,8 @@ class MainActivity : ComponentActivity() {
sessionRunning = SessionState.running,
backActionsInverted = backActionsInverted,
buildLabel = BuildConfig.BUILD_LABEL,
+ oscMode = oscMode,
+ controller = controllerSettings,
),
FrontEndActions(
onPlay = { startSession(Intent(this, SessionActivity::class.java)) },
@@ -296,7 +305,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
},
onProtons = { openProtons() },
- onPerformance = { refreshCores(); showProtons = false; showPerformance = true },
+ onPerformance = { refreshCores(); showProtons = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onLogs = {
@@ -317,7 +326,7 @@ class MainActivity : ComponentActivity() {
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
- onPageBack = { settingsMode = null; showPerformance = false; showProtons = false },
+ onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
@@ -332,6 +341,17 @@ class MainActivity : ComponentActivity() {
onCheckLatestBuild = {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/The412Banner/DroidDeck/actions/workflows/build.yml")))
},
+ controller = ControllerActions(
+ onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
+ onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
+ onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
+ onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
+ onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
+ onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
+ onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
+ onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = true },
+ onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
+ ),
),
page = page,
)
@@ -385,6 +405,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
+ oscMode = SessionPrefs.oscMode(this)
+ refreshController()
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
@@ -453,10 +475,26 @@ class MainActivity : ComponentActivity() {
private fun openProtons() {
settingsMode = null
showPerformance = false
+ showMapping = false
showProtons = true
refreshProtons()
}
+ private fun refreshController() {
+ controllerSettings = ControllerPrefs.read(this)
+ }
+
+ @Composable
+ private fun MappingHost() {
+ val settings = controllerSettings ?: return
+ ControllerMappingPage(
+ mapping = settings.mapping,
+ onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
+ onReset = { ControllerPrefs.resetMapping(this); refreshController() },
+ onBack = { showMapping = false },
+ )
+ }
+
@Composable
private fun ProtonHost() {
ProtonPage(
@@ -687,6 +725,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
showPerformance = false
showProtons = false
+ showMapping = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 2d0c3c243..f8c5ea3fc 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -458,7 +458,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// was 16:9 - the picture came back squashed sideways. While a session runs, keep its size.
val size = if (SessionState.running) SessionState.outputSize else outputSize()
SessionState.outputSize = size
- onScreenControls?.setPicture(drawnRect())
if (!SessionState.running) SessionState.refreshHz = refreshHz()
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
// and a game's picture must keep its proportions with bars, not lose its edges.
@@ -532,8 +531,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val resized = surfaceW != 0 && (width != surfaceW || height != surfaceH)
surfaceW = width
surfaceH = height
- // The on-screen controls follow the picture: into the bars beside or under it when there are any.
- onScreenControls?.setPicture(drawnRect())
if (resized) {
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
@@ -1146,6 +1143,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
+ onScreenControls?.reload()
updateOnScreenControls()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
@@ -1160,6 +1158,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onPause()
}
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
+ val decor = window.decorView
+ decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES)
+ decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES) } }
+ }
+
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) goFullscreen()
@@ -1207,6 +1213,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
private const val TAG = "SessionActivity"
+ private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
private const val DRAWER_HAT_THRESHOLD = 0.5f
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt
new file mode 100644
index 000000000..34478dc6f
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt
@@ -0,0 +1,81 @@
+package com.droiddeck.launcher.input
+
+import android.os.Build
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.ui.DroidDeckTheme
+import com.droiddeck.launcher.ui.SecondaryButton
+
+class ControllerEditorActivity : ComponentActivity() {
+ private lateinit var controls: OnScreenControls
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ immersive()
+ val root = FrameLayout(this)
+ root.setBackgroundColor(0xFF0B0F14.toInt())
+ controls = OnScreenControls(this, null, editing = true)
+ root.addView(controls, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
+ val bar = ComposeView(this).apply {
+ setContent {
+ DroidDeckTheme {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(top = 16.dp)
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), RoundedCornerShape(14.dp))
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ Text("Drag a control to move it", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) {
+ SecondaryButton("Reset to default") { controls.resetLayout() }
+ SecondaryButton("Cancel") { finish() }
+ SecondaryButton("Save") {
+ controls.saveLayout()
+ finish()
+ }
+ }
+ }
+ }
+ }
+ }
+ root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.CENTER_HORIZONTAL))
+ setContentView(root)
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) immersive()
+ }
+
+ private fun immersive() {
+ @Suppress("DEPRECATION")
+ window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
+ if (Build.VERSION.SDK_INT >= 28) {
+ window.attributes = window.attributes.apply {
+ layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt
new file mode 100644
index 000000000..cd56bf23b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt
@@ -0,0 +1,115 @@
+package com.droiddeck.launcher.input
+
+import android.content.Context
+
+object ControllerPrefs {
+ const val STEAM_BLUE = 0xFF1A9FFF.toInt()
+ const val OFF = "off"
+
+ val tints = listOf(
+ STEAM_BLUE to "Steam blue",
+ 0xFF66C0F4.toInt() to "Sky",
+ 0xFFE8EEF4.toInt() to "White",
+ 0xFFC77DFF.toInt() to "Violet",
+ 0xFF3DDC84.toInt() to "Green",
+ 0xFFFFA726.toInt() to "Amber",
+ 0xFFFF5252.toInt() to "Red",
+ 0xFFFF6FB5.toInt() to "Pink",
+ )
+
+ val opacities = listOf(40, 60, 80, 100)
+
+ val sizes = listOf(80, 90, 100, 110, 125)
+
+ val mappable = listOf(
+ "a" to "A button", "b" to "B button", "x" to "X button", "y" to "Y button",
+ "lb" to "Left bumper", "rb" to "Right bumper", "lt" to "Left trigger", "rt" to "Right trigger",
+ "select" to "View button", "start" to "Menu button",
+ )
+
+ val targets = listOf(
+ "a" to "A", "b" to "B", "x" to "X", "y" to "Y",
+ "lb" to "LB", "rb" to "RB", "lt" to "LT", "rt" to "RT",
+ "l3" to "L3", "r3" to "R3", "select" to "View", "start" to "Menu", "guide" to "Steam",
+ "up" to "D-pad up", "down" to "D-pad down", "left" to "D-pad left", "right" to "D-pad right",
+ OFF to "Hidden",
+ )
+
+ class Settings(
+ val tint: Int,
+ val opacity: Int,
+ val size: Int,
+ val stickClick: Boolean,
+ val customLayout: Boolean,
+ val mapping: Map,
+ )
+
+ private fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE)
+
+ fun read(context: Context): Settings {
+ val p = prefs(context)
+ return Settings(
+ tint = p.getInt("tint", STEAM_BLUE),
+ opacity = p.getInt("opacity", 100).takeIf { it in opacities } ?: 100,
+ size = p.getInt("size", 100).takeIf { it in sizes } ?: 100,
+ stickClick = p.getBoolean("stickClick", true),
+ customLayout = p.all.keys.any { it.startsWith("layout.") },
+ mapping = mappable.associate { (id, _) -> id to target(context, id) },
+ )
+ }
+
+ fun target(context: Context, id: String): String =
+ prefs(context).getString("map.$id", id)?.takeIf { t -> targets.any { it.first == t } } ?: id
+
+ fun setTarget(context: Context, id: String, target: String) {
+ prefs(context).edit().putString("map.$id", target).apply()
+ }
+
+ fun resetMapping(context: Context) {
+ val p = prefs(context)
+ p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
+ }
+
+ fun setTint(context: Context, tint: Int) {
+ prefs(context).edit().putInt("tint", tint).apply()
+ }
+
+ fun setOpacity(context: Context, percent: Int) {
+ prefs(context).edit().putInt("opacity", percent).apply()
+ }
+
+ fun setSize(context: Context, percent: Int) {
+ prefs(context).edit().putInt("size", percent).apply()
+ }
+
+ fun setStickClick(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean("stickClick", on).apply()
+ }
+
+ fun layout(context: Context, width: Int, height: Int): Map> {
+ val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap()
+ return raw.split(';').mapNotNull { entry ->
+ val (group, pos) = entry.split(':').takeIf { it.size == 2 } ?: return@mapNotNull null
+ val (x, y) = pos.split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 2 } ?: return@mapNotNull null
+ group to (x to y)
+ }.toMap()
+ }
+
+ fun setLayout(context: Context, width: Int, height: Int, positions: Map>) {
+ val raw = positions.entries.joinToString(";") { (group, pos) -> "$group:${pos.first},${pos.second}" }
+ prefs(context).edit().putString("layout.${width}x$height", raw).apply()
+ }
+
+ fun resetLayout(context: Context, width: Int, height: Int) {
+ prefs(context).edit().remove("layout.${width}x$height").apply()
+ }
+
+ fun resetAll(context: Context) {
+ prefs(context).edit().clear().apply()
+ }
+
+ fun resetAllLayouts(context: Context) {
+ val p = prefs(context)
+ p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
index 2de8a62e7..7ac2159d2 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
@@ -5,56 +5,46 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
-import android.graphics.RectF
+import android.graphics.Path
+import android.graphics.Rect
+import android.os.Build
import android.view.MotionEvent
import android.view.View
+import android.view.WindowInsets
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sin
import kotlin.math.sqrt
-/**
- * A pad drawn on the screen for devices that have no controller attached.
- *
- * It writes into the same [PadBridge] a physical pad does, so the Steam client sees one Xbox pad
- * whichever is being used, and the two can be used at once. A d-pad, two sticks, the four face
- * buttons, the bumpers, and the three centre buttons: enough to drive Big Picture and to play,
- * and no control editor.
- *
- * Where the controls go follows the picture. The compositor letterboxes the session's output onto
- * the panel, and a foldable or a wide phone has bars beside or above and below it that are empty
- * black: the controls take the bars first, and only overlay the picture when there are none wide
- * enough. Only the controls themselves take touches; everything else falls through to the
- * activity, where it moves the mouse pointer.
- */
@SuppressLint("ViewConstructor")
-class OnScreenControls(context: Context, private val pad: PadBridge) : View(context) {
+class OnScreenControls(context: Context, private val pad: PadBridge?, private val editing: Boolean = false) : View(context) {
private class Control(
val id: String,
- val label: String,
- /** A PadState button number, or -1. */
- val button: Int,
- /** A d-pad direction (0 up, 1 right, 2 down, 3 left), or -1. */
- val dpad: Int,
- /** 0 = left stick, 1 = right stick, -1 = a button. */
+ val group: String,
val stick: Int,
- val radius: Float,
+ val radiusDp: Float,
+ val offsetXDp: Float,
+ val offsetYDp: Float,
) {
+ var target = id
+ var radius = 0f
var cx = 0f
var cy = 0f
- var pressedBy = -1 // the pointer id holding it, or -1
- /** A stick's knob offset from its centre, in [-radius, radius]. */
+ var pressedBy = -1
var kx = 0f
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, radius: Float = this.radius): Boolean {
+ var clicked = false
+ var lastUp = 0L
+
+ fun contains(x: Float, y: Float, r: Float): 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
- // means the user thinks the pad does not work.
- val r = radius * 1.25f
- return dx * dx + dy * dy <= r * r
+ val hit = r * 1.25f
+ return dx * dx + dy * dy <= hit * hit
}
- /** Moves the knob towards the finger, clamped to the base. */
+
fun drag(x: Float, y: Float) {
var dx = x - cx
var dy = y - cy
@@ -64,192 +54,325 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
}
}
- private val density = resources.displayMetrics.density
- private fun dp(value: Float) = value * density
-
private val controls = listOf(
- Control("up", "▲", -1, 0, -1, dp(26f)),
- Control("right", ">", -1, 1, -1, dp(26f)),
- Control("down", "▼", -1, 2, -1, dp(26f)),
- Control("left", "<", -1, 3, -1, dp(26f)),
- Control("ls", "L", -1, -1, 0, dp(48f)),
- Control("rs", "R", -1, -1, 1, dp(48f)),
- Control("a", "A", 0, -1, -1, dp(30f)),
- Control("b", "B", 1, -1, -1, dp(30f)),
- Control("x", "X", 2, -1, -1, dp(30f)),
- Control("y", "Y", 3, -1, -1, dp(30f)),
- Control("lb", "LB", 4, -1, -1, dp(24f)),
- Control("rb", "RB", 5, -1, -1, dp(24f)),
- Control("select", "⧉", 6, -1, -1, dp(20f)),
- Control("start", "☰", 7, -1, -1, dp(20f)),
- // The client's own in-game menu; the interposer publishes it as BTN_MODE.
- Control("guide", "◉", PadState.GUIDE, -1, -1, dp(22f)),
- Control("qam", "⋯", -1, -1, -1, dp(22f)),
+ Control("up", "dpad", -1, 26f, 0f, -52f),
+ Control("right", "dpad", -1, 26f, 52f, 0f),
+ Control("down", "dpad", -1, 26f, 0f, 52f),
+ Control("left", "dpad", -1, 26f, -52f, 0f),
+ Control("ls", "ls", 0, 48f, 0f, 0f),
+ Control("rs", "rs", 1, 48f, 0f, 0f),
+ Control("a", "face", -1, 30f, 0f, 50f),
+ Control("b", "face", -1, 30f, 50f, 0f),
+ Control("x", "face", -1, 30f, -50f, 0f),
+ Control("y", "face", -1, 30f, 0f, -50f),
+ Control("lb", "lb", -1, 24f, 0f, 0f),
+ Control("rb", "rb", -1, 24f, 0f, 0f),
+ Control("lt", "lt", -1, 24f, 0f, 0f),
+ Control("rt", "rt", -1, 24f, 0f, 0f),
+ Control("select", "select", -1, 20f, 0f, 0f),
+ Control("start", "start", -1, 20f, 0f, 0f),
+ Control("guide", "guide", -1, 22f, 0f, 0f),
+ Control("qam", "qam", -1, 22f, 0f, 0f),
)
+ private val groups = controls.map { it.group }.distinct()
+
private var buttonsOnly = false
+ private var settings = ControllerPrefs.read(context)
+ private var safe = Rect()
+ private var selected: String? = null
+ private var grabX = 0f
+ private var grabY = 0f
+ private var ignoreSaved = false
+
+ private var idleFill = 0
+ private var heldFill = 0
+ private var idleStroke = 0
+ private var heldStroke = 0
+ private var idleText = 0
+ private var stickFill = 0
+ private var knobFill = 0
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
- strokeWidth = dp(1.5f)
- }
+ private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
+ private val arrow = Path()
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
- /** Where the session's picture is drawn on this view, or null when the whole view is picture. */
- private var picture: RectF? = null
+ init {
+ applySettings()
+ }
- /** The activity tells the controls where the picture lands; the layout follows it. */
- fun setPicture(rect: RectF?) {
- val same = (rect == null && picture == null) || (rect != null && picture != null && rect == picture)
- if (same) return
- picture = rect?.let { RectF(it) }
- if (width > 0 && height > 0) { layoutControls(width.toFloat(), height.toFloat()); invalidate() }
+ private fun dp(value: Float) = value * resources.displayMetrics.density
+
+ private fun scaled(value: Float) = dp(value) * settings.size / 100f
+
+ fun reload() {
+ releaseAll()
+ settings = ControllerPrefs.read(context)
+ applySettings()
+ relayout()
+ }
+
+ private fun applySettings() {
+ for (control in controls) control.target = settings.mapping[control.id] ?: control.id
+ val tint = settings.tint
+ val alpha = settings.opacity / 100f
+ fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
+ fun light(a: Int, f: Float) = Color.argb(
+ (a * alpha).toInt(),
+ (Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
+ (Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
+ (Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
+ )
+ idleFill = shade(80, 0.12f)
+ heldFill = shade(160, 1f)
+ idleStroke = light(150, 0.25f)
+ heldStroke = light(230, 0.7f)
+ idleText = light(210, 0.75f)
+ stickFill = shade(60, 0.12f)
+ knobFill = shade(120, 0.35f)
+ stroke.strokeWidth = dp(1.5f)
}
fun setButtonsOnly(enabled: Boolean) {
if (buttonsOnly == enabled) return
releaseAll()
buttonsOnly = enabled
- if (width > 0 && height > 0) layoutControls(width.toFloat(), height.toFloat())
- invalidate()
+ relayout()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
- layoutControls(w.toFloat(), h.toFloat())
+ relayout()
}
- /**
- * Three layouts: side bars (a wide panel showing 16:9), bottom band (a squarer panel showing
- * a wide picture), and the overlay when neither bar is wide enough for a cluster. Thumbs reach
- * 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) {
+ override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
+ val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
+ val fresh = Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
+ if (fresh != safe) { safe = fresh; relayout() }
+ return super.onApplyWindowInsets(insets)
+ }
+
+ override fun onDetachedFromWindow() {
+ releaseAll()
+ super.onDetachedFromWindow()
+ }
+
+ private fun relayout() {
+ if (width <= 0 || height <= 0) return
+ for (control in controls) control.radius = if (buttonsOnly && !editing && (control.id == "guide" || control.id == "qam")) dp(30f) else scaled(control.radiusDp)
+ if (buttonsOnly && !editing) {
val inset = dp(44f)
- place("guide", inset, h - inset)
- place("qam", w - inset, h - inset)
- return
+ put("guide", safe.left + inset, height - safe.bottom - inset)
+ put("qam", width - safe.right - inset, height - safe.bottom - inset)
+ } else {
+ placeAuto(width.toFloat(), height.toFloat())
+ if (!ignoreSaved) for ((group, pos) in ControllerPrefs.layout(context, width, height)) {
+ if (group in groups) put(group, pos.first * width, pos.second * height)
+ }
}
- 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
- val margin = dp(34f)
- when {
- // A cluster (d-pad or face buttons) is ~156 dp across; a stick above it needs the height.
- sideBar >= dp(160f) && p != null -> {
- val lx = p.left / 2f
- val rx = p.right + (w - p.right) / 2f
- val bottom = h - margin - dp(74f)
- dpadAt(lx, bottom)
- faceAt(rx, bottom)
- place("ls", lx, bottom - dp(196f))
- place("rs", rx, bottom - dp(196f))
- place("lb", lx, bottom - dp(196f) - dp(96f))
- place("rb", rx, bottom - dp(196f) - dp(96f))
- place("select", lx, h - margin + dp(8f))
- place("start", rx, h - margin + dp(8f))
- place("guide", w / 2f, h - dp(22f))
- }
- bottomBand >= dp(190f) && p != null -> {
- val cy = p.bottom + bottomBand / 2f + dp(12f)
- val dx = margin + dp(74f)
- val fx = w - margin - dp(74f)
- dpadAt(dx, cy)
- faceAt(fx, cy)
- place("ls", dx + dp(150f), cy)
- place("rs", fx - dp(150f), cy)
- place("lb", dx, p.bottom + dp(30f))
- place("rb", fx, p.bottom + dp(30f))
- val centre = w / 2f
- place("select", centre - dp(60f), h - dp(30f))
- place("guide", centre, h - dp(30f))
- place("start", centre + dp(60f), h - dp(30f))
- }
- else -> {
- val dx = margin + dp(74f)
- val fx = w - margin - dp(74f)
- val bottom = h - margin - dp(74f)
- dpadAt(dx, bottom)
- faceAt(fx, bottom)
- place("ls", dx + dp(142f), bottom)
- place("rs", fx - dp(142f), bottom)
- place("lb", margin + dp(46f), h - margin - dp(196f))
- place("rb", w - margin - dp(46f), h - margin - dp(196f))
- val centre = w / 2f
- place("select", centre - dp(60f), h - margin - dp(26f))
- place("guide", centre, h - margin - dp(26f))
- place("start", centre + dp(60f), h - margin - dp(26f))
- }
+ groups.forEach { clamp(it) }
+ invalidate()
+ }
+
+ private fun pxPerMm(dpi: Float): Float {
+ val nominal = resources.displayMetrics.densityDpi.toFloat()
+ return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
+ }
+
+ private fun placeAuto(w: Float, h: Float) {
+ val metrics = resources.displayMetrics
+ val mmX = pxPerMm(metrics.xdpi)
+ val mmY = pxPerMm(metrics.ydpi)
+ val usableW = w - safe.left - safe.right
+ val usableH = h - safe.top - safe.bottom
+ val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
+ val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
+ val gap = dp(12f)
+ val restY = h - safe.bottom - lift
+ put("ls", safe.left + inboard, restY)
+ put("face", w - safe.right - inboard, restY)
+ besideBelow("dpad", "ls", 1f, gap)
+ besideBelow("rs", "face", -1f, gap)
+ val shoulderY = min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - extent("lb")
+ shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
+ shoulders("face", "rb", "rt", -1f, gap, shoulderY)
+ val bottom = h - safe.bottom - dp(8f) - extent("guide")
+ val spread = extent("guide") + gap / 2f
+ put("guide", w / 2f - spread, bottom)
+ put("qam", w / 2f + spread, bottom)
+ val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
+ put("select", inner, restY)
+ put("start", w - inner, restY)
+ if (crowded("select", gap) || crowded("start", gap)) {
+ above("select", "dpad", gap)
+ val (sx, sy) = centre("select")
+ put("start", w - sx, sy)
+ if (crowded("start", gap)) above("start", "rs", gap)
}
}
- private fun dpadAt(x: Float, y: Float) {
- val step = dp(52f)
- place("up", x, y - step)
- place("down", x, y + step)
- place("left", x - step, y)
- place("right", x + step, y)
+ private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float) {
+ val (ax, ay) = centre(anchor)
+ val reach = extent(anchor) + extent(group) + gap
+ val floor = height - safe.bottom - dp(8f) - extent(group)
+ val dy = min(reach * sin(Math.toRadians(35.0)).toFloat(), floor - ay)
+ val dx = sqrt(max(0f, reach * reach - dy * dy))
+ put(group, ax + inward * dx, ay + dy)
}
- private fun faceAt(x: Float, y: Float) {
- val step = dp(50f)
- place("a", x, y + step)
- place("b", x + step, y)
- place("x", x - step, y)
- place("y", x, y - step)
+ private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
+ val (ax, _) = centre(anchor)
+ val spread = extent(bumper) + gap / 2f
+ put(bumper, ax + inward * spread, y)
+ put(trigger, ax - inward * spread, y)
}
- private fun place(id: String, x: Float, y: Float) {
- val control = controls.firstOrNull { it.id == id } ?: return
- control.cx = x
- control.cy = y
+ private fun above(group: String, anchor: String, gap: Float) {
+ val (ax, ay) = centre(anchor)
+ put(group, ax, ay - extent(anchor) - gap - extent(group))
+ }
+
+ private fun crowded(group: String, gap: Float): Boolean {
+ val (x, y) = centre(group)
+ return groups.any { other ->
+ if (other == group) return@any false
+ val (ox, oy) = centre(other)
+ val dx = x - ox
+ val dy = y - oy
+ sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + gap / 2f
+ }
+ }
+
+ private fun members(group: String) = controls.filter { it.group == group }
+
+ private fun centre(group: String): Pair {
+ val first = members(group).first()
+ return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
+ }
+
+ private fun extent(group: String): Float = members(group).maxOf { control ->
+ sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.radius
+ }
+
+ private fun put(group: String, x: Float, y: Float) {
+ for (control in members(group)) {
+ control.cx = x + scaled(control.offsetXDp)
+ control.cy = y + scaled(control.offsetYDp)
+ }
+ }
+
+ private fun clamp(group: String) {
+ val list = members(group)
+ val margin = dp(4f)
+ val minX = list.minOf { it.cx - it.radius } - safe.left - margin
+ val maxX = list.maxOf { it.cx + it.radius } - (width - safe.right - margin)
+ val minY = list.minOf { it.cy - it.radius } - safe.top - margin
+ val maxY = list.maxOf { it.cy + it.radius } - (height - safe.bottom - margin)
+ val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
+ val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
+ if (dx == 0f && dy == 0f) return
+ for (control in list) { control.cx += dx; control.cy += dy }
+ }
+
+ fun saveLayout() {
+ if (width <= 0 || height <= 0) return
+ if (ignoreSaved) { ControllerPrefs.resetLayout(context, width, height); return }
+ ControllerPrefs.setLayout(context, width, height, groups.associateWith { group ->
+ val (x, y) = centre(group)
+ x / width to y / height
+ })
+ }
+
+ fun resetLayout() {
+ ControllerPrefs.resetLayout(context, width, height)
+ ignoreSaved = true
+ selected = null
+ relayout()
+ }
+
+ private fun label(control: Control): String = when (control.target) {
+ "ls" -> "L"
+ "rs" -> "R"
+ "select" -> "⧉"
+ "start" -> "☰"
+ "guide" -> if (buttonsOnly && !editing) "Steam" else "◉"
+ "qam" -> "⋯"
+ else -> control.target.uppercase()
}
override fun onDraw(canvas: Canvas) {
for (control in controls) {
if (!isVisible(control)) continue
val held = control.pressedBy != -1
- val radius = controlRadius(control)
+ val radius = control.radius
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)
+ fill.color = if (held) heldFill else stickFill
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)
+ stroke.color = if (held) heldStroke else idleStroke
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)
+ fill.color = if (held || control.clicked) heldFill else knobFill
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)
+ stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(control.cx + control.kx, control.cy + control.ky, knob, stroke)
- text.color = if (held) Color.WHITE else Color.argb(150, 225, 210, 245)
+ text.color = if (held) Color.WHITE else idleText
text.textSize = knob * 0.8f
- canvas.drawText(control.label, control.cx + control.kx, control.cy + control.ky + text.textSize * 0.35f, text)
- continue
+ canvas.drawText(label(control), control.cx + control.kx, control.cy + control.ky + text.textSize * 0.35f, text)
+ } else {
+ fill.color = if (held) heldFill else idleFill
+ canvas.drawCircle(control.cx, control.cy, radius, fill)
+ stroke.color = if (held) heldStroke else idleStroke
+ canvas.drawCircle(control.cx, control.cy, radius, stroke)
+ text.color = if (held) Color.WHITE else idleText
+ val direction = directions.indexOf(control.target)
+ if (direction >= 0) {
+ drawArrow(canvas, control.cx, control.cy, radius * 0.34f, direction)
+ continue
+ }
+ val name = label(control)
+ text.textSize = if (name.length > 2) dp(11f) else radius * if (name.length == 2) 0.62f else 0.85f
+ canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
}
- fill.color = if (held) Color.argb(150, 199, 125, 255) else Color.argb(70, 20, 12, 30)
- 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, radius, stroke)
- text.color = if (held) Color.WHITE else Color.argb(190, 225, 210, 245)
- 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)
}
+ val group = selected ?: return
+ val (x, y) = centre(group)
+ stroke.color = heldStroke
+ canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
+ }
+
+ private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
+ val (fx, fy) = when (direction) {
+ 0 -> 0f to -1f
+ 1 -> 1f to 0f
+ 2 -> 0f to 1f
+ else -> -1f to 0f
+ }
+ arrow.reset()
+ arrow.moveTo(x + fx * size, y + fy * size)
+ arrow.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
+ arrow.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
+ arrow.close()
+ fill.color = text.color
+ canvas.drawPath(arrow, fill)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (editing) return onEditTouch(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
control.pressedBy = event.getPointerId(index)
- if (control.stick >= 0) control.drag(event.getX(index), event.getY(index))
+ if (control.stick >= 0) {
+ control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
+ control.drag(event.getX(index), event.getY(index))
+ }
+ requestUnbufferedDispatch(event)
apply()
return true
}
@@ -259,11 +382,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
val pointer = event.getPointerId(index)
val x = event.getX(index)
val y = event.getY(index)
- // A stick keeps its finger wherever it goes: the knob follows, clamped.
val stick = controls.firstOrNull { it.stick >= 0 && it.pressedBy == pointer }
if (stick != null) { stick.drag(x, y); changed = true; continue }
- // A finger that slides off a button releases it, and one that slides onto
- // another presses that - which is how a d-pad is used in practice.
val over = controlAt(x, y)?.takeIf { it.stick < 0 }
for (control in controls) {
if (control.stick < 0 && control.pressedBy == pointer && control !== over) {
@@ -285,7 +405,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
+ if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
control.pressedBy = -1
+ control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
control.kx = 0f; control.ky = 0f
}
@@ -297,45 +419,106 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
return false
}
+ private fun onEditTouch(event: MotionEvent): Boolean {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ val control = controlAt(event.x, event.y)
+ selected = control?.group
+ val group = selected ?: run { invalidate(); return false }
+ val (x, y) = centre(group)
+ grabX = x - event.x
+ grabY = y - event.y
+ invalidate()
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val group = selected ?: return true
+ ignoreSaved = false
+ put(group, event.x + grabX, event.y + grabY)
+ clamp(group)
+ invalidate()
+ }
+ }
+ return true
+ }
+
private fun controlAt(x: Float, y: Float): Control? =
- controls.firstOrNull { isVisible(it) && it.contains(x, y, controlRadius(it)) }
+ controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
- 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 isVisible(control: Control): Boolean = when {
+ buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
+ else -> control.target != ControllerPrefs.OFF
+ }
private fun apply() {
+ val bridge = pad
+ if (bridge == null || editing) { invalidate(); return }
+ val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
if (buttonsOnly) {
- val guidePressed = controls.first { it.id == "guide" }.pressedBy != -1
- val qamPressed = controls.first { it.id == "qam" }.pressedBy != -1
- pad.setSystemButtons(guidePressed, qamPressed)
+ bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
} else {
- pad.setSystemButtons(false, false)
- pad.applyTouch { state ->
+ bridge.setSystemButtons(false, qamHeld)
+ bridge.applyTouch { state ->
+ val used = HashSet()
+ val held = HashSet()
for (control in controls) {
- val held = control.pressedBy != -1
when {
- control.stick >= 0 && !control.dirty -> {}
- control.stick == 0 -> { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius; control.dirty = false }
- control.stick == 1 -> { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius; control.dirty = false }
- control.dpad == 0 -> state.up = held
- control.dpad == 1 -> state.right = held
- control.dpad == 2 -> state.down = held
- control.dpad == 3 -> state.left = held
- control.button >= 0 -> state.press(control.button, held)
+ control.stick >= 0 -> {
+ val click = if (control.stick == 0) "l3" else "r3"
+ if (settings.stickClick) used.add(click)
+ if (control.clicked) held.add(click)
+ if (!control.dirty) continue
+ if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
+ else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
+ control.dirty = false
+ }
+ control.id == "qam" || control.target == ControllerPrefs.OFF -> {}
+ else -> {
+ used.add(control.target)
+ if (control.pressedBy != -1) held.add(control.target)
+ }
}
}
+ for (target in used) write(state, target, target in held)
}
}
invalidate()
}
- /** Everything up, for when the controls are hidden mid-press. */
+ private fun write(state: PadState, target: String, down: Boolean) {
+ when (target) {
+ "a" -> state.press(PadState.A, down)
+ "b" -> state.press(PadState.B, down)
+ "x" -> state.press(PadState.X, down)
+ "y" -> state.press(PadState.Y, down)
+ "lb" -> state.press(PadState.LB, down)
+ "rb" -> state.press(PadState.RB, down)
+ "select" -> state.press(PadState.SELECT, down)
+ "start" -> state.press(PadState.START, down)
+ "l3" -> state.press(PadState.L3, down)
+ "r3" -> state.press(PadState.R3, down)
+ "guide" -> state.press(PadState.GUIDE, down)
+ "lt" -> state.leftTrigger = if (down) 1f else 0f
+ "rt" -> state.rightTrigger = if (down) 1f else 0f
+ "up" -> state.up = down
+ "right" -> state.right = down
+ "down" -> state.down = down
+ "left" -> state.left = down
+ }
+ }
+
fun releaseAll() {
- 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 }
+ if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
+ controls.forEach {
+ it.pressedBy = -1
+ it.clicked = false
+ if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
+ it.kx = 0f; it.ky = 0f
+ }
apply()
}
+
+ private companion object {
+ const val DOUBLE_TAP_MS = 300L
+ val directions = listOf("up", "right", "down", "left")
+ }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt
new file mode 100644
index 000000000..4892e28e1
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt
@@ -0,0 +1,89 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.droiddeck.launcher.input.ControllerPrefs
+import com.droiddeck.launcher.session.SessionPrefs
+
+class ControllerActions(
+ val onOsc: (String) -> Unit,
+ val onTint: (Int) -> Unit,
+ val onOpacity: (Int) -> Unit,
+ val onSize: (Int) -> Unit,
+ val onStickClick: (Boolean) -> Unit,
+ val onEditLayout: () -> Unit,
+ val onResetLayout: () -> Unit,
+ val onMapping: () -> Unit,
+ val onResetAll: () -> Unit,
+)
+
+@Composable
+private fun Swatch(color: Int) {
+ Box(Modifier.size(14.dp).background(Color(color), CircleShape).border(1.dp, Color.White.copy(alpha = 0.35f), CircleShape))
+}
+
+@Composable
+fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
+ ChoiceRow(
+ host, "controller-osc", "On-screen controls", "When the touch pad appears in a session",
+ listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"),
+ oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
+ )
+ val tintOpen = host.open == "controller-tint"
+ SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) {
+ Box {
+ ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
+ host.open = if (tintOpen) null else "controller-tint"
+ }
+ AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus ->
+ ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
+ MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
+ a.onTint(color)
+ host.open = null
+ }
+ }
+ }
+ }
+ }
+ ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
+ ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
+ ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
+ SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ SecondaryButton("Edit") { a.onEditLayout() }
+ SecondaryButton("Reset", enabled = c.customLayout) { a.onResetLayout() }
+ }
+ }
+ val remapped = c.mapping.count { (id, target) -> id != target }
+ ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping)
+ ActionRow("Reset controller", "Restore the default colour, opacity, size, stick click, mapping and layout", "Reset", a.onResetAll)
+}
+
+@Composable
+fun ControllerMappingPage(mapping: Map, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
+ val host = rememberMenuHost()
+ SettingsPage(
+ host, title = "Button mapping", eyebrow = "Controller",
+ lede = "Choose what each on-screen button sends to the game. Hidden removes the button.",
+ onBack = onBack,
+ ) {
+ SettingsGroup("On-screen buttons") {
+ for ((id, name) in ControllerPrefs.mappable) {
+ ChoiceRow(host, "map-$id", name, null, ControllerPrefs.targets, mapping[id] ?: id) { onPick(id, it) }
+ }
+ }
+ SettingsGroup("Defaults") {
+ ActionRow("Reset mapping", "Every button sends its own input again", "Reset", onReset)
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index 5c73281b7..23106e263 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -169,6 +169,8 @@ class FrontEndState(
val sessionRunning: Boolean = false,
val backActionsInverted: Boolean = false,
val buildLabel: String = "local",
+ val oscMode: String = SessionPrefs.OSC_AUTO,
+ val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
)
class FrontEndActions(
@@ -200,6 +202,7 @@ class FrontEndActions(
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
val onBackActionsInverted: (Boolean) -> Unit = {},
val onCheckLatestBuild: () -> Unit = {},
+ val controller: ControllerActions? = null,
)
@@ -356,7 +359,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.background).systemBarsPadding().onFocusChanged { anyFocused = it.hasFocus }) {
val wide = maxWidth >= 640.dp
val railSelection = when {
- s.pageKey == "performance" || s.pageKey == "protons" -> "setup"
+ s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
selected.startsWith("app:") -> "steam"
@@ -930,6 +933,10 @@ private fun SetupPanel(s: FrontEndState, a: FrontEndActions) {
ActionRow("Performance", "CPU core assignment", "Configure", a.onPerformance)
ActionRow("ROMs folder", s.romsDir ?: "Choose where emulator games are stored", "Choose", a.onRoms)
}
+ val controller = s.controller
+ if (controller != null && a.controller != null) SettingsGroup("Controller") {
+ ControllerRows(host, s.oscMode, controller, a.controller)
+ }
SettingsGroup("Session") {
ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),