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:
@@ -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 decoder — SettingsScreen filters it out otherwise. */
|
||||
/**
|
||||
* (stored value, label) for the preferred video codec — the 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 = {
|
||||
|
||||
Reference in New Issue
Block a user