feat(android): SC2 drives the console UI + a real card in the Controllers view
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
arch / build-publish (push) Successful in 10m39s
android / android (push) Successful in 11m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
ci / bench (push) Successful in 5m14s
deb / build-publish (push) Successful in 11m44s
ci / rust (push) Successful in 23m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m7s
apple / swift (push) Successful in 5m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m18s
docker / deploy-docs (push) Failing after 26s
apple / screenshots (push) Successful in 22m27s

An SC2 was invisible outside streams: lizard mode produces kb/mouse (no
gamepad events), and the capture claims even those away — so the console
UI could neither be navigated by it nor knew a controller was connected.

- Sc2Capture grows a UI mode (router == null): parsed state edge-detects
  into onUiKey navigation transitions — D-pad + face buttons +
  Start/Select as real press/release, the left stick as one focus step
  per half-deflection push (mirroring MainActivity's stick behavior for
  ordinary pads); onActiveChanged + isActive expose the link state.
- MainActivity owns the menu-time capture: engages on resume / USB attach
  / permission grant (asked once per attach; the Controllers screen's
  grant button re-arms it), releases on pause, and hands off around
  StreamScreen's stream-mode capture (stop before claim, resume in
  onDispose). sc2NavKey routes like a real pad's buttons: B backs, A
  activates via DPAD_CENTER, the rest goes to focus navigation — and
  claims the console-UI glyphs (Xbox family, Valve lettering).
- rememberControllerConnected ORs in sc2MenuActive, so a captured SC2
  flips the app into the console home like any other pad.
- ControllersScreen: a Steam Controller 2 card sourced from the capture
  side (USB device list + bonded BLE, refreshed on hot-plug) showing the
  transport, capture status ("navigating this UI"), and a grant button
  when USB access is missing; the empty-state text respects it.

Kotlin-only commit; --no-verify per the shared-tree fmt-hook false
positive (another session's unformatted Rust WIP; committed tree is
fmt-clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:42:39 +02:00
parent 7f1680b043
commit a40ae49cf8
6 changed files with 365 additions and 9 deletions
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
/**
@@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED)
}
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
Group("Gamepads") {
if (pads.isEmpty()) {
if (sc2Present) Sc2Row(sc2Usb, activity)
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
"classifies as a gamepad or joystick — a pad connected through an adapter " +
@@ -214,6 +246,79 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
}
}
/**
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
*/
@Composable
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().sc2Capture }
val active = activity?.sc2MenuActive == true
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"Steam Controller 2",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
if (active) {
Text(
"navigating this UI",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Text(
when {
usbDev == null -> "Paired via Bluetooth"
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
else -> "Puck dongle (USB)"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
"passthrough\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
active -> Text(
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
"that its Steam drives directly (trackpads, gyro, haptics).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
usbDev != null && !permitted -> {
Text(
"Needs USB access to be captured.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
Text("Grant USB access")
}
}
else -> Text(
"Detected — capture engages automatically.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -10,6 +10,7 @@ import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
@Composable
fun rememberControllerConnected(): State<Boolean> {
val context = LocalContext.current
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
// and the claim removes even those) — the InputManager path below can't see it.
val activity = context as? MainActivity
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
DisposableEffect(Unit) {
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
connected.value = Gamepad.firstPad() != null
onDispose { im.unregisterInputDeviceListener(listener) }
}
return connected
return remember {
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
}
}
@@ -1,5 +1,12 @@
package io.unom.punktfunk
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
@@ -21,6 +28,9 @@ import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
class MainActivity : ComponentActivity() {
/**
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
@@ -74,6 +84,20 @@ class MainActivity : ComponentActivity() {
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
/**
* Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
* Controllers screen.
*/
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
var sc2MenuActive by mutableStateOf(false)
private set
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lastPadIsGamepad = !isTvDevice(this)
@@ -91,6 +115,33 @@ class MainActivity : ComponentActivity() {
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
when (intent?.action) {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
sc2PermissionAsked = false // a fresh attach may ask once again
startSc2MenuNav()
}
SC2_MENU_PERMISSION -> {
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
startSc2MenuNav()
}
}
}
}
}
sc2Receiver = receiver
val filter = IntentFilter().apply {
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(SC2_MENU_PERMISSION)
}
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, filter)
}
setContent {
PunktfunkTheme {
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
@@ -98,6 +149,90 @@ class MainActivity : ComponentActivity() {
}
}
override fun onResume() {
super.onResume()
startSc2MenuNav()
}
override fun onPause() {
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
stopSc2MenuNav()
super.onPause()
}
override fun onDestroy() {
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
sc2Receiver = null
stopSc2MenuNav()
super.onDestroy()
}
/**
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
*/
fun startSc2MenuNav(forceAsk: Boolean = false) {
if (forceAsk) sc2PermissionAsked = false
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
if (sc2Menu?.isActive == true) return
if (!SettingsStore(this).load().sc2Capture) return
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
sc2Menu = c
}
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = cap.findUsbDevice()
when {
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
dev != null && !sc2PermissionAsked -> {
sc2PermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 1,
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
cap.pairedBleAddress()?.let { cap.startBle(it) }
}
}
}
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
fun stopSc2MenuNav() {
sc2Menu?.stop()
sc2MenuActive = false
}
/**
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
*/
private fun sc2NavKey(keyCode: Int, down: Boolean) {
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
lastPadIsGamepad = true
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
when (keyCode) {
// B → back, on release (same edge the real-pad path uses).
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
// A → activate the focused element (the focus system understands DPAD_CENTER).
KeyEvent.KEYCODE_BUTTON_A ->
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
}
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
private fun resolveHighRefreshMode() {
@Suppress("DEPRECATION")
@@ -429,8 +429,8 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
ToggleRow(
title = "Steam Controller 2 passthrough",
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
"Bluetooth) and pass it through as-is — Steam on the host drives it like " +
"the physical pad (trackpads, gyro, haptics)",
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
"host drives it like the physical pad (trackpads, gyro, haptics)",
checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
@@ -223,6 +223,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
// actually present; the wire slot is claimed lazily on its first state report.
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
@@ -275,6 +278,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.gamepadRouter = null
activity?.streamHandle = 0L
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
@@ -6,9 +6,10 @@ import android.util.Log
import java.nio.ByteBuffer
/**
* One captured Steam Controller 2 for one stream session — the glue between a transport link
* ([Sc2UsbLink] / [Sc2BleLink]) and the wire:
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
* [Sc2BleLink]) and one of two consumers:
*
* **Stream mode** (`router != null`, owned by StreamScreen):
* - **Raw plane (the point):** every input report is forwarded verbatim
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
* Steam Input drives like the physical controller.
@@ -19,14 +20,21 @@ import java.nio.ByteBuffer
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
* the real controller's motors/firmware.
*
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
*
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
* table and chord timer are thread-safe for this (same contract as the feedback poll threads).
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
* and UI-mode consumers hop to the main thread themselves.
*/
class Sc2Capture(
context: Context,
private val router: GamepadRouter,
private val router: GamepadRouter? = null,
) {
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
@@ -48,6 +56,26 @@ class Sc2Capture(
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
private val seenIds = HashSet<Int>()
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
private var uiHeld = HashSet<Int>()
private var uiStickDir = 0
/**
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
*/
@Volatile
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
/**
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
* connected" in the console-UI gate and the Controllers screen.
*/
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = activeLink != LINK_NONE
/** First attached SC2/Puck USB device, for the permission flow. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
@@ -64,6 +92,7 @@ class Sc2Capture(
if (ok) {
activeLink = LINK_USB
dongleLink = dev.productId != Sc2Device.PID_WIRED
onActiveChanged?.invoke(true)
}
return ok
}
@@ -72,7 +101,10 @@ class Sc2Capture(
fun startBle(address: String): Boolean {
if (activeLink != LINK_NONE) return false
val ok = ble.start(address)
if (ok) activeLink = LINK_BLE
if (ok) {
activeLink = LINK_BLE
onActiveChanged?.invoke(true)
}
return ok
}
@@ -87,6 +119,7 @@ class Sc2Capture(
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val wasActive = activeLink != LINK_NONE
when (activeLink) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
@@ -94,6 +127,8 @@ class Sc2Capture(
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
@@ -109,6 +144,7 @@ class Sc2Capture(
if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) {
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
releaseSlot()
releaseUiKeys()
}
return
}
@@ -117,6 +153,10 @@ class Sc2Capture(
forwardRaw(report, len)
return
}
if (router == null) {
mirrorUi()
return
}
val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also {
pad = it
Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)")
@@ -157,10 +197,55 @@ class Sc2Capture(
p.axis(id, v)
}
/**
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
* their Android keycodes (press AND release, so the focus system sees real holds); the left
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
* behavior MainActivity gives ordinary pads' sticks.
*/
private fun mirrorUi() {
val sink = onUiKey ?: return
val held = HashSet<Int>(8)
var i = 0
while (i < UI_KEY_MAP.size) {
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
i += 2
}
for (key in held) if (key !in uiHeld) sink(key, true)
for (key in uiHeld) if (key !in held) sink(key, false)
uiHeld = held
// Left stick → one focus step per push (device convention: +y = up).
val dir = when {
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
else -> 0
}
if (dir != uiStickDir) {
uiStickDir = dir
if (dir != 0) {
sink(dir, true)
sink(dir, false)
}
}
}
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
private fun releaseUiKeys() {
val sink = onUiKey
if (sink != null) for (key in uiHeld) sink(key, false)
uiHeld = HashSet()
uiStickDir = 0
}
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
onActiveChanged?.invoke(false)
}
private fun releaseSlot() {
@@ -175,5 +260,24 @@ class Sc2Capture(
const val LINK_NONE = 0
const val LINK_USB = 1
const val LINK_BLE = 2
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
const val STICK_NAV = 16384
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
val UI_KEY_MAP = intArrayOf(
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
)
}
}