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
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:
@@ -317,9 +317,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
// 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
|
// 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
|
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||||
// accidental brush of the four buttons no longer quits instantly.
|
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||||
gamepadRouter?.onButton(event, bit)
|
gamepadRouter?.onButton(event, bit)
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ import android.view.inputmethod.InputConnection
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -36,9 +39,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -163,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}.onEach { it.setReferenceCounted(false) }
|
}.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) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
wifiLocks.forEach { lock ->
|
wifiLocks.forEach { lock ->
|
||||||
@@ -205,6 +214,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// the same way the Back gesture does.
|
// the same way the Back gesture does.
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
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
|
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
|
||||||
@@ -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
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
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
|
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||||
activity?.gamepadRouter = null
|
activity?.gamepadRouter = null
|
||||||
activity?.streamHandle = 0L
|
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))
|
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
|
// 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.
|
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||||
AndroidView(
|
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
|
* 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
|
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
*/
|
*/
|
||||||
var onExitChord: (() -> Unit)? = null
|
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())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||||
private var pendingExit: Runnable? = null
|
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_*
|
* 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, and tracks held state. Completing the emergency stream-exit chord (Select +
|
* 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;
|
* 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
|
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
|
||||||
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
* 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) {
|
fun onButton(event: KeyEvent, bit: Int) {
|
||||||
val slot = slotFor(event.device) ?: return
|
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
|
if (pendingExit != null) return // already counting down
|
||||||
val r = Runnable {
|
val r = Runnable {
|
||||||
pendingExit = null
|
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.
|
// Fire only if the chord survived the full hold on some pad.
|
||||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||||
if (held.isNotEmpty()) {
|
if (held.isNotEmpty()) {
|
||||||
@@ -134,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
|||||||
}
|
}
|
||||||
pendingExit = r
|
pendingExit = r
|
||||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||||
|
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel a pending exit-chord hold timer. */
|
/** Cancel a pending exit-chord hold timer. */
|
||||||
private fun disarmExit() {
|
private fun disarmExit() {
|
||||||
|
val wasArmed = pendingExit != null
|
||||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||||
pendingExit = null
|
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). */
|
/** 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
|
* 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. */
|
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||||
const val EXTERNAL_ID_BASE = -1000
|
const val EXTERNAL_ID_BASE = -1000
|
||||||
|
|||||||
Reference in New Issue
Block a user