feat(client/android): the settings the shared struct had and Android didn't

Gap closure against the cross-client settings struct, plus one real bug.

`pointer_capture` was Android's private spelling of `mouse_mode`: same two
states (pointer lock + relative deltas, or free absolute pointing), a
different name and a Boolean shape, so a profile or another client could
never carry it. It becomes `MouseMode` with the shared `capture`/`desktop`
names and the shared labels, migrating from the old Boolean — whose `false`
default IS `desktop`, so an install that never touched the toggle lands
exactly where it already was. Android keeps `desktop` as its default rather
than the desktop clients' `capture`: a phone or TV is far more often driven
by touch or a pad than by a locked mouse, and that is what this platform
already did.

The pad picker gains Steam Deck, and stops assuming its index IS the wire
byte — `GamepadPref` has eleven values now and the offered subset is not a
prefix of them (Steam Deck is 6; 5, the classic Steam Controller, is not
offered, exactly as on the desktop clients).

PyroWave joins the codec table so the value is representable, but is not
offered: it is a Vulkan-compute codec that lives in `pf-presenter`, this
client decodes through MediaCodec and never advertises the bit, so choosing
it would silently resolve to HEVC. AV1's existing capability gate and this
one now share `codecOptionsFor`, which also keeps whatever IS stored
selectable — a codec chosen on another device must survive being looked at
here.

And the fix: "Steam Controller 2 passthrough" was gated on the device having
a body vibrator. It is a USB/BLE capture with nothing to do with rumbling
this device, and the gate hid it on exactly the machines that most want it —
TV boxes, where an SC2 is the whole input story. Only the rumble-mirroring
row, which genuinely needs a motor to mirror onto, stays gated.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent 24a2d3a81b
commit e7c0024543
4 changed files with 108 additions and 44 deletions
@@ -87,7 +87,9 @@ fun GamepadSettingsScreen(
val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -263,10 +265,12 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
}
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
@@ -337,7 +341,7 @@ private fun buildSettingsRows(
choice(
"codec", "Video", "Video codec",
"A preference — the host falls back if it can't encode this one.",
CODEC_OPTIONS, s.codec,
codecOptionsFor(s.codec, av1Capable), s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", null, "10-bit HDR",
@@ -362,7 +366,7 @@ private fun buildSettingsRows(
choice(
"padType", "Controller", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
@@ -111,12 +111,12 @@ data class Settings(
val sc2Capture: Boolean = true,
/**
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
* to the stream ([android.view.View.requestPointerCapture]) and forwards raw relative motion.
* Read once per session by StreamScreen; Ctrl+Alt+Shift+Q flips the capture live either way.
*/
val pointerCapture: Boolean = false,
val mouseMode: MouseMode = MouseMode.DESKTOP,
/**
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
@@ -135,6 +135,20 @@ data class Settings(
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* How a physical mouse drives the host — the cross-client mouse model (the Rust `MouseMode`,
* persisted as the same lowercase names). Only meaningful with a mouse attached.
* - [CAPTURE] — pointer lock: relative deltas, the local cursor hidden, the host's cursor the only
* one you see. The game model, and the desktop clients' default.
* - [DESKTOP] — uncaptured absolute pointing: the cursor enters and leaves the stream freely. The
* remote-desktop model, and Android's default (a phone/TV is far more often driven by touch or a
* pad than by a locked mouse, and this is what the platform did before the setting existed).
*/
enum class MouseMode(val storedName: String, val label: String) {
CAPTURE("capture", "Capture (games)"),
DESKTOP("desktop", "Desktop (absolute)"),
}
/**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps:
@@ -193,7 +207,12 @@ class SettingsStore(context: Context) {
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
// default was false, which IS `desktop` — so an install that never touched the toggle
// lands where it already was.
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
)
@@ -219,7 +238,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
.apply()
@@ -260,6 +279,9 @@ class SettingsStore(context: Context) {
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll"
const val K_CLIPBOARD_SYNC = "clipboard_sync"
@@ -406,21 +428,47 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround",
)
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
* only makes sense on a device with a real AV1 decoderSettingsScreen filters it out otherwise. */
/**
* (stored value, label) for the preferred video codecthe cross-client table (the Rust
* `CODECS`), so a value another client or a profile stored is always representable here.
* `"auto"` = host decides.
*
* Two rows are capability-gated by [codecOptionsFor] rather than dropped from the table: `"av1"`
* needs a real `video/av01` decoder on this device, and `"pyrowave"` needs a PyroWave decoder,
* which this platform does not have at all (it is a Vulkan-compute codec living in `pf-presenter`;
* the JNI client decodes through MediaCodec and never advertises the bit, so preferring it would
* be a dead setting that silently resolves to HEVC).
*/
val CODEC_OPTIONS = listOf(
"auto" to "Automatic",
"hevc" to "HEVC (H.265)",
"h264" to "H.264 (AVC)",
"av1" to "AV1",
"pyrowave" to "PyroWave (wired LAN)",
)
/**
* [CODEC_OPTIONS] minus the rows this device can't decode — a preference the client never
* advertises is a setting that does nothing. [stored] is the currently persisted value, which is
* always kept selectable so the selection can be rendered (the don't-clobber rule: a codec chosen
* on another device, or by a newer build, must survive being looked at here).
*/
fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, String>> =
CODEC_OPTIONS.filter { (v, _) ->
when (v) {
"av1" -> av1Capable || stored == "av1"
"pyrowave" -> stored == "pyrowave" // no PyroWave decoder on Android — see above
else -> true
}
}
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4. */
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
"h264" -> 1
"hevc" -> 2
"av1" -> 4
"pyrowave" -> 8
else -> 0
}
@@ -456,11 +504,20 @@ val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TOUCH to "Touch passthrough",
)
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
/** (mode, label) for the physical-mouse model. */
val MOUSE_MODE_OPTIONS = MouseMode.entries.map { it to it.label }
/**
* (GamepadPref wire byte, label) for the emulated pad the host creates. NOT positional: the wire
* bytes are `punktfunk_core::config::GamepadPref` (see `Gamepad.PREF_*`), and Steam Deck is `6`
* with `5` (the classic Steam Controller) deliberately not offered — the same subset the desktop
* clients' picker shows.
*/
val GAMEPAD_OPTIONS = listOf(
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_AUTO to "Automatic",
io.unom.punktfunk.kit.Gamepad.PREF_XBOX360 to "Xbox 360",
io.unom.punktfunk.kit.Gamepad.PREF_DUALSENSE to "DualSense",
io.unom.punktfunk.kit.Gamepad.PREF_XBOXONE to "Xbox One",
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
@@ -419,14 +419,12 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
caption = "Automatic lets the host decide (its default, clamped to what it supports).",
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
// device stays visible so the selection is always representable.
// Only codecs this device can actually decode are offered — a preference the client never
// advertises would be a dead setting (see [codecOptionsFor]).
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" }
SettingDropdown(
label = "Video codec",
options = codecOptions,
options = codecOptionsFor(s.codec, av1Capable),
selected = s.codec,
caption = "A preference — the host falls back if it can't encode this one.",
) { c -> update(s.copy(codec = c)) }
@@ -487,14 +485,15 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
) { mode -> update(s.copy(touchMode = mode)) }
}
SettingsGroup("Keyboard & mouse") {
ToggleRow(
title = "Capture pointer for games",
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
"Off: the mouse points at the desktop directly",
checked = s.pointerCapture,
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
)
SettingDropdown(
label = "Mouse input",
options = MOUSE_MODE_OPTIONS,
selected = s.mouseMode,
caption = "Capture locks a connected mouse to the stream and sends relative motion " +
"(mouse-look) — best for games; click the stream to re-capture. Desktop leaves " +
"the pointer free to enter and leave the stream and sends absolute positions — " +
"best for remote-desktop work. Ctrl+Alt+Shift+Q flips the capture live either way.",
) { mode -> update(s.copy(mouseMode = mode)) }
ToggleRow(
title = "Invert scroll direction",
subtitle = "Reverses the wheel and two-finger touch scroll direction sent to the host",
@@ -535,7 +534,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
SettingsGroup(footer = "Applies from the next session.") {
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
caption = "The virtual pad created on the host. Automatic matches your controller — " +
"a DualSense keeps adaptive triggers, lightbar, touchpad and motion. Every " +
@@ -546,7 +545,8 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers,
)
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
// Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row
// would be a silent no-op there.
val context = LocalContext.current
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
if (hasBodyVibrator) {
@@ -557,15 +557,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
checked = s.rumbleOnPhone,
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
)
ToggleRow(
title = "Steam Controller 2 passthrough",
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
"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)) },
)
}
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
ToggleRow(
title = "Steam Controller 2 passthrough",
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
"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)) },
)
}
}
@@ -246,7 +246,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val mouse = MouseForwarder(
handle,
invertScroll = initialSettings.invertScroll,
captureWanted = initialSettings.pointerCapture,
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
)
mouse.onRequestCapture = {