diff --git a/clients/decky/README.md b/clients/decky/README.md index cb6aa0b4..687401d4 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -24,8 +24,14 @@ the panel looks and feels native to Gaming Mode. browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive plugin reinstalls (stored next to the client's config) and follow a host across IP changes (matched by certificate fingerprint). -5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written - to the client's config. +5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's + own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs + scrolling. The categories and their order are the console settings screen's — Stream (resolution + / refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4), + Audio (channels / output + mic device / echo cancellation), Controllers, Touch & mouse, + Interface (stats overlay / auto-wake / library / fullscreen). The device pickers are populated + from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where + there is more than one adapter. 6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and a force-stop for a wedged stream client. @@ -93,7 +99,7 @@ restart is required for an out-of-band install to appear. | --- | --- | | `src/index.tsx` | Plugin entry: the QAM panel + route registration. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | -| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. | +| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of six category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. | | `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. | | `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. | | `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). | diff --git a/clients/decky/main.py b/clients/decky/main.py index b9c6a9b4..19e73116 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do: the frontend so it can create/point the Steam shortcut. * **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. + ``set_settings`` MERGES onto the file: it is shared with the desktop client and the console. +* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's + device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``) + and cached, since enumerating them costs a Vulkan + PipeWire init. * **kill_stream()** — force-stop a wedged stream (``flatpak kill``). * **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own @@ -343,6 +347,9 @@ def _flatpak() -> str | None: # settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real # home), so nothing else in this file has to care which one answered. NATIVE_BIN = "punktfunk-client" +# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate +# this device's GPUs and audio endpoints for the settings pickers. +SESSION_BIN = "punktfunk-session" # Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and # SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix. @@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None: return [native] if native else None +def _session_argv() -> list[str] | None: + """The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there. + + The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on + `punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and + shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The + flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native + install puts them in the same bindir, so the session is the client's sibling. + """ + prefix = _client_argv() + if not prefix: + return None + if prefix[0] == _flatpak(): + # `flatpak run --command= ` — the app id must stay LAST. + return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]] + sibling = Path(prefix[0]).with_name(SESSION_BIN) + return [str(sibling)] if sibling.exists() else None + + def _client_is_flatpak() -> bool: """Is the client this plugin actually drives the FLATPAK one? @@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in return -1, "", "" +def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]: + """Split `punktfunk-session --list-audio` into ``(sinks, sources)``. + + Its format is one endpoint per line, ``sink|sourcenode.namedescription``. The + node.name is what gets STORED (it is the stable id the client resolves against), so a line + without one is unusable and dropped; a missing description falls back to the name rather than + rendering a picker entry with no label. Anything else on the line is ignored, so an extra + trailing column in a future client can't break this. + """ + sinks: list[dict] = [] + sources: list[dict] = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 3 or not parts[1].strip(): + continue + kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip() + entry = {"name": name, "description": description or name} + if kind == "sink": + sinks.append(entry) + elif kind == "source": + sources.append(entry) + return sinks, sources + + +async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]: + """Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when + it isn't installed or the call errors/times out. + + Only ever used for the two read-only device enumerations — the launch path goes through the + Steam shortcut and the wrapper script, never through here. The timeout is generous because + `--list-adapters` initialises Vulkan on a cold flatpak.""" + prefix = _session_argv() + if not prefix: + return -1, "" + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *prefix, *session_args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + env=_flatpak_env(), + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + rc = proc.returncode if proc.returncode is not None else -1 + return rc, (out or b"").decode("utf-8", "replace") + except asyncio.TimeoutError: + decky.logger.warning("session %s timed out", " ".join(session_args)) + if proc: + try: + proc.kill() + except ProcessLookupError: + pass + return -1, "" + except Exception: # noqa: BLE001 + decky.logger.exception("session %s failed", " ".join(session_args)) + return -1, "" + + # The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the # QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability # probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any @@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in _HOSTS_TTL_S = 12.0 _hosts_cache: dict = {"at": 0.0, "probed": None, "data": None} +# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading +# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices` +# clears it for the user who just plugged a headset in. +_devices_cache: dict = {"data": None} + def _invalidate_hosts_cache() -> None: _hosts_cache["data"] = None @@ -1079,6 +1167,49 @@ class Plugin: decky.logger.exception("could not write settings") return {"ok": False, "error": str(exc)} + async def list_devices(self) -> dict: + """GPUs + audio endpoints for the settings tab's device pickers. + + Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the + Decky session: hardware doesn't come and go often enough to justify paying that on every + remount of the page, and a stale entry is harmless — a picked device that has since + vanished falls back to the OS default in the client anyway. `refresh_devices` clears it. + + Best-effort in the same way every other client call here is: no session binary (an old + flatpak that predates the two-binary split, or a native install missing its sibling) just + means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as + "you have no devices". + """ + if _devices_cache["data"] is not None: + return _devices_cache["data"] + + adapters: list[str] = [] + sinks: list[dict] = [] + sources: list[dict] = [] + rc_a, out_a = await _run_session(["--list-adapters"]) + if rc_a == 0: + adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()] + rc_d, out_d = await _run_session(["--list-audio"]) + if rc_d == 0: + sinks, sources = _parse_audio_endpoints(out_d) + + result = { + "ok": rc_a == 0 or rc_d == 0, + "adapters": adapters, + "sinks": sinks, + "sources": sources, + } + # Only a run that actually answered is worth remembering — caching a failure would make + # a client installed after the page was first opened stay invisible until a Decky restart. + if result["ok"]: + _devices_cache["data"] = result + return result + + async def refresh_devices(self) -> dict: + """Drop the cached enumeration and read it again (a headset was just plugged in).""" + _devices_cache["data"] = None + return await self.list_devices() + # ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ---- async def list_hosts(self, probe: bool = True) -> dict: diff --git a/clients/decky/scripts/test-backend.py b/clients/decky/scripts/test-backend.py index f1f20cc0..5e335262 100644 --- a/clients/decky/scripts/test-backend.py +++ b/clients/decky/scripts/test-backend.py @@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"] check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True) shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True) +# ---- `--list-audio` parsing (the settings tab's device pickers) -------------------------- +sinks, sources = main._parse_audio_endpoints( + "sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n" + "sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n" + "source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n" +) +check("audio: sinks parsed", [d["name"] for d in sinks] == [ + "alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1" +]) +check("audio: sources parsed", len(sources) == 1) +check("audio: description kept", sinks[1]["description"] == "WH-1000XM4") + +# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a +# short line is malformed, and an unknown kind belongs to neither list. A blank description +# falls back to the name so no entry renders unlabelled. +sinks, sources = main._parse_audio_endpoints( + "sink\t\tNo node name\n" + "sink\tonly-two-columns\n" + "monitor\tsome.monitor\tNot a sink or source\n" + "source\tbare.node\t\n" + "\n" +) +check("audio: junk lines dropped", sinks == []) +check("audio: blank description falls back to the node name", sources == [ + {"name": "bare.node", "description": "bare.node"} +]) + print() if failures: print(f"{failures} check(s) FAILED") diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 27011722..367fd908 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -101,27 +101,62 @@ export interface RunnerInfo { client_bin?: string; } -// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more -// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched -// because get_settings returns the whole parsed file and patches are object spreads. +// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client +// and the console's settings screen own, so a value changed in any of them shows in the others. +// +// Every field the client's `Settings` struct persists is modelled here EXCEPT the four that +// cannot be answered from a plugin backend or aren't settings at all: +// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only +// the client process has; there is no CLI that enumerates pads. +// • `last_window_w/h` — the session's remembered window size, written BY the client, not a +// preference anyone sets. +// Both round-trip untouched: get_settings returns the whole parsed file, patches are object +// spreads, and set_settings merges onto what's on disk. +// +// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before +// that key existed simply lacks it. Read those through the same fallback the client uses — +// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here +// while the stream runs with it on. export interface StreamSettings { + // ---- Stream mode ---- width: number; // 0 = native height: number; // 0 = native refresh_hz: number; // 0 = native render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files) bitrate_kbps: number; // 0 = host default - codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files) + compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" + // Stream mode follows the session window instead of width/height, renegotiating on resize. + // Overrides width/height while on; degenerates to the display's native mode on fullscreen. + match_window?: boolean; + + // ---- Video ---- + codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files) + decoder?: string; // "auto" | "vulkan" | "vaapi" | "software" + hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10 + enable_444?: boolean; // default off — ask for full chroma + adapter?: string; // decode/present GPU by marketing name; "" = automatic + + // ---- Audio ---- + audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1) + speaker_device?: string; // PipeWire node.name for playback; "" = system default + mic_enabled: boolean; + mic_device?: string; // PipeWire node.name for capture; "" = system default + echo_cancel?: boolean; // default ON; only meaningful while mic_enabled + + // ---- Controllers ---- gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" // Forward this device's controllers at all. Absent in pre-forwarding files, where the // client's own serde default (true) applies — so `?? true` at every read, never `!!`. gamepad_forwarding?: boolean; - compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" - // Round-trips only — deliberately NOT offered as a row here. It decides whether the session - // grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no - // compositor shortcuts to inhibit and hands the focused window every key already. A toggle - // here would be a dead one. The desktop client's row still edits this same file. + + // ---- Touchscreen, mouse & keyboard ---- + touch_mode?: string; // "trackpad" | "pointer" | "touch" + mouse_mode?: string; // "capture" | "desktop" + invert_scroll?: boolean; + // Whether the session grabs the keyboard so Alt+Tab/Super reach the host. inhibit_shortcuts: boolean; - mic_enabled: boolean; + + // ---- Interface & behaviour ---- // Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file, // which resolves through `show_stats` — read both the way the client's // `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does. @@ -129,6 +164,26 @@ export interface StreamSettings { // The legacy on/off the tier supersedes; kept written in sync so a client that predates the // tiers still honours an Off chosen here. show_stats?: boolean; + fullscreen_on_stream?: boolean; + auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting + library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own) +} + +// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the +// human name to show. +export interface AudioDevice { + name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store + description: string; // human label ("Steam Deck Speakers") +} + +// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`). +// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the +// pickers stay on their stored value rather than pretending the device is gone. +export interface DeviceLists { + ok: boolean; + adapters: string[]; // Vulkan physical devices, discrete first + sinks: AudioDevice[]; // playback endpoints + sources: AudioDevice[]; // capture endpoints } export interface UpdateInfo { @@ -195,6 +250,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings"); export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( "set_settings", ); +// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and +// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path. +export const listDevices = callable<[], DeviceLists>("list_devices"); +// The same, bypassing the backend's cache — for the user who just plugged in a headset. +export const refreshDevices = callable<[], DeviceLists>("refresh_devices"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); // Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is // up by the time the stream connects. The MAC is looked up from the flatpak client's own diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index cae0640f..8b334bc8 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -334,8 +334,14 @@ const HostsTab: FC<{ ); +// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail + +// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an +// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and +// keeps its hands off the overflow. The footer inset lives inside the pages instead. +const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" }; + const SettingsTab: FC = () => ( -
+
); diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index 97baf0f0..975df1a8 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -1,10 +1,58 @@ -// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to -// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The -// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. -import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; -import { CSSProperties, FC, useEffect, useState } from "react"; -import { getSettings, setSettings, StreamSettings } from "./backend"; -import { RowActions } from "./ui"; +// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on +// launch (main.py set_settings, merged onto what's on disk). This is the same +// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value +// changed in any of the three shows in the other two. +// +// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split +// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings +// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is +// the whole point of the split: the rail is the index, so nothing is more than one hop away. +// +// The categories, their order, and the wording of the rows are the console's settings screen +// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user +// reaches without leaving Gaming Mode, and two different orders for one store is how people stop +// trusting either. It shows them as one steppable list because it has no pointer and no room for +// a rail; here they become the rail's pages, same groups, same sequence. Three more rules: +// +// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the +// console dims those rows rather than dropping them, and a row that vanishes as you toggle +// the one above it is a moving target for a thumbstick. +// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a +// one-GPU Deck). A dead control is worse than an absent one. +// • Anything that behaves differently *here* than it does on a desktop says so in its own +// description, rather than being silently dropped from the screen. +// +// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name` +// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` / +// `MouseMode` enums, which serialize lowercase. +import { + DialogButton, + Dropdown, + Field, + SidebarNavigation, + SliderField, + Spinner, + ToggleField, +} from "@decky/ui"; +import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react"; +import { + FaDesktop, + FaGamepad, + FaHandPointer, + FaSlidersH, + FaVideo, + FaVolumeUp, +} from "react-icons/fa"; +import { + AudioDevice, + DeviceLists, + getSettings, + listDevices, + refreshDevices, + setSettings, + StreamSettings, +} from "./backend"; +import { actionButton, RowActions } from "./ui"; // Decky's Dropdown has no width prop — it fills whatever container it's in, and a // `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell @@ -17,59 +65,493 @@ const selectShell: CSSProperties = { maxWidth: "24em", }; +// ---------------------------------------------------------------------------------------- +// Option tables — the console's, so the two Gaming-Mode editors offer the same choices. +// ---------------------------------------------------------------------------------------- + +// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on. +// Match window is offered even though this plugin's launches are always fullscreen (where it +// degenerates to the display's native mode) — leaving it out would make the row lie about a +// store the desktop client can set it in. +const MATCH_WINDOW = "match"; const RESOLUTIONS: [number, number, string][] = [ [0, 0, "Native display"], [1280, 720, "1280 × 720"], [1280, 800, "1280 × 800 (Deck)"], [1920, 1080, "1920 × 1080"], [2560, 1440, "2560 × 1440"], + [3840, 2160, "3840 × 2160"], ]; +const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`); + const REFRESH = [0, 30, 60, 90, 120]; // Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native. const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; const renderScaleLabel = (x: number): string => x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`; -const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"]; -const GAMEPAD_LABELS: Record = { - auto: "Automatic", - xbox360: "Xbox 360", - xboxone: "Xbox One", - dualsense: "DualSense", - dualshock4: "DualShock 4", - steamdeck: "Steam Deck", + +const COMPOSITORS: [string, string][] = [ + ["auto", "Automatic"], + ["kwin", "KDE Plasma (KWin)"], + ["wlroots", "Sway (wlroots)"], + ["mutter", "GNOME (Mutter)"], + ["gamescope", "gamescope"], +]; +const CODECS: [string, string][] = [ + ["auto", "Automatic"], + ["hevc", "HEVC (H.265)"], + ["h264", "H.264 (AVC)"], + ["av1", "AV1"], + // Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected + // when the host advertises it too; anything else falls back to HEVC. + ["pyrowave", "PyroWave (wired LAN)"], +]; +const DECODERS: [string, string][] = [ + ["auto", "Automatic"], + ["vulkan", "Vulkan Video"], + ["vaapi", "VAAPI"], + ["software", "Software"], +]; +const AUDIO_CHANNELS: [number, string][] = [ + [2, "Stereo"], + [6, "5.1 surround"], + [8, "7.1 surround"], +]; +const GAMEPADS: [string, string][] = [ + ["auto", "Automatic"], + ["xbox360", "Xbox 360"], + ["xboxone", "Xbox One"], + ["dualsense", "DualSense"], + ["dualshock4", "DualShock 4"], + ["steamdeck", "Steam Deck"], +]; +const TOUCH_MODES: [string, string][] = [ + ["trackpad", "Trackpad"], + ["pointer", "Direct pointer"], + ["touch", "Touch passthrough"], +]; +const MOUSE_MODES: [string, string][] = [ + ["capture", "Capture (games)"], + ["desktop", "Desktop (absolute)"], +]; +const STATS_TIERS: [string, string][] = [ + ["off", "Off"], + ["compact", "Compact"], + ["normal", "Normal"], + ["detailed", "Detailed"], +]; + +// ---------------------------------------------------------------------------------------- +// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the +// twelve of them below stay one line each and can't drift apart. +// ---------------------------------------------------------------------------------------- + +const SelectRow = ({ + label, + description, + options, + value, + onChange, + formatUnknown, + disabled, + indent, +}: { + label: string; + description?: ReactNode; + options: [T, string][]; + value: T; + onChange: (v: T) => void; + // How to name a stored value this table doesn't list (see below); defaults to the raw value. + formatUnknown?: (v: T) => string; + disabled?: boolean; + indent?: boolean; +}): ReactElement => { + // A Dropdown can only display a value that is one of its options, and this store has four other + // writers — the desktop client, the console, a settings profile, a newer client with presets + // this build doesn't know. Rather than render a blank control (or, worse, silently show a + // different value than the stream will actually use), carry the stored one as its own entry. + const shown: [T, string][] = options.some(([v]) => v === value) + ? options + : [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]]; + return ( + + +
+ ({ data, label: l }))} + selectedOption={value} + onChange={(o) => onChange(o.data as T)} + /> +
+
+
+ ); }; -// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host -// falls back from when its GPU can't encode it. -const CODECS = ["auto", "hevc", "h264", "av1"]; -const CODEC_LABELS: Record = { - auto: "Automatic", - hevc: "HEVC (H.265)", - h264: "H.264 (AVC)", - av1: "AV1", + +// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS +// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is +// a real preference that simply isn't plugged in right now, and dropping it would silently +// re-point the next stream at the default without ever showing the user why. +const DeviceRow: FC<{ + label: string; + description: string; + devices: AudioDevice[] | null; + value: string; + onChange: (v: string) => void; + disabled?: boolean; + indent?: boolean; +}> = ({ label, description, devices, value, onChange, disabled, indent }) => { + const options: [string, string][] = [["", "System default"]]; + for (const d of devices ?? []) options.push([d.name, d.description]); + if (value && !options.some(([name]) => name === value)) { + options.push([value, `${value} (not connected)`]); + } + return ( + + ); }; -const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"]; -const COMPOSITOR_LABELS: Record = { - auto: "Automatic", - kwin: "KDE Plasma (KWin)", - wlroots: "Sway (wlroots)", - mutter: "GNOME (Mutter)", - gamescope: "gamescope", + +// ---------------------------------------------------------------------------------------- +// The pages. One settings object, six views on it — every page takes the same context rather +// than fetching or holding state of its own, so a change on one page is visible on the others +// the moment you switch. +// ---------------------------------------------------------------------------------------- + +interface PageCtx { + s: StreamSettings; + patch: (p: Partial) => void; + devices: DeviceLists | null; + reading: boolean; + readDevices: (again: boolean) => void; +} + +// SidebarNavigation gives each page Steam's own padding, but the routed page still renders +// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same +// inset the tabs use). +const pageBody: CSSProperties = { paddingBottom: "80px" }; + +const StreamPage: FC = ({ s, patch }) => { + const renderScale = s.render_scale ?? 1; + const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height); + return ( +
+ [resolutionKey(w, h), label] as [string, string]), + [MATCH_WINDOW, "Match window"] as [string, string], + ]} + value={resolution} + // A size set from a desktop profile that isn't one of these presets, spelled the way the + // presets are rather than left as the raw "1600x900" key. + formatUnknown={(v) => v.replace("x", " × ")} + onChange={(v) => { + if (v === MATCH_WINDOW) { + // The tri-state the console stores: the flag on, the explicit size cleared. + patch({ match_window: true, width: 0, height: 0 }); + return; + } + const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v); + patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 }); + }} + /> + [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])} + value={s.refresh_hz} + formatUnknown={(v) => `${v} Hz`} + onChange={(v) => patch({ refresh_hz: v })} + /> + [x, renderScaleLabel(x)] as [number, string])} + // Snap the stored value to the nearest preset so the dropdown always shows a match. + value={RENDER_SCALES.reduce((best, x) => + Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best, + )} + onChange={(v) => patch({ render_scale: v })} + /> + patch({ bitrate_kbps: v * 1000 })} + /> + patch({ compositor: v })} + /> +
+ ); }; -// The stats-overlay tiers, in the cycle order every other client's picker uses -// (punktfunk_core `StatsVerbosity::ALL`). Stored lowercase — the enum is `rename_all`. -const STATS_TIERS = ["off", "compact", "normal", "detailed"]; -const STATS_LABELS: Record = { - off: "Off", - compact: "Compact", - normal: "Normal", - detailed: "Detailed", + +const VideoPage: FC = ({ s, patch, devices }) => { + // Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a + // picker with a single option is a control that can't do anything. + const showGpuRow = (devices?.adapters.length ?? 0) > 1; + return ( +
+ patch({ codec: v })} + /> + patch({ decoder: v })} + /> + {showGpuRow && ( + [a, a] as [string, string]), + ]} + value={s.adapter ?? ""} + onChange={(v) => patch({ adapter: v })} + /> + )} + patch({ hdr_enabled: v })} + /> + patch({ enable_444: v })} + /> +
+ ); }; +const AudioPage: FC = ({ s, patch, devices, reading, readDevices }) => { + const micOn = s.mic_enabled; + // What the pickers get: null while the enumeration is in flight (they show a loading state), + // [] when it answered but couldn't read the endpoints (System default plus whatever is + // stored), and the real list otherwise. + const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null => + reading || !devices ? null : devices.ok ? (list ?? []) : []; + return ( +
+ `${v} channels`} + onChange={(v) => patch({ audio_channels: v })} + /> + patch({ speaker_device: v })} + /> + patch({ mic_enabled: v })} + /> + patch({ mic_device: v })} + disabled={!micOn} + indent + /> + patch({ echo_cancel: v })} + disabled={!micOn} + indentLevel={1} + /> + {/* The escape hatch for a headset plugged in after this page was opened, and the honest + answer when the enumeration failed outright (a client too old to ship the session + binary). Rendered unconditionally, including while it is reading: a row that comes and + goes under a thumbstick is a moving target, so only its wording changes. */} + + + readDevices(true)}> + {reading ? : "Refresh"} + + + +
+ ); +}; + +const ControllersPage: FC = ({ s, patch }) => { + const forwarding = s.gamepad_forwarding ?? true; + return ( +
+ patch({ gamepad_forwarding: v })} + /> + patch({ gamepad: v })} + disabled={!forwarding} + indent + /> + {forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && ( + + )} +
+ ); +}; + +const PointerPage: FC = ({ s, patch }) => ( +
+ patch({ touch_mode: v })} + /> + patch({ mouse_mode: v })} + /> + patch({ invert_scroll: v })} + /> + patch({ inhibit_shortcuts: v })} + /> +
+); + +const InterfacePage: FC = ({ s, patch }) => { + // `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool, + // which itself defaults to true. + const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off"); + return ( +
+ patch({ stats_verbosity: v, show_stats: v !== "off" })} + /> + patch({ auto_wake: v })} + /> + patch({ library_enabled: v })} + /> + patch({ fullscreen_on_stream: v })} + /> +
+ ); +}; + +// ---------------------------------------------------------------------------------------- + export const SettingsSection: FC = () => { const [s, setS] = useState(null); + // null until the enumeration answers — the pickers show a loading state rather than briefly + // claiming this device has no endpoints. + const [devices, setDevices] = useState(null); + const [reading, setReading] = useState(true); + + const readDevices = (again: boolean) => { + setReading(true); + void (again ? refreshDevices() : listDevices()) + .then(setDevices) + .finally(() => setReading(false)); + }; useEffect(() => { void getSettings().then(setS); + // Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan + // takes seconds, and the rest of the screen must not wait for it. + readDevices(false); }, []); const patch = (p: Partial) => { @@ -83,164 +565,36 @@ export const SettingsSection: FC = () => { if (!s) return ; - // Mirrors `Settings::stats_verbosity`: an absent tier is a pre-tier store, which resolves - // through the legacy `show_stats` bool — and an absent bool is the client's serde default - // (true), so a file this plugin wrote before it carried either key reads as Normal, exactly - // as the stream sees it. - const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off"); - - const resIdx = Math.max( - 0, - RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), - ); - + const ctx: PageCtx = { s, patch, devices, reading, readDevices }; return ( - <> - - -
- ({ data: i, label }))} - selectedOption={resIdx} - onChange={(o) => { - const [w, h] = RESOLUTIONS[o.data as number]; - patch({ width: w, height: h }); - }} - /> -
-
-
- - -
- ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} - selectedOption={s.refresh_hz} - onChange={(o) => patch({ refresh_hz: o.data as number })} - /> -
-
-
- - -
- ({ data: x, label: renderScaleLabel(x) }))} - // Snap the stored value to the nearest preset so the dropdown always shows a match. - selectedOption={RENDER_SCALES.reduce((best, x) => - Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best, - )} - onChange={(o) => patch({ render_scale: o.data as number })} - /> -
-
-
- patch({ bitrate_kbps: v * 1000 })} - /> - - -
- ({ data: c, label: CODEC_LABELS[c] ?? c }))} - selectedOption={s.codec ?? "auto"} - onChange={(o) => patch({ codec: o.data as string })} - /> -
-
-
- patch({ gamepad_forwarding: v })} - /> - {(s.gamepad_forwarding ?? true) && ( - <> - - -
- ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} - selectedOption={s.gamepad} - onChange={(o) => patch({ gamepad: o.data as string })} - /> -
-
-
- {(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( - - )} - - )} - - -
- ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} - selectedOption={s.compositor} - onChange={(o) => patch({ compositor: o.data as string })} - /> -
-
-
- patch({ mic_enabled: v })} - /> - - -
- ({ data: t, label: STATS_LABELS[t] ?? t }))} - selectedOption={statsTier} - // Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a - // client too old for the tier still reads the on/off it understands. - onChange={(o) => { - const tier = o.data as string; - patch({ stats_verbosity: tier, show_stats: tier !== "off" }); - }} - /> -
-
-
- + , content: }, + { title: "Video", identifier: "video", icon: , content: }, + { title: "Audio", identifier: "audio", icon: , content: }, + { + title: "Controllers", + identifier: "controllers", + icon: , + content: , + }, + { + title: "Touch & mouse", + identifier: "pointer", + icon: , + content: , + }, + { + title: "Interface", + identifier: "interface", + icon: , + content: , + }, + ]} + /> ); }; diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 7c5c7cf1..9385c65c 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -15,8 +15,10 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa **Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings* elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the -client's **console home**, whose settings screen is one steppable list; the Decky plugin has a -smaller section of its own. The console home is part of the client — it is not the host's +client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings +tab covers the same store in the same groups and the same order, as a left rail of categories the +way SteamOS's own Settings looks. The console home is part of the +client — it is not the host's [web console](/docs/web-console). Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin @@ -43,8 +45,9 @@ and your client scales what it gets — see **Match window** — *default: off.* The stream mode follows your window instead, and each resize renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen -degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and console -home screens; not by Android or Decky. +degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console +home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are +always fullscreen, so it lands on native); not by Android. **Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display, @@ -69,8 +72,8 @@ link. The stops run 0.5× to 4×. The result is floored to an even size and capp **Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264. -**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an -Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on +**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or +an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the device has a hardware AV1 decoder; Android never offers PyroWave. @@ -84,7 +87,8 @@ needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. **Today only the Apple app actually advertises 4:4:4**, and only when its hardware decode probe passes — the Linux and Windows apps store the toggle but their session doesn't advertise the -capability yet, so it has no effect there. Android, Decky and the console home don't offer it. +capability yet, so it has no effect there. The console home and Decky offer the toggle; Android +doesn't. **Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual output. Advisory: a host without that backend quietly auto-detects instead. @@ -96,7 +100,7 @@ stereo. The count the host will really send comes back in the handshake, and you decoder from *that*, never from the request. What surround means differs by host: a **Linux** host claims a sink advertising exactly that many channels, so applications produce real surround, while a **Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1 -from a stereo endpoint is an upmix, not new channels. Offered everywhere except the Decky plugin. +from a stereo endpoint is an upmix, not new channels. Offered everywhere. **Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the @@ -110,16 +114,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind for the Communications stream category so the endpoint's processing engages, and on **Apple** and **Android** the platform's voice-processing mode. Turn it off if your microphone already runs its own processing, or if the canceller makes your voice sound thin. The row sits under the microphone -toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and -console-home clients; Decky has no toggle. What it can and can't fix is in +toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android, +console-home and Decky clients. What it can and can't fix is in [Why do I hear myself](/docs/echo). **Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream -audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the -**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV, -Android, Decky and the console home have none, and the Windows app has none and ignores a stored +audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the +**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad, +Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather -than silently snapping back to the default; the Mac shows it as "Unavailable device". +than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as +"(not connected)". Decky reads the endpoint list from the client's session binary, so a client +older than the two-binary split leaves these pickers on Automatic. ## Input @@ -159,8 +165,10 @@ which forwards *every* connected controller, each as its own player, on Linux, W console home. Pinning one restricts the session to that controller alone — single-player. The Android app has no such picker. -**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps only; Windows -spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On, Alt+Tab and the Windows key +**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home +and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it +matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming +Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key (Super on Linux) reach the host while the stream has input captured. Off, they act on this machine instead — what you want when the stream shares a screen with local work. Either way the chords come back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream @@ -179,17 +187,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this -toggle. The console home has no toggle — it offers wake as an explicit action on an offline host -instead — and the Decky plugin always sends a wake before a stream starts. See +toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own +before a stream starts whatever this setting says, so on a Deck it governs the client's connect +rather than the launch. The console home also offers wake as an explicit action on an offline host. +See [Wake-on-LAN](/docs/wake-on-lan). **Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse -a paired host's games and launch one directly; the Windows app still labels it experimental. There is -no toggle in the console home or in Decky. See [Game library](/docs/game-library). +a paired host's games and launch one directly; the Windows app still labels it experimental. The +console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the +plugin's own library browser works either way. See [Game library](/docs/game-library). **Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back -when you return to the host list. iPhone, iPad, Apple TV and Android have no equivalent. +when you return to the host list. The console home and Decky carry the row for the desktop client +that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV +and Android have no equivalent. ## Overlay @@ -210,8 +223,9 @@ stay global and **cannot be put in a settings profile**: vendor-ordered and falls back on its own; change it only when debugging, and note that `PUNKTFUNK_DECODER` overrides it ([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux, - Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has - more than one adapter. The Apple and Android apps have neither. + Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only + when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The + Apple and Android apps have neither. - **Speaker** and **Microphone** device pickers — this device's audio endpoints. - **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a