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). */
|
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
||||||
var axisMapper: Gamepad.AxisMapper? = null
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
// 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
|
// 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
|
// buttons to the navigation keys the focus system understands; D-pad *keys* already
|
||||||
// focus on their own, so they fall through to super untouched.
|
// move focus on their own, so they fall through to super untouched.
|
||||||
val mapped = when (event.keyCode) {
|
val mapped = when (event.keyCode) {
|
||||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
||||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
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))
|
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return super.dispatchKeyEvent(event)
|
return super.dispatchKeyEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +115,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (axisMapper?.onMotion(event) == true) return true
|
if (axisMapper?.onMotion(event) == true) return true
|
||||||
return super.dispatchGenericMotionEvent(event)
|
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
|
// 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
|
// 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.
|
// 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) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showLicenses by remember { mutableStateOf(false) }
|
var showLicenses by remember { mutableStateOf(false) }
|
||||||
|
var showControllers by remember { mutableStateOf(false) }
|
||||||
fun update(next: Settings) {
|
fun update(next: Settings) {
|
||||||
s = next
|
s = next
|
||||||
onChange(next)
|
onChange(next)
|
||||||
@@ -62,6 +63,10 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
LicensesScreen(onBack = { showLicenses = false })
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (showControllers) {
|
||||||
|
ControllersScreen(gamepadSetting = s.gamepad, onBack = { showControllers = false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -130,6 +135,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
selected = s.gamepad,
|
selected = s.gamepad,
|
||||||
) { g -> update(s.copy(gamepad = g)) }
|
) { 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") {
|
SettingsGroup("Audio") {
|
||||||
|
|||||||
@@ -98,20 +98,20 @@ object Gamepad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||||
fun firstPad(): InputDevice? {
|
fun isPad(dev: InputDevice?): Boolean {
|
||||||
for (id in InputDevice.getDeviceIds()) {
|
val s = dev?.sources ?: return false
|
||||||
val d = InputDevice.getDevice(id) ?: continue
|
return s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||||
val s = d.sources
|
|
||||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
|
||||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
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
|
* 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
|
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||||
|
|||||||
Reference in New Issue
Block a user