feat(clients/input): system buttons route around local overlays
ci / rust-arm64 (pull_request) Failing after 48s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m22s
ci / web (pull_request) Successful in 1m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / rust (pull_request) Canceled after 10m44s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m43s
ci / rust-arm64 (pull_request) Failing after 48s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m22s
ci / web (pull_request) Successful in 1m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / rust (pull_request) Canceled after 10m44s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m43s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
This commit is contained in:
@@ -483,6 +483,18 @@ private fun buildSettingsRows(
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
|
||||
@@ -44,6 +44,8 @@ data class SettingsOverlay(
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -78,6 +80,8 @@ data class SettingsOverlay(
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -115,6 +119,8 @@ data class SettingsOverlay(
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -142,6 +148,8 @@ data class SettingsOverlay(
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -166,6 +174,8 @@ data class SettingsOverlay(
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -198,6 +208,8 @@ data class SettingsOverlay(
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -214,6 +226,7 @@ data class SettingsOverlay(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
@@ -237,6 +250,8 @@ data class SettingsOverlay(
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -45,6 +45,20 @@ data class Settings(
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -228,6 +242,8 @@ class SettingsStore(context: Context) {
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -275,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -305,6 +323,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
@@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -621,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
|
||||
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
|
||||
@@ -50,12 +50,35 @@ class GamepadRouter(
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
@@ -139,7 +162,24 @@ class GamepadRouter(
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
@@ -155,7 +195,8 @@ class GamepadRouter(
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send && forwarding) {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -167,6 +208,61 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -362,6 +458,24 @@ class GamepadRouter(
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
@@ -403,5 +517,14 @@ class GamepadRouter(
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user