30d0d36efe
apple / swift (push) Successful in 1m4s
apple / screenshots (push) Successful in 5m26s
android / android (push) Successful in 3m27s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m16s
ci / rust (push) Successful in 4m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
ci / bench (push) Successful in 4m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m38s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m25s
Plugin self-update (no Decky store): CI publishes a per-channel manifest.json
({version, immutable per-version artifact, sha256}) beside the zip and bakes
update.json {channel, manifest} into the plugin. main.py `check_update` reads the
installed version from package.json (the value Decky reports — not plugin.json),
fetches the channel manifest, and the frontend shows an "Update to vX" button that
drives Decky Loader's own install RPC (root downloads + SHA-256-verifies + hot-reloads).
CI now stamps a plain-numeric semver (0.3.<run> canary / X.Y.Z stable) into
package.json — a -ciN suffix would mis-order under compare-versions.
Linux client: `--fullscreen` (plus SteamDeck/gamescope env fallback) enters GTK
fullscreen on stream start so Gaming-Mode chrome is hidden; native-mode resolution
falls back to the display's first monitor when the window isn't mapped yet (was
dropping to the 1080p floor — wrong on the Deck's 1280×800); add a confirmed
"Remove saved host" action (KnownHosts::remove_by_fp).
Docs: new docs/steam-deck.md (Decky install/pair/stream/self-update/troubleshooting),
wired into meta.json nav, and cross-linked from clients/install-client/channels. This
is the page docs.punktfunk.unom.io/docs/steam-deck — the website's download link
pointed at it before it existed; committing it makes that link resolve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
import {
|
||
ButtonItem,
|
||
Dropdown,
|
||
Field,
|
||
Focusable,
|
||
DialogButton,
|
||
ModalRoot,
|
||
Navigation,
|
||
PanelSection,
|
||
PanelSectionRow,
|
||
SliderField,
|
||
Spinner,
|
||
Tabs,
|
||
ToggleField,
|
||
showModal,
|
||
staticClasses,
|
||
} from "@decky/ui";
|
||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||
import {
|
||
Component,
|
||
CSSProperties,
|
||
ErrorInfo,
|
||
FC,
|
||
ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
FaTv,
|
||
FaSyncAlt,
|
||
FaLock,
|
||
FaLockOpen,
|
||
FaPlay,
|
||
FaArrowLeft,
|
||
FaDownload,
|
||
} from "react-icons/fa";
|
||
import {
|
||
discover,
|
||
getSettings,
|
||
pair,
|
||
setSettings,
|
||
checkUpdate,
|
||
Host,
|
||
StreamSettings,
|
||
UpdateInfo,
|
||
} from "./backend";
|
||
import { launchStream } from "./steam";
|
||
|
||
const ROUTE = "/punktfunk";
|
||
|
||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||
declare global {
|
||
interface Window {
|
||
DeckyBackend?: {
|
||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||
};
|
||
}
|
||
}
|
||
|
||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||
const INSTALL_TYPE_UPDATE = 2;
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||
// ----------------------------------------------------------------------------------------
|
||
class PluginErrorBoundary extends Component<
|
||
{ children: ReactNode },
|
||
{ error: Error | null }
|
||
> {
|
||
state: { error: Error | null } = { error: null };
|
||
|
||
static getDerivedStateFromError(error: Error) {
|
||
return { error };
|
||
}
|
||
|
||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||
// eslint-disable-next-line no-console
|
||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||
}
|
||
|
||
render() {
|
||
const { error } = this.state;
|
||
if (!error) return this.props.children;
|
||
return (
|
||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||
punktfunk couldn’t draw this view
|
||
</div>
|
||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||
The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from
|
||
Decky's plugin list, or update the plugin.
|
||
</div>
|
||
<div
|
||
style={{
|
||
opacity: 0.55,
|
||
fontFamily: "monospace",
|
||
fontSize: "0.8em",
|
||
wordBreak: "break-word",
|
||
}}
|
||
>
|
||
{String(error?.message ?? error)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
|
||
function useUpdate() {
|
||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||
useEffect(() => {
|
||
void checkUpdate(false)
|
||
.then(setInfo)
|
||
.catch(() => {});
|
||
}, []);
|
||
return info;
|
||
}
|
||
|
||
async function applyUpdate(info: UpdateInfo) {
|
||
try {
|
||
const backend = window.DeckyBackend;
|
||
if (backend?.callable) {
|
||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||
void backend.callable("utilities/install_plugin")(
|
||
info.artifact,
|
||
"punktfunk",
|
||
info.latest,
|
||
info.hash,
|
||
INSTALL_TYPE_UPDATE,
|
||
);
|
||
toaster.toast({
|
||
title: "punktfunk",
|
||
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
|
||
});
|
||
return;
|
||
}
|
||
} catch {
|
||
// fall through to the manual path
|
||
}
|
||
toaster.toast({
|
||
title: "punktfunk",
|
||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Discovery hook — shared by the QAM panel and the full page.
|
||
// ----------------------------------------------------------------------------------------
|
||
function useHosts() {
|
||
const [hosts, setHosts] = useState<Host[]>([]);
|
||
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<string | null>(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 (
|
||
<ModalRoot closeModal={closeModal}>
|
||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||
Pair with {host.name}
|
||
</div>
|
||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
fontSize: "2.2em",
|
||
letterSpacing: "0.4em",
|
||
textAlign: "center",
|
||
fontFamily: "monospace",
|
||
minHeight: "1.4em",
|
||
marginBottom: "0.6em",
|
||
}}
|
||
>
|
||
{pin.padEnd(4, "•")}
|
||
</div>
|
||
{error && (
|
||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<Focusable
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(3, 1fr)",
|
||
gap: "0.5em",
|
||
}}
|
||
>
|
||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||
{d}
|
||
</DialogButton>
|
||
))}
|
||
<DialogButton disabled={busy} onClick={back}>
|
||
⌫
|
||
</DialogButton>
|
||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||
0
|
||
</DialogButton>
|
||
<DialogButton
|
||
disabled={busy || pin.length !== 4}
|
||
onClick={submit}
|
||
>
|
||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||
</DialogButton>
|
||
</Focusable>
|
||
</ModalRoot>
|
||
);
|
||
};
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// 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<StreamSettings | null>(null);
|
||
|
||
useEffect(() => {
|
||
void getSettings().then(setS);
|
||
}, []);
|
||
|
||
const patch = (p: Partial<StreamSettings>) => {
|
||
setS((cur) => {
|
||
if (!cur) return cur;
|
||
const next = { ...cur, ...p };
|
||
void setSettings(next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||
|
||
const resIdx = Math.max(
|
||
0,
|
||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<Field
|
||
label="Resolution"
|
||
description="The host creates a virtual output at exactly this size"
|
||
childrenContainerWidth="max"
|
||
>
|
||
<Dropdown
|
||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||
selectedOption={resIdx}
|
||
onChange={(o) => {
|
||
const [w, h] = RESOLUTIONS[o.data as number];
|
||
patch({ width: w, height: h });
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||
<Dropdown
|
||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||
selectedOption={s.refresh_hz}
|
||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||
/>
|
||
</Field>
|
||
<SliderField
|
||
label="Bitrate"
|
||
description="Mbit/s · 0 = host default"
|
||
value={Math.round(s.bitrate_kbps / 1000)}
|
||
min={0}
|
||
max={150}
|
||
step={5}
|
||
showValue
|
||
valueSuffix=" Mbit/s"
|
||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||
/>
|
||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||
<Dropdown
|
||
rgOptions={GAMEPADS.map((g) => ({
|
||
data: g,
|
||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
||
}))}
|
||
selectedOption={s.gamepad}
|
||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||
/>
|
||
</Field>
|
||
<ToggleField
|
||
label="Stream microphone"
|
||
checked={s.mic_enabled}
|
||
onChange={(v) => patch({ mic_enabled: v })}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// One host row on the full page.
|
||
// ----------------------------------------------------------------------------------------
|
||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||
// 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 (
|
||
<Field
|
||
label={
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||
{host.name}
|
||
</span>
|
||
}
|
||
description={`${host.host}:${host.port}${
|
||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||
}`}
|
||
childrenContainerWidth="max"
|
||
>
|
||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||
{needsPair && (
|
||
<DialogButton
|
||
style={{ minWidth: "5em" }}
|
||
onClick={() =>
|
||
showModal(<PairModal host={host} onPaired={() => {}} />)
|
||
}
|
||
>
|
||
Pair
|
||
</DialogButton>
|
||
)}
|
||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||
Stream
|
||
</DialogButton>
|
||
</Focusable>
|
||
</Field>
|
||
);
|
||
};
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
|
||
// ----------------------------------------------------------------------------------------
|
||
|
||
// 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",
|
||
};
|
||
|
||
const HostsTab: FC<{
|
||
hosts: Host[];
|
||
scanning: boolean;
|
||
refresh: () => void;
|
||
}> = ({ hosts, scanning, refresh }) => (
|
||
<div style={tabScroll}>
|
||
<Field
|
||
label="Discover"
|
||
description={
|
||
scanning
|
||
? "Scanning the LAN…"
|
||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||
}
|
||
childrenContainerWidth="max"
|
||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||
>
|
||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||
{scanning ? (
|
||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||
) : (
|
||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||
)}
|
||
{scanning ? "Scanning…" : "Refresh"}
|
||
</DialogButton>
|
||
</Field>
|
||
|
||
{hosts.length === 0 && !scanning && (
|
||
<Field
|
||
focusable={false}
|
||
description="No punktfunk hosts found. Make sure a host is running on the same network."
|
||
>
|
||
No hosts found
|
||
</Field>
|
||
)}
|
||
{hosts.map((h) => (
|
||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const SettingsTab: FC = () => (
|
||
<div style={tabScroll}>
|
||
<SettingsSection />
|
||
</div>
|
||
);
|
||
|
||
const PunktfunkPage: FC = () => {
|
||
const { hosts, scanning, refresh } = useHosts();
|
||
const update = useUpdate();
|
||
const [tab, setTab] = useState("hosts");
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
marginTop: "40px",
|
||
height: "calc(100% - 40px)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
<Focusable
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "1em",
|
||
padding: "0 2.5em",
|
||
marginBottom: "0.4em",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<DialogButton
|
||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||
onClick={() => Navigation.NavigateBack()}
|
||
>
|
||
<FaArrowLeft />
|
||
</DialogButton>
|
||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||
punktfunk
|
||
</div>
|
||
{update?.update_available && (
|
||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||
Update v{update.latest}
|
||
</DialogButton>
|
||
)}
|
||
</Focusable>
|
||
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<Tabs
|
||
activeTab={tab}
|
||
onShowTab={(id: string) => setTab(id)}
|
||
autoFocusContents
|
||
tabs={[
|
||
{
|
||
id: "hosts",
|
||
title: "Hosts",
|
||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||
},
|
||
{
|
||
id: "settings",
|
||
title: "Settings",
|
||
content: <SettingsTab />,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||
// ----------------------------------------------------------------------------------------
|
||
const QamPanel: FC = () => {
|
||
const { hosts, scanning, refresh } = useHosts();
|
||
const update = useUpdate();
|
||
|
||
return (
|
||
<>
|
||
{update?.update_available && (
|
||
<PanelSection title="Update">
|
||
<PanelSectionRow>
|
||
<ButtonItem
|
||
layout="below"
|
||
onClick={() => applyUpdate(update)}
|
||
label={`v${update.current} → v${update.latest}`}
|
||
>
|
||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||
Update punktfunk
|
||
</ButtonItem>
|
||
</PanelSectionRow>
|
||
</PanelSection>
|
||
)}
|
||
|
||
<PanelSection title="punktfunk">
|
||
<PanelSectionRow>
|
||
<ButtonItem
|
||
layout="below"
|
||
onClick={() => {
|
||
Navigation.Navigate(ROUTE);
|
||
Navigation.CloseSideMenus();
|
||
}}
|
||
>
|
||
<FaTv style={{ marginRight: "0.5em" }} />
|
||
Open punktfunk
|
||
</ButtonItem>
|
||
</PanelSectionRow>
|
||
<PanelSectionRow>
|
||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||
{scanning ? (
|
||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||
) : (
|
||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||
)}
|
||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||
</ButtonItem>
|
||
</PanelSectionRow>
|
||
</PanelSection>
|
||
|
||
<PanelSection title="Hosts">
|
||
{hosts.length === 0 && !scanning && (
|
||
<PanelSectionRow>
|
||
<Field focusable={false}>No hosts found.</Field>
|
||
</PanelSectionRow>
|
||
)}
|
||
{hosts.map((h) => {
|
||
const needsPair = h.pair === "required" && !h.paired;
|
||
return (
|
||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||
<ButtonItem
|
||
layout="below"
|
||
onClick={() =>
|
||
needsPair
|
||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||
: startStream(h)
|
||
}
|
||
label={
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||
{h.name}
|
||
</span>
|
||
}
|
||
description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`}
|
||
>
|
||
{needsPair ? "Pair & Stream" : "Stream"}
|
||
</ButtonItem>
|
||
</PanelSectionRow>
|
||
);
|
||
})}
|
||
</PanelSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Full page behind the boundary — registered as the /punktfunk route.
|
||
const PunktfunkRoute: FC = () => (
|
||
<PluginErrorBoundary>
|
||
<PunktfunkPage />
|
||
</PluginErrorBoundary>
|
||
);
|
||
|
||
export default definePlugin(() => {
|
||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||
return {
|
||
name: "punktfunk",
|
||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||
titleView: <div className={staticClasses?.Title}>punktfunk</div>,
|
||
content: (
|
||
<PluginErrorBoundary>
|
||
<QamPanel />
|
||
</PluginErrorBoundary>
|
||
),
|
||
icon: <FaTv />,
|
||
onDismount() {
|
||
routerHook.removeRoute(ROUTE);
|
||
},
|
||
};
|
||
});
|