Merge pull request #9 from maxjivi05/feature/controller-editor

Feature/controller editor
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-09-24 23:08:23 -04:00
commit 00057932cd
9 files changed
+828 -201

No files matched your search

+8
View File
@@ -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.
-->
<activity
android:name=".input.ControllerEditorActivity"
android:exported="false"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".files.FileManagerActivity"
android:exported="false"
+5 -3
View File
@@ -1231,6 +1231,8 @@ EXPORT int close(int fd) {
return my_close(fd);
}
static constexpr int kMaxRingWaitMs = 2;
EXPORT ssize_t read(int fd, void *buf, size_t count) {
std::unique_lock<std::recursive_mutex> 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;
}
}
@@ -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
@@ -84,6 +88,8 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode 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<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
private var catalogLoading by mutableStateOf(false)
private var packageRows by mutableStateOf<List<PackageRow>?>(null)
@@ -233,6 +239,7 @@ class MainActivity : ComponentActivity() {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
FrontEndScreen(
@@ -245,7 +252,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,
@@ -260,6 +267,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)) },
@@ -295,7 +304,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 = {
@@ -316,7 +325,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 ->
@@ -331,6 +340,18 @@ 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() },
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(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,
)
@@ -384,6 +405,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
@@ -452,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(
@@ -682,6 +721,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)
@@ -516,7 +516,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.
@@ -592,8 +591,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() }
@@ -1222,6 +1219,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
updateOnScreenControls()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
@@ -1236,6 +1234,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()
@@ -1283,6 +1289,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
@@ -0,0 +1,97 @@
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.clickable
import androidx.compose.foundation.layout.Arrangement
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.LocalPalette
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 {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
) {
Text("Drag to move", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton("Reset", false) { controls.resetLayout() }
EditorButton("Cancel", false) { finish() }
EditorButton("Save", true) {
controls.saveLayout()
finish()
}
}
}
}
}
root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
setContentView(root)
}
@Composable
private fun EditorButton(label: String, primary: Boolean, onClick: () -> Unit) {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (primary) Color.White else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.clip(RoundedCornerShape(50))
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
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
}
}
}
}
@@ -0,0 +1,121 @@
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 adaptiveSticks: Boolean,
val customLayout: Boolean,
val mapping: Map<String, String>,
)
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),
adaptiveSticks = p.getBoolean("adaptiveSticks", 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 setAdaptiveSticks(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("adaptiveSticks", on).apply()
}
fun layout(context: Context, width: Int, height: Int): Map<String, Pair<Float, Float>> {
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<String, Pair<Float, Float>>) {
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()
}
}
@@ -5,251 +5,437 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import kotlin.math.abs
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 ax = 0f
var ay = 0f
var dirty = false
fun contains(x: Float, y: Float, radius: Float = this.radius): Boolean {
var clicked = false
var lastUp = 0L
val wide = id in shoulderIds
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
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
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
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
var dx = x - ax
var dy = y - ay
val d = sqrt(dx * dx + dy * dy)
if (d > radius) { dx = dx / d * radius; dy = dy / d * radius }
kx = dx; ky = dy; dirty = true
}
}
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 fit = 1f
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 box = RectF()
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 * fit
private fun fitScale(): Float {
val metrics = resources.displayMetrics
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
}
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
fit = if (buttonsOnly && !editing) 1f else fitScale()
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 floor = BOTTOM_MARGIN_MM * mmY
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
put("ls", safe.left + inboard, restY)
put("face", w - safe.right - inboard, restY)
besideBelow("dpad", "ls", 1f, gap, floor)
besideBelow("rs", "face", -1f, gap, floor)
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
val shoulderY = if (phone) safe.top + floor + halfH("lb")
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
val bottom = h - safe.bottom - floor - 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 (phone) {
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
put("select", top, shoulderY)
put("start", w - top, shoulderY)
}
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)
}
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val row = centre("select").second
put("guide", w / 2f - spread, row)
put("qam", w / 2f + spread, row)
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val top = safe.top + dp(8f) + extent("guide")
put("guide", w / 2f - spread, top)
put("qam", w / 2f + spread, top)
}
}
}
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 drop(group: String, anchor: String, gap: Float): Float =
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
val (ax, ay) = centre(anchor)
val reach = extent(anchor) + extent(group) + gap
val floor = height - safe.bottom - margin - extent(group)
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).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, space: Float = gap / 2f, except: String? = null): Boolean {
val (x, y) = centre(group)
return groups.any { other ->
if (other == group || other == except) 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) + space
}
}
private fun members(group: String) = controls.filter { it.group == group }
private fun centre(group: String): Pair<Float, Float> {
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.halfW
}
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
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.halfW } - safe.left - margin
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
val maxY = list.maxOf { it.cy + it.halfH } - (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)
canvas.drawCircle(control.cx, control.cy, radius, fill)
stroke.color = if (held) Color.argb(200, 235, 212, 255) else Color.argb(90, 201, 160, 255)
canvas.drawCircle(control.cx, control.cy, radius, stroke)
val bx = if (held) control.ax else control.cx
val by = if (held) control.ay else control.cy
fill.color = if (held) heldFill else stickFill
canvas.drawCircle(bx, by, radius, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx, by, radius, stroke)
val knob = radius * 0.46f
fill.color = if (held) Color.argb(170, 199, 125, 255) else Color.argb(110, 60, 40, 90)
canvas.drawCircle(control.cx + control.kx, control.cy + control.ky, knob, fill)
stroke.color = if (held) Color.argb(230, 245, 230, 255) else Color.argb(140, 201, 160, 255)
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)
fill.color = if (held || control.clicked) heldFill else knobFill
canvas.drawCircle(bx + control.kx, by + control.ky, knob, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx + control.kx, by + control.ky, knob, stroke)
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), bx + control.kx, by + control.ky + text.textSize * 0.35f, text)
} else if (control.wide) {
box.set(control.cx - control.halfW, control.cy - control.halfH, control.cx + control.halfW, control.cy + control.halfH)
val corner = control.halfH * 0.55f
fill.color = if (held) heldFill else idleFill
canvas.drawRoundRect(box, corner, corner, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawRoundRect(box, corner, corner, stroke)
text.color = if (held) Color.WHITE else idleText
text.textSize = control.halfH * 0.8f
canvas.drawText(label(control), control.cx, control.cy + 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
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else 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
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
control.drag(event.getX(index), event.getY(index))
}
requestUnbufferedDispatch(event)
apply()
return true
}
@@ -259,11 +445,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 +468,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 +482,114 @@ 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<String>()
val held = HashSet<String>()
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
const val MIN_FACE_MM = 9.5f
const val FULL_SIZE_HEIGHT_MM = 85f
const val BOTTOM_MARGIN_MM = 5f
const val DROP_DEGREES = 35.0
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
val directions = listOf("up", "right", "down", "left")
}
}
@@ -0,0 +1,91 @@
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 onAdaptiveSticks: (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)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
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 behaviour, mapping and layout", "Reset", a.onResetAll)
}
@Composable
fun ControllerMappingPage(mapping: Map<String, String>, 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)
}
}
}
@@ -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),