From 3fac2e8945ae9dec01e936fdeb3ca560e6a7a46a Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Thu, 24 Sep 2026 22:13:48 -0400 Subject: [PATCH] Add a Controller section to Setup with an on-screen pad editor The on-screen pad is now placed from the panel's physical size: the sticks and face buttons sit at the two-thumb rest point (about 17% of the width inboard, 28% of the height up, clamped in millimetres), with the d-pad and right stick below-inboard, shoulders above, and nothing ever placed off screen or under a display cutout. Button sizes are unchanged. Setup > Controller holds the on-screen controls mode, a tint (Steam blue #1A9FFF by default), opacity, button size, stick click (double-tap for L3/R3), a full-screen layout editor, per-button mapping including LT/RT, and resets for each. Also from WinNative #772: the fakeinput ring poll backoff is capped at 2 ms instead of 16 ms, the session asks for unbuffered joystick input, and the on-screen controls request unbuffered dispatch per gesture and release everything when detached. The QAM button no longer draws at the top-left corner in full-pad mode. --- app/src/main/AndroidManifest.xml | 8 + app/src/main/cpp/fakeinput_steam.cpp | 8 +- .../com/droiddeck/launcher/MainActivity.kt | 45 +- .../com/droiddeck/launcher/SessionActivity.kt | 13 +- .../input/ControllerEditorActivity.kt | 81 +++ .../launcher/input/ControllerPrefs.kt | 115 ++++ .../launcher/input/OnScreenControls.kt | 557 ++++++++++++------ .../launcher/ui/ControllerSettings.kt | 89 +++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 9 +- 9 files changed, 728 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt 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),