import { ButtonItem, Dropdown, Field, Focusable, DialogButton, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Spinner, ToggleField, showModal, staticClasses, } from "@decky/ui"; import { definePlugin, routerHook, toaster } from "@decky/api"; import { FC, useCallback, useEffect, useState } from "react"; import { FaTv, FaSyncAlt, FaLock, FaLockOpen, FaPlay, FaArrowLeft, } from "react-icons/fa"; import { discover, getSettings, pair, setSettings, Host, StreamSettings, } from "./backend"; import { launchStream } from "./steam"; const ROUTE = "/punktfunk"; // ---------------------------------------------------------------------------------------- // Discovery hook — shared by the QAM panel and the full page. // ---------------------------------------------------------------------------------------- function useHosts() { const [hosts, setHosts] = useState([]); const [scanning, setScanning] = useState(false); const refresh = useCallback(async () => { setScanning(true); try { setHosts(await discover()); } catch (e) { toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` }); } finally { setScanning(false); } }, []); useEffect(() => { void refresh(); }, [refresh]); return { hosts, scanning, refresh }; } async function startStream(h: Host) { try { await launchStream(h.host, h.port); Navigation.CloseSideMenus(); toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` }); } catch (e) { toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` }); } } // ---------------------------------------------------------------------------------------- // PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode). // The host displays the PIN after the operator arms pairing; the user enters it here. // ---------------------------------------------------------------------------------------- const PairModal: FC<{ host: Host; closeModal?: () => void; onPaired: () => void; }> = ({ host, closeModal, onPaired }) => { const [pin, setPin] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d)); const back = () => setPin((p) => p.slice(0, -1)); const submit = async () => { setBusy(true); setError(null); try { const res = await pair(host.host, host.port, pin, "Steam Deck"); if (res.ok) { toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` }); onPaired(); closeModal?.(); } else { setError(res.error ?? "pairing failed"); setPin(""); } } catch (e) { setError(String(e)); } finally { setBusy(false); } }; return (
Pair with {host.name}
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
{pin.padEnd(4, "•")}
{error && (
{error}
)} {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => ( press(d)}> {d} ))} press("0")}> 0 {busy ? : "Pair"}
); }; // ---------------------------------------------------------------------------------------- // Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON. // ---------------------------------------------------------------------------------------- const RESOLUTIONS: [number, number, string][] = [ [0, 0, "Native display"], [1280, 720, "1280 × 720"], [1920, 1080, "1920 × 1080"], [2560, 1440, "2560 × 1440"], ]; const REFRESH = [0, 30, 60, 90, 120]; const GAMEPADS = ["auto", "xbox360", "dualsense"]; const SettingsSection: FC = () => { const [s, setS] = useState(null); useEffect(() => { void getSettings().then(setS); }, []); const patch = (p: Partial) => { setS((cur) => { if (!cur) return cur; const next = { ...cur, ...p }; void setSettings(next); return next; }); }; if (!s) return ; const resIdx = Math.max( 0, RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), ); 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 })} /> patch({ bitrate_kbps: v * 1000 })} /> ({ data: g, label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense", }))} selectedOption={s.gamepad} onChange={(o) => patch({ gamepad: o.data as string })} /> patch({ mic_enabled: v })} /> ); }; // ---------------------------------------------------------------------------------------- // One host row on the full page. // ---------------------------------------------------------------------------------------- const HostRow: FC<{ host: Host }> = ({ host }) => { const pairRequired = host.pair === "required"; return ( {pairRequired ? : } {host.name} } description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`} childrenContainerWidth="max" > {pairRequired && ( showModal( {}} />) } > Pair )} startStream(host)}> Stream ); }; // ---------------------------------------------------------------------------------------- // The fullscreen page (registered as the /punktfunk route). // ---------------------------------------------------------------------------------------- const PunktfunkPage: FC = () => { const { hosts, scanning, refresh } = useHosts(); return (
Navigation.NavigateBack()} >
punktfunk
{scanning ? ( ) : ( )} {scanning ? "Scanning…" : "Refresh"}
Hosts
{hosts.length === 0 && !scanning && ( No hosts discovered on the LAN. )} {hosts.map((h) => ( ))}
Stream settings
); }; // ---------------------------------------------------------------------------------------- // QAM panel — quick status + entry into the full page + one-tap stream for known hosts. // ---------------------------------------------------------------------------------------- const QamPanel: FC = () => { const { hosts, scanning, refresh } = useHosts(); return ( <> { Navigation.Navigate(ROUTE); Navigation.CloseSideMenus(); }} > Open punktfunk {scanning ? ( ) : ( )} {scanning ? "Scanning…" : "Refresh hosts"} {hosts.length === 0 && !scanning && ( No hosts found. )} {hosts.map((h) => { const pairRequired = h.pair === "required"; return ( pairRequired ? showModal( startStream(h)} />) : startStream(h) } label={ {pairRequired ? : } {h.name} } description={`${h.host}:${h.port}`} > {pairRequired ? "Pair & Stream" : "Stream"} ); })} ); }; export default definePlugin(() => { routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true }); return { name: "punktfunk", titleView:
punktfunk
, content: , icon: , onDismount() { routerHook.removeRoute(ROUTE); }, }; });