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
@@ -317,9 +317,9 @@ class MainActivity : ComponentActivity() {
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
// accidental brush of the four buttons no longer quits instantly.
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
// can still leave — but an accidental brush of the four buttons no longer quits.
gamepadRouter?.onButton(event, bit)
return true // consumed
}
@@ -23,10 +23,13 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -36,9 +39,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
@@ -163,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}.onEach { it.setReferenceCounted(false) }
}
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
var exitArming by remember { mutableStateOf(false) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock ->
@@ -205,6 +214,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// the same way the Back gesture does.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
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
// index via the router; poll threads stopped + joined before the router is released and the
@@ -274,6 +286,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
activity?.streamHandle = 0L
@@ -356,6 +369,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
}
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
// broken. Purely visual; it sits above the video and below the gesture layer.
if (exitArming) {
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
AndroidView(
@@ -382,6 +401,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}
}
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
* toggles its visibility.
*/
@Composable
private fun ExitChordHint(modifier: Modifier = Modifier) {
Text(
"Hold to quit…",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s