// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. import { DialogButton, Field, Focusable, ModalRoot, Navigation, Spinner, Tabs, showModal, staticClasses, } from "@decky/ui"; import { toaster } from "@decky/api"; import { CSSProperties, FC, useState } from "react"; import { FaArrowLeft, FaDownload, FaExternalLinkAlt, FaInfoCircle, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaThLarge, } from "react-icons/fa"; import { Host, UpdateInfo, killStream } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { DOCS_URL, PinsApi, applyUpdate, checkForUpdatesNow, hasUpdate, resolvePinHost, startStream, useHosts, usePins, useUpdate, } from "./hooks"; 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", }; // DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a // screen-wide button. Size action buttons to their content instead (right-aligned by the // Field's children container). const actionButton: CSSProperties = { width: "fit-content", minWidth: "6em", flexShrink: 0, }; // Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or // the zero padding collapses it to the icon's line height. const iconButton: CSSProperties = { width: "40px", minWidth: "40px", height: "40px", padding: 0, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", }; // ---------------------------------------------------------------------------------------- // Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check // against the host's own log / web console before trusting it. // ---------------------------------------------------------------------------------------- const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({ host, closeModal, }) => { const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised"; return (
{host.name}
{host.host}:{host.port} {host.proto || "unknown"} {host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"} {host.paired ? "Paired" : "Not paired yet"} {fp} } />
); }; // ---------------------------------------------------------------------------------------- // One host row: status icon + address, details / pair / stream actions. // ---------------------------------------------------------------------------------------- const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({ host, onPaired, onGames, }) => { // The host's policy is `pair=required`, but if THIS device is already paired we don't need to // pair again — show it as trusted and go straight to Stream. const needsPair = host.pair === "required" && !host.paired; return ( {needsPair ? : } {host.name} } description={`${host.host}:${host.port}${ needsPair ? " · pairing required" : host.paired ? " · paired" : "" }`} 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 {needsPair && ( showModal()} > Pair )} needsPair ? showModal( startStream(host)} />, ) : startStream(host) } > Stream ); }; const HostsTab: FC<{ hosts: Host[]; scanning: boolean; refresh: () => void; pins: PinsApi; clientUpdatePending: boolean; }> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
{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 } = resolvePinHost(pin, hosts); return ( streamPin(pin, hosts, 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. // ---------------------------------------------------------------------------------------- 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.", }); } const AboutTab: FC<{ update: UpdateInfo | null; checking: boolean; check: (force: boolean) => Promise; }> = ({ update, checking, check }) => (
void checkForUpdatesNow(check)} > {checking ? : "Check for updates"} {hasUpdate(update) && ( applyUpdate(update!, check)} > Update )} Navigation.NavigateToExternalWeb(DOCS_URL)} > Open void forceStopStream()}> Force-stop
); const PunktfunkPage: FC = () => { const { hosts, scanning, refresh } = useHosts(); const { info: update, checking, check } = useUpdate(); const pins = usePins(); const [tab, setTab] = useState("hosts"); return (
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */} Navigation.NavigateBack()}>
Punktfunk
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always live in a clipped flex box; match that. */}
setTab(id)} autoFocusContents tabs={[ { id: "hosts", title: "Hosts", content: ( ), }, { id: "settings", title: "Settings", content: , }, { id: "about", title: "About", content: , }, ]} />
); }; // Full page behind the boundary — registered as the /punktfunk route. export const PunktfunkRoute: FC = () => ( );