feat(android): connected-controllers debug view (Settings → Host)
The client end of the "host doesn't see my gamepad" triage chain: a new ControllersScreen lists every InputDevice Android classifies as a gamepad/joystick (name, VID:PID, source classes, the punktfunk pad type it resolves to, rumble test) plus an "Other input devices" section — a pad behind a BT→USB adapter (the Pico 2W tester case) often enumerates with the adapter's identity or not as a gamepad at all, and this makes that visible on the device instead of over a bug report. A live input test (button chips + axis bars + raw last-keycode line) consumes pad events via new MainActivity probe hooks ahead of the focus-nav remap; hold B 1.2s to exit since the pad can't reach the toggle while captured. Gamepad grows pads()/isPad() (firstPad generalized). Kotlin compiles green (kit + app); on-device validation pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.CombinedVibration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.VibrationEffect
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
// Device list, re-read on every hot-plug event.
|
||||
var generation by remember { mutableIntStateOf(0) }
|
||||
val pads = remember(generation) { Gamepad.pads() }
|
||||
val others = remember(generation) {
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
val listener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) { generation++ }
|
||||
override fun onInputDeviceRemoved(deviceId: Int) { generation++ }
|
||||
override fun onInputDeviceChanged(deviceId: Int) { generation++ }
|
||||
}
|
||||
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
|
||||
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
|
||||
// here instead of driving focus navigation); holding B releases, since the pad can no longer
|
||||
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
|
||||
// "last input" line works while browsing.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val held = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
val axes = remember { mutableStateMapOf<String, Float>() }
|
||||
var lastInput by remember { mutableStateOf<String?>(null) }
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
activity?.padKeyProbe = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
testing
|
||||
}
|
||||
activity?.padMotionProbe = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
testing
|
||||
}
|
||||
onDispose {
|
||||
activity?.padKeyProbe = null
|
||||
activity?.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). A short tap cancels the effect before the delay fires.
|
||||
LaunchedEffect(bHeld) {
|
||||
if (bHeld && testing) {
|
||||
delay(1_200)
|
||||
testing = false
|
||||
held.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
Group("Gamepads") {
|
||||
if (pads.isEmpty()) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
"or hub may show up under \"Other input devices\" below with the adapter's " +
|
||||
"identity, or not at all.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
pads.forEachIndexed { i, dev ->
|
||||
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
Group("Input test") {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
if (testing) "Controller input stays on this screen — hold B to finish"
|
||||
else "Show button presses and stick motion live",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
|
||||
}
|
||||
if (testing) {
|
||||
ButtonGrid(held)
|
||||
AXIS_LABELS.forEach { label -> AxisBar(label, axes[label] ?: 0f) }
|
||||
}
|
||||
lastInput?.let {
|
||||
Text(
|
||||
"Last input — $it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Group("Other input devices") {
|
||||
if (others.isEmpty()) {
|
||||
Text(
|
||||
"None",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
others.forEach { dev ->
|
||||
Column {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
deviceDetail(dev),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
Text(
|
||||
"forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
deviceDetail(dev),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val resolved = Gamepad.prefFor(dev)
|
||||
Text(
|
||||
if (gamepadSetting == Gamepad.PREF_AUTO) {
|
||||
"Streams as: ${prefLabel(resolved)} (automatic)"
|
||||
} else {
|
||||
"Streams as: ${prefLabel(gamepadSetting)} (set in Settings; " +
|
||||
"automatic would pick ${prefLabel(resolved)})"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
} else {
|
||||
Text(
|
||||
"No rumble motors reported — host rumble will be silent",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The forwarded buttons as chips that light up while held. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ButtonGrid(held: Map<Int, Boolean>) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
TEST_BUTTONS.forEach { (label, keyCode) ->
|
||||
val active = held[keyCode] == true
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (active) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (active) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(6.dp),
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled live axis bar; sticks/HAT are −1..1 (centre = half), triggers 0..1. */
|
||||
@Composable
|
||||
private fun AxisBar(label: String, value: Float) {
|
||||
val progress = if (label == "LT" || label == "RT") value else (value + 1f) / 2f
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(32.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"%+.2f".format(value),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled section — same look as the Settings groups. */
|
||||
@Composable
|
||||
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
runCatching {
|
||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
private fun sourcesLabel(sources: Int): String {
|
||||
fun has(flag: Int) = sources and flag == flag
|
||||
val names = buildList {
|
||||
if (has(InputDevice.SOURCE_GAMEPAD)) add("gamepad")
|
||||
if (has(InputDevice.SOURCE_JOYSTICK)) add("joystick")
|
||||
if (has(InputDevice.SOURCE_DPAD)) add("dpad")
|
||||
if (has(InputDevice.SOURCE_KEYBOARD)) add("keyboard")
|
||||
if (has(InputDevice.SOURCE_MOUSE)) add("mouse")
|
||||
if (has(InputDevice.SOURCE_TOUCHSCREEN)) add("touchscreen")
|
||||
if (has(InputDevice.SOURCE_TOUCHPAD)) add("touchpad")
|
||||
if (has(InputDevice.SOURCE_STYLUS)) add("stylus")
|
||||
if (has(InputDevice.SOURCE_ROTARY_ENCODER)) add("rotary")
|
||||
}
|
||||
return if (names.isEmpty()) "sources 0x%08X".format(sources) else names.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** [Gamepad] PREF_* wire byte → user-facing label (mirrors GAMEPAD_OPTIONS, plus the Steam types). */
|
||||
private fun prefLabel(pref: Int): String = when (pref) {
|
||||
Gamepad.PREF_XBOX360 -> "Xbox 360"
|
||||
Gamepad.PREF_DUALSENSE -> "DualSense"
|
||||
Gamepad.PREF_XBOXONE -> "Xbox One"
|
||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||
else -> "Automatic"
|
||||
}
|
||||
|
||||
/** Buttons shown in the test grid (label → Android keycode). */
|
||||
private val TEST_BUTTONS = listOf(
|
||||
"A" to KeyEvent.KEYCODE_BUTTON_A,
|
||||
"B" to KeyEvent.KEYCODE_BUTTON_B,
|
||||
"X" to KeyEvent.KEYCODE_BUTTON_X,
|
||||
"Y" to KeyEvent.KEYCODE_BUTTON_Y,
|
||||
"LB" to KeyEvent.KEYCODE_BUTTON_L1,
|
||||
"RB" to KeyEvent.KEYCODE_BUTTON_R1,
|
||||
"L2" to KeyEvent.KEYCODE_BUTTON_L2,
|
||||
"R2" to KeyEvent.KEYCODE_BUTTON_R2,
|
||||
"LS" to KeyEvent.KEYCODE_BUTTON_THUMBL,
|
||||
"RS" to KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
"→" to KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
)
|
||||
|
||||
/** Axis bars shown in the test view, in display order. */
|
||||
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
|
||||
@@ -26,6 +26,14 @@ class MainActivity : ComponentActivity() {
|
||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
||||
var axisMapper: Gamepad.AxisMapper? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
* Called for every key/motion event while not streaming; a `true` return consumes the event —
|
||||
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
|
||||
*/
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||
@@ -81,10 +89,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
} else {
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already move
|
||||
// focus on their own, so they fall through to super untouched.
|
||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already
|
||||
// move focus on their own, so they fall through to super untouched.
|
||||
val mapped = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
||||
@@ -92,6 +103,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
||||
}
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
@@ -103,6 +115,8 @@ class MainActivity : ComponentActivity() {
|
||||
if (axisMapper?.onMotion(event) == true) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||
padMotionProbe?.let { if (it(event)) return true }
|
||||
// Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a
|
||||
// controller navigates the menus even when its D-pad reports as axes (not key events) and
|
||||
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
|
||||
|
||||
@@ -46,6 +46,7 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
var showControllers by remember { mutableStateOf(false) }
|
||||
fun update(next: Settings) {
|
||||
s = next
|
||||
onChange(next)
|
||||
@@ -62,6 +63,10 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
LicensesScreen(onBack = { showLicenses = false })
|
||||
return
|
||||
}
|
||||
if (showControllers) {
|
||||
ControllersScreen(gamepadSetting = s.gamepad, onBack = { showControllers = false })
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -130,6 +135,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = { showControllers = true },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
|
||||
@@ -98,20 +98,20 @@ object Gamepad {
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||
fun isPad(dev: InputDevice?): Boolean {
|
||||
val s = dev?.sources ?: return false
|
||||
return s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
|
||||
Reference in New Issue
Block a user