From e7c002454394db20ace6a256a4295e22040bf945 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:06:52 +0200 Subject: [PATCH] feat(client/android): the settings the shared struct had and Android didn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 12 ++- .../main/kotlin/io/unom/punktfunk/Settings.kt | 89 +++++++++++++++---- .../io/unom/punktfunk/SettingsScreen.kt | 49 +++++----- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 2 +- 4 files changed, 108 insertions(+), 44 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 38ba4e71..3d1cb5ef 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -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 { fun 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) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 87fc4598..5d0d3873 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -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> = + 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", ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index aac8950c..1e0900a6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -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)) }, + ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index eaec7000..75d82552 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -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 = {