// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. import { ConfirmModal, DialogButton, Field, Focusable, ModalRoot, Navigation, Spinner, Tabs, showModal, staticClasses, } from "@decky/ui"; import { RowActions, actionButton, iconButton } from "./ui"; import { toaster } from "@decky/api"; import { CSSProperties, FC, useState } from "react"; import { FaArrowLeft, FaDownload, FaExternalLinkAlt, FaInfoCircle, FaLock, FaLockOpen, FaPen, FaPlay, FaPlus, FaSyncAlt, FaThLarge, FaTrashAlt, } from "react-icons/fa"; import { UpdateInfo, forgetHost, killStream } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { DOCS_URL, HostView, PinsApi, applyUpdate, checkForUpdatesNow, hasUpdate, mergeHosts, needsPair, pinIsOnline, resetAll, startStream, toHost, useHosts, usePins, useSavedHosts, useUpdate, } from "./hooks"; import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt"; import { GamePickerModal, storeLabel, streamPin } from "./library"; import { PairModal } from "./pair"; import { SettingsSection } from "./settings"; import { stopStream } from "./steam"; export const ROUTE = "/punktfunk"; // Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render // *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The // value is generous on purpose (and harmless where the tab area already insets); tune to taste. const SAFE_BOTTOM = "80px"; // Each tab is its own scroll area so long content is always reachable above the footer. const tabScroll: CSSProperties = { height: "100%", overflowY: "auto", padding: "0.5em 2.5em", paddingBottom: SAFE_BOTTOM, boxSizing: "border-box", }; // The one-line status under a host name: address, live presence, and trust state. function hostSubtitle(v: HostView): string { const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"]; if (needsPair(v)) { parts.push("pairing required"); } else if (v.paired) { parts.push("paired"); } else if (v.saved) { parts.push("trusted"); } return parts.join(" · "); } /** Confirm + forget a saved host, then refresh the list. */ function confirmForget(v: HostView, refresh: () => void): void { const selector = v.fp || `${v.addr}:${v.port}`; showModal( { const r = await forgetHost(selector); toaster.toast({ title: "Punktfunk", body: r.ok ? `Forgot ${v.name}` : mutationError(r), }); refresh(); }} />, ); } // ---------------------------------------------------------------------------------------- // Host details — everything we know, plus (for a saved host) rename / edit / forget. // ---------------------------------------------------------------------------------------- const HostDetailsModal: FC<{ host: HostView; onChanged: () => void; closeModal?: () => void; }> = ({ host, onChanged, closeModal }) => { const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet"; return (
{host.name}
{host.addr}:{host.port} {host.online ? "Online" : "Offline"} {host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"} {fp} } /> {host.saved && ( { closeModal?.(); showModal(); }} > Edit { closeModal?.(); confirmForget(host, onChanged); }} > Forget )}
); }; // ---------------------------------------------------------------------------------------- // One host row: status icon + address, details / pair / stream actions. // ---------------------------------------------------------------------------------------- const HostRow: FC<{ host: HostView; onChanged: () => void; onGames: () => void; }> = ({ host, onChanged, onGames }) => { const pair = needsPair(host); const h = toHost(host); return ( {pair ? : } {host.name} } description={hostSubtitle(host)} childrenContainerWidth="max" > showModal()} > {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen library browser, and controller nav has no hover tooltip to explain a bare icon. */} Games {pair && ( showModal()} > Pair )} pair ? showModal( startStream(h)} />) : startStream(h) } > Stream ); }; const HostsTab: FC<{ hosts: HostView[]; scanning: boolean; refresh: () => void; pins: PinsApi; clientUpdatePending: boolean; }> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
showModal()} > Add {scanning ? ( ) : ( )} {scanning ? "Scanning…" : "Refresh"} {hosts.length === 0 && !scanning && ( )} {hosts.map((h) => ( showModal( , ) } /> ))} {/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */} {pins.pins.length > 0 && ( <> {pins.pins.map((pin) => { const online = pinIsOnline(pin, hosts); return ( streamPin(pin, hosts.map(toHost), pins)} > Play pins.removePin(pin.host_fp, pin.game_id)} > Remove ); })} )}
); const SettingsTab: FC = () => (
); // ---------------------------------------------------------------------------------------- // About — plugin version + explicit update check, docs link, stream-exit help, force-stop, // and the destructive "reset everything" action. // ---------------------------------------------------------------------------------------- async function forceStopStream(): Promise { stopStream(); // ask Steam to end the "game" first (clean path) const res = await killStream(); // then the flatpak-level hammer for a wedged client toaster.toast({ title: "Punktfunk", body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.", }); } function confirmReset(refreshers: Array<() => void | Promise>): void { showModal( void resetAll(refreshers)} />, ); } const AboutTab: FC<{ update: UpdateInfo | null; checking: boolean; check: (force: boolean) => Promise; onReset: () => void; }> = ({ update, checking, check, onReset }) => (
void checkForUpdatesNow(check)} > {checking ? : "Check for updates"} {hasUpdate(update) && ( applyUpdate(update!, check)}> Update )} Navigation.NavigateToExternalWeb(DOCS_URL)} > Open void forceStopStream()}> Force-stop Reset
); const PunktfunkPage: FC = () => { const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); const { info: update, checking, check } = useUpdate(); const pins = usePins(); const [tab, setTab] = useState("hosts"); const hosts = mergeHosts(saved, discovered); // A host action (pair/add/edit/forget) can change either store, so refresh both. const refreshHosts = () => { void refreshDiscovered(); void refreshSaved(); }; return (
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */} Navigation.NavigateBack()}>
Punktfunk
{/* Two things fight each other on an L1/R1 tab switch: 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform. 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks up and pans the whole page — the "screen jumps right, then animates back" glitch. Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while focus is anywhere inside — including the tab strip); after a switch, focus stays on the strip and Down enters the content, which is how Steam's own tabbed pages behave. The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
setTab(id)} tabs={[ { id: "hosts", title: "Hosts", content: ( ), }, { id: "settings", title: "Settings", content: , }, { id: "about", title: "About", content: ( confirmReset([refreshHosts, pins.refresh])} /> ), }, ]} />
); }; // Full page behind the boundary — registered as the /punktfunk route. export const PunktfunkRoute: FC = () => ( );