fix(gamepad/android): batched HAT, rumble-duration floor, bind eviction, held exit chord (G4/G9/G18/G24)
Four Android gamepad fixes bringing the client to parity with SDL/Apple: G4 — HAT batched history. Android batches joystick ACTION_MOVEs, so a rapid d-pad tap (press+release within one batch) lived only in the event's historical samples; onMotion read just the final getAxisValue and missed it. Feed every historical HAT sample through the transition logic (new `applyHat`) before the current one. Sticks/triggers stay latest-wins. G9 — floor the rumble one-shot duration. A v2 lease can carry ttl_ms==0 with a nonzero amplitude (past the (0,0) stop guard); createOneShot throws on a non-positive duration, and on the VibratorManager path the effect is built outside the vibrate() runCatching, so the throw would kill the whole rumble poll thread. `durationMs.coerceAtLeast(1)`. G18 — evict feedback binds on disconnect. Rumble/light bindings were cached by device id and freed only at session stop, so a controller unplugged mid-session leaked its open LightsSession. Add GamepadFeedback.onDeviceRemoved(deviceId) (closes the session, cancels rumble), invoked from GamepadRouter's slot-close via a new onSlotClosed callback wired in StreamScreen. The bind maps are now guarded by a lock (the poll threads write them; eviction runs on the main thread). G24 — held exit chord + releases. The emergency-exit chord (Select+Start+ L1+R1) quit the stream the instant it completed — an accidental brush killed the session, and the four held buttons were never released host-side. Now completing the chord ARMS a 1.5 s hold timer (matching DISCONNECT_HOLD on SDL/Apple); onExitChord fires only if still held at expiry, after releasing the held buttons + zeroing the axes on the triggering pad(s). onButton no longer returns the exit bool (async now); MainActivity + StreamScreen updated. G25 (Android half): no change — Android's stick/trigger `.toInt()` already truncates, the chosen cross-client convention. G23 (rich-input plane) stays deferred to its own doc. Verified on this Mac: :kit + :app compileDebugKotlin clean; kit lint unchanged at its pre-existing baseline. On-glass on a real phone + pad still owed (per the Android-regressions-only-show-on-hardware history): watch batched d-pad taps, the 1.5 s exit hold, and a mid-session unplug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
val bit = Gamepad.buttonBit(event.keyCode)
|
val bit = Gamepad.buttonBit(event.keyCode)
|
||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
// The router forwards the bit on this device's own wire pad index, tracks held
|
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||||
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
|
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||||
// R1) completed on any one pad (a couch user has no keyboard/Back).
|
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||||
if (gamepadRouter?.onButton(event, bit) == true) {
|
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
// accidental brush of the four buttons no longer quits instantly.
|
||||||
}
|
gamepadRouter?.onButton(event, bit)
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||||
activity?.gamepadRouter = router
|
activity?.gamepadRouter = router
|
||||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||||
|
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||||
|
// the same way the Back gesture does.
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
// index via the router; poll threads stopped + joined before the router is released and the
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
// session closed.
|
// session closed.
|
||||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||||
|
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||||
|
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||||
|
router.onSlotClosed = feedback::onDeviceRemoved
|
||||||
onDispose {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
|
|||||||
@@ -219,14 +219,31 @@ object Gamepad {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||||
|
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
|
||||||
|
// Feed every historical HAT sample (oldest→newest) through the same transition logic
|
||||||
|
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
|
||||||
|
// only the final value matters for an analog axis.)
|
||||||
|
for (h in 0 until event.historySize) {
|
||||||
|
applyHat(
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applyHat(
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */
|
||||||
|
private fun applyHat(hx: Int, hy: Int) {
|
||||||
if (hx != hatX) {
|
if (hx != hatX) {
|
||||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||||
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
||||||
hatX = hx
|
hatX = hx
|
||||||
}
|
}
|
||||||
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
|
|
||||||
if (hy != hatY) {
|
if (hy != hatY) {
|
||||||
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
||||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||||
|
|||||||
@@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
private var rumbleThread: Thread? = null
|
private var rumbleThread: Thread? = null
|
||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
|
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
|
||||||
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
|
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
|
||||||
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
|
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
|
||||||
// race-free. A null value caches "this controller has no vibrator / no controllable lights".
|
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
|
||||||
|
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
|
||||||
|
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
|
||||||
|
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
|
||||||
|
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
|
||||||
|
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
|
||||||
|
private val bindsLock = Any()
|
||||||
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||||
private val lightBinds = HashMap<Int, LightBind?>()
|
private val lightBinds = HashMap<Int, LightBind?>()
|
||||||
|
|
||||||
@@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
// Threads are dead — drop any held rumble and close every lights session.
|
// Threads are dead — drop any held rumble and close every lights session.
|
||||||
for (b in rumbleBinds.values) b?.let {
|
synchronized(bindsLock) {
|
||||||
runCatching { it.vm?.cancel() }
|
for (b in rumbleBinds.values) b?.let {
|
||||||
runCatching { it.legacy?.cancel() }
|
runCatching { it.vm?.cancel() }
|
||||||
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||||
|
rumbleBinds.clear()
|
||||||
|
lightBinds.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||||
|
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||||
|
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||||
|
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||||
|
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||||
|
*/
|
||||||
|
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||||
|
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||||
|
@Suppress("MissingPermission", "NewApi")
|
||||||
|
fun onDeviceRemoved(deviceId: Int) {
|
||||||
|
synchronized(bindsLock) {
|
||||||
|
rumbleBinds.remove(deviceId)?.let {
|
||||||
|
runCatching { it.vm?.cancel() }
|
||||||
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||||
}
|
}
|
||||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
|
||||||
rumbleBinds.clear()
|
|
||||||
lightBinds.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Rumble ----
|
// ---- Rumble ----
|
||||||
@@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||||
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||||
val dev = router?.deviceForPad(pad) ?: return null
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
synchronized(bindsLock) {
|
||||||
val bind = bindRumble(dev)
|
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||||
rumbleBinds[dev.id] = bind
|
val bind = bindRumble(dev)
|
||||||
return bind
|
rumbleBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||||
@@ -217,9 +247,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||||
// self-terminates on a lost stop; cancel on zero.
|
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||||
|
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||||
|
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||||
|
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||||
|
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||||
VibrationEffect.createOneShot(durationMs, amp)
|
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||||
|
|
||||||
// ---- HID output ----
|
// ---- HID output ----
|
||||||
|
|
||||||
@@ -268,10 +302,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
private fun lightBindFor(pad: Int): LightBind? {
|
private fun lightBindFor(pad: Int): LightBind? {
|
||||||
if (Build.VERSION.SDK_INT < 33) return null
|
if (Build.VERSION.SDK_INT < 33) return null
|
||||||
val dev = router?.deviceForPad(pad) ?: return null
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
synchronized(bindsLock) {
|
||||||
val bind = bindLights(dev)
|
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||||
lightBinds[dev.id] = bind
|
val bind = bindLights(dev)
|
||||||
return bind
|
lightBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindLights(dev: InputDevice): LightBind? {
|
private fun bindLights(dev: InputDevice): LightBind? {
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||||
|
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||||
|
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||||
|
*/
|
||||||
|
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||||
|
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||||
|
*/
|
||||||
|
var onExitChord: (() -> Unit)? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||||
|
private var pendingExit: Runnable? = null
|
||||||
|
|
||||||
private val inputManager = context.getSystemService(InputManager::class.java)
|
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||||
private val listener = object : InputManager.InputDeviceListener {
|
private val listener = object : InputManager.InputDeviceListener {
|
||||||
override fun onInputDeviceAdded(deviceId: Int) {
|
override fun onInputDeviceAdded(deviceId: Int) {
|
||||||
@@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
inputManager?.registerInputDeviceListener(listener, mainHandler)
|
||||||
// Open a slot for every controller already connected when the session starts — the pads that
|
// Open a slot for every controller already connected when the session starts — the pads that
|
||||||
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||||
for (id in InputDevice.getDeviceIds()) {
|
for (id in InputDevice.getDeviceIds()) {
|
||||||
@@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
/**
|
/**
|
||||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||||
* slot's pad index, tracks held state, and returns true when this press completed the emergency
|
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||||
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
|
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||||
* (mirrors the Linux client's escape chord: any one controller can leave).
|
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||||
|
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||||
*/
|
*/
|
||||||
fun onButton(event: KeyEvent, bit: Int): Boolean {
|
fun onButton(event: KeyEvent, bit: Int) {
|
||||||
val slot = slotFor(event.device) ?: return false
|
val slot = slotFor(event.device) ?: return
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
KeyEvent.ACTION_DOWN -> {
|
KeyEvent.ACTION_DOWN -> {
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||||
slot.held = slot.held or bit
|
slot.held = slot.held or bit
|
||||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
|
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||||
slot.held = 0
|
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyEvent.ACTION_UP -> {
|
KeyEvent.ACTION_UP -> {
|
||||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||||
slot.held = slot.held and bit.inv()
|
slot.held = slot.held and bit.inv()
|
||||||
|
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||||
|
// holds the full chord.
|
||||||
|
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||||
|
disarmExit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||||
|
private fun armExit() {
|
||||||
|
if (pendingExit != null) return // already counting down
|
||||||
|
val r = Runnable {
|
||||||
|
pendingExit = null
|
||||||
|
// Fire only if the chord survived the full hold on some pad.
|
||||||
|
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||||
|
if (held.isNotEmpty()) {
|
||||||
|
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
|
||||||
|
// host-side once we leave, then signal the deliberate exit.
|
||||||
|
for (s in held) releaseHeld(s)
|
||||||
|
onExitChord?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingExit = r
|
||||||
|
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a pending exit-chord hold timer. */
|
||||||
|
private fun disarmExit() {
|
||||||
|
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
pendingExit = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
*/
|
*/
|
||||||
fun release() {
|
fun release() {
|
||||||
inputManager?.unregisterInputDeviceListener(listener)
|
inputManager?.unregisterInputDeviceListener(listener)
|
||||||
|
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
|
||||||
// Snapshot the ids first — closeSlot mutates the map.
|
// Snapshot the ids first — closeSlot mutates the map.
|
||||||
for (id in slots.keys.toList()) closeSlot(id)
|
for (id in slots.keys.toList()) closeSlot(id)
|
||||||
}
|
}
|
||||||
@@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
val slot = slots.remove(deviceId) ?: return
|
val slot = slots.remove(deviceId) ?: return
|
||||||
releaseHeld(slot)
|
releaseHeld(slot)
|
||||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||||
|
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||||
|
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||||
|
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||||
|
onSlotClosed?.invoke(deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||||
@@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
|
|
||||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
|
||||||
|
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||||
|
const val EXIT_HOLD_MS = 1500L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user