fix(gamepad/android): make the exit chord usable again — shorter hold + on-screen hint
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
ci / bench (push) Successful in 5m21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / deploy-docs (push) Successful in 24s
ci / rust (push) Failing after 8m21s
arch / build-publish (push) Successful in 11m13s
deb / build-publish (push) Successful in 12m19s
android / android (push) Successful in 15m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m56s
apple / swift (push) Successful in 4m57s
apple / screenshots (push) Successful in 20m12s

The Select+Start+L1+R1 close-stream chord read as broken after 48933dc4
changed it from an instant quit to a 1.5 s arm-and-hold: a normal quick
press did nothing and there was no on-screen cue that a hold was now
required. Keep the accident-prevention hold (an errant brush of the four
buttons still shouldn't kill a session), but make it usable:

- Shorten EXIT_HOLD_MS 1500 -> 1000 ms — still rejects a brush, feels
  responsive.
- GamepadRouter gains onExitArmed(Boolean): fires true when the chord
  completes and the countdown starts (once per cycle, past the
  pendingExit guard), false on an early release or when the timer
  elapses.
- StreamScreen shows a "Hold to quit…" pill (top-center) while armed, so
  the hold is discoverable; the callback is detached in onDispose before
  router.release() so its disarm can't poke Compose state during
  teardown.
- MainActivity: drop the now-stale "~1.5 s" dispatch comment.

Verified on this Mac: :kit + :app compileDebugKotlin clean; Android lint
clean for all three touched files (the kit lint baseline errors are
pre-existing, unrelated). On-glass on a real phone + pad still owed (the
1 s hold firing the exit, early-release cancelling, the hint showing /
hiding) — per the Android-input-regressions-only-show-on-hardware
history, and the original hold path was never exercised on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:24:49 +02:00
parent 499bf2dae8
commit a7d4a93ff2
3 changed files with 62 additions and 8 deletions
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
var onExitChord: (() -> Unit)? = null
/**
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
* quick press, and without an on-screen cue that reads as the shortcut being broken.
*/
var onExitArmed: ((armed: Boolean) -> 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
@@ -84,9 +92,10 @@ 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_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
* [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.
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [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) {
val slot = slotFor(event.device) ?: return
@@ -123,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (pendingExit != null) return // already counting down
val r = Runnable {
pendingExit = null
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
// 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()) {
@@ -134,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
}
pendingExit = r
mainHandler.postDelayed(r, EXIT_HOLD_MS)
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
}
/** Cancel a pending exit-chord hold timer. */
private fun disarmExit() {
val wasArmed = pendingExit != null
pendingExit?.let { mainHandler.removeCallbacks(it) }
pendingExit = null
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
}
/**
@@ -303,8 +316,12 @@ 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). */
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
/**
* How long the exit chord must be held before the stream leaves — long enough that an
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
*/
const val EXIT_HOLD_MS = 1000L
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000