mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 11:01:07 +00:00
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.
This commit is contained in:
1 parent
4a46e16702
commit
3fac2e8945
9 files changed
+728
-197
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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<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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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),
|
||||
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<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,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<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.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<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
|
||||
val directions = listOf("up", "right", "down", "left")
|
||||
}
|
||||
}
|
||||
@@ -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<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),
|
||||
|
||||
Reference in new issue
Block a user