57ae00a9c8
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
GTK Linux client: - hosts/library: clicking a card was dead — the handler was on FlowBoxChild::activate (never emitted on click); bridge child-activated → child.activate() on the FlowBox (ui_hosts, ui_library). - stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were dropped because the key controller sat on the overlay, which loses focus to the header back button after nav.push+fullscreen — move it to the window and remove it on teardown. - video: a mid-session VAAPI decode error rebuilt a software decoder but never requested a keyframe, so under the infinite GOP the picture stayed gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware decoder, and demote to software only after repeated failures. - stream: fix a per-session Capture↔overlay reference cycle that leaked the overlay subtree + the Arc<NativeClient> on every session end — hold the overlay weakly. - stream: accumulate the fractional wheel remainder so precision-scroll (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped. - gamepad library: keep the launcher smooth on the Deck — freeze the aurora and trim the visible card range (fewer 3D offscreen passes) on low-power. - gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to diagnose an empty controller list on the Deck. - cli: --connect host:<badport> silently did nothing; default to 9777 + warn. - css: add the missing .pf-neutral pill rule; fix the clipped most-recent accent (inset outline instead of a corner-clipped box-shadow bar). Decky plugin: - surface the on-screen library browser: label the host-row Games button. - fix silent pin data-loss — the detached Games modal captured a frozen pins array, so pinning a second game clobbered the first; mirror pins in a ref and track the modal's pinned ids locally for a live label. - route pair-required hosts through the pairing modal from the fullscreen Stream button (parity with the QAM panel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
// 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 (
|
||
<ModalRoot closeModal={closeModal}>
|
||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||
{host.name}
|
||
</div>
|
||
<Field focusable={false} label="Address">
|
||
{host.host}:{host.port}
|
||
</Field>
|
||
<Field focusable={false} label="Protocol">
|
||
{host.proto || "unknown"}
|
||
</Field>
|
||
<Field focusable={false} label="Pairing policy">
|
||
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
|
||
</Field>
|
||
<Field focusable={false} label="This Deck">
|
||
{host.paired ? "Paired" : "Not paired yet"}
|
||
</Field>
|
||
<Field
|
||
focusable={false}
|
||
label="Certificate fingerprint (SHA-256)"
|
||
description={
|
||
<span
|
||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||
>
|
||
{fp}
|
||
</span>
|
||
}
|
||
/>
|
||
</ModalRoot>
|
||
);
|
||
};
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// 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 (
|
||
<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", justifyContent: "flex-end" }}>
|
||
<DialogButton
|
||
style={iconButton}
|
||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||
>
|
||
<FaInfoCircle />
|
||
</DialogButton>
|
||
{/* 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. */}
|
||
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
|
||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||
Games
|
||
</DialogButton>
|
||
{needsPair && (
|
||
<DialogButton
|
||
style={{ ...actionButton, minWidth: "5em" }}
|
||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||
>
|
||
Pair
|
||
</DialogButton>
|
||
)}
|
||
<DialogButton
|
||
style={actionButton}
|
||
onClick={() =>
|
||
needsPair
|
||
? showModal(
|
||
<PairModal host={host} onPaired={() => startStream(host)} />,
|
||
)
|
||
: startStream(host)
|
||
}
|
||
>
|
||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||
Stream
|
||
</DialogButton>
|
||
</Focusable>
|
||
</Field>
|
||
);
|
||
};
|
||
|
||
const HostsTab: FC<{
|
||
hosts: Host[];
|
||
scanning: boolean;
|
||
refresh: () => void;
|
||
pins: PinsApi;
|
||
clientUpdatePending: boolean;
|
||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||
<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={{ ...actionButton, 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}
|
||
label="No hosts found"
|
||
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
|
||
/>
|
||
)}
|
||
{hosts.map((h) => (
|
||
<HostRow
|
||
key={h.fp || `${h.host}:${h.port}`}
|
||
host={h}
|
||
onPaired={refresh}
|
||
onGames={() =>
|
||
showModal(
|
||
<GamePickerModal host={h} pins={pins} clientUpdatePending={clientUpdatePending} />,
|
||
)
|
||
}
|
||
/>
|
||
))}
|
||
|
||
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
|
||
{pins.pins.length > 0 && (
|
||
<>
|
||
<Field
|
||
focusable={false}
|
||
label="Pinned games"
|
||
description="One-tap streams — they also live in the quick-access menu"
|
||
bottomSeparator="standard"
|
||
/>
|
||
{pins.pins.map((pin) => {
|
||
const { online } = resolvePinHost(pin, hosts);
|
||
return (
|
||
<Field
|
||
key={`${pin.host_fp}:${pin.game_id}`}
|
||
label={pin.title}
|
||
description={`${storeLabel(pin.store)} · ${pin.host_name}${
|
||
online ? "" : " · offline?"
|
||
}${pin.paired ? "" : " · pairing required"}`}
|
||
childrenContainerWidth="max"
|
||
>
|
||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
|
||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||
Play
|
||
</DialogButton>
|
||
<DialogButton
|
||
style={{ ...actionButton, minWidth: "5em" }}
|
||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||
>
|
||
Remove
|
||
</DialogButton>
|
||
</Focusable>
|
||
</Field>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const SettingsTab: FC = () => (
|
||
<div style={tabScroll}>
|
||
<SettingsSection />
|
||
</div>
|
||
);
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
|
||
// ----------------------------------------------------------------------------------------
|
||
async function forceStopStream(): Promise<void> {
|
||
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<UpdateInfo | null>;
|
||
}> = ({ update, checking, check }) => (
|
||
<div style={tabScroll}>
|
||
<Field
|
||
label="Version"
|
||
description={
|
||
update
|
||
? `v${update.current}${
|
||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||
}`
|
||
: "…"
|
||
}
|
||
childrenContainerWidth="max"
|
||
>
|
||
<DialogButton
|
||
style={{ ...actionButton, minWidth: "11em" }}
|
||
disabled={checking}
|
||
onClick={() => void checkForUpdatesNow(check)}
|
||
>
|
||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||
</DialogButton>
|
||
</Field>
|
||
{hasUpdate(update) && (
|
||
<Field
|
||
label={
|
||
update!.update_available
|
||
? `Plugin update — v${update!.latest}${
|
||
update!.client_update_available ? " + client" : ""
|
||
}`
|
||
: "Client update available"
|
||
}
|
||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||
childrenContainerWidth="max"
|
||
>
|
||
<DialogButton
|
||
style={{ ...actionButton, minWidth: "9em" }}
|
||
onClick={() => applyUpdate(update!, check)}
|
||
>
|
||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||
Update
|
||
</DialogButton>
|
||
</Field>
|
||
)}
|
||
<Field
|
||
label="Setup guide"
|
||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||
childrenContainerWidth="max"
|
||
>
|
||
<DialogButton
|
||
style={{ ...actionButton, minWidth: "8em" }}
|
||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||
>
|
||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||
Open
|
||
</DialogButton>
|
||
</Field>
|
||
<Field
|
||
focusable={false}
|
||
label="Leaving a stream"
|
||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||
/>
|
||
<Field
|
||
label="Stream stuck?"
|
||
description="Force-stop the stream client if a session wedges"
|
||
childrenContainerWidth="max"
|
||
>
|
||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||
Force-stop
|
||
</DialogButton>
|
||
</Field>
|
||
</div>
|
||
);
|
||
|
||
const PunktfunkPage: FC = () => {
|
||
const { hosts, scanning, refresh } = useHosts();
|
||
const { info: update, checking, check } = useUpdate();
|
||
const pins = usePins();
|
||
const [tab, setTab] = useState("hosts");
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
marginTop: "40px",
|
||
height: "calc(100% - 40px)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||
<Focusable
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "1em",
|
||
padding: "0 2.5em",
|
||
marginBottom: "0.4em",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||
<FaArrowLeft />
|
||
</DialogButton>
|
||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||
Punktfunk
|
||
</div>
|
||
</Focusable>
|
||
|
||
{/* 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. */}
|
||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||
<Tabs
|
||
activeTab={tab}
|
||
onShowTab={(id: string) => setTab(id)}
|
||
autoFocusContents
|
||
tabs={[
|
||
{
|
||
id: "hosts",
|
||
title: "Hosts",
|
||
content: (
|
||
<HostsTab
|
||
hosts={hosts}
|
||
scanning={scanning}
|
||
refresh={refresh}
|
||
pins={pins}
|
||
clientUpdatePending={!!update?.client_update_available}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: "settings",
|
||
title: "Settings",
|
||
content: <SettingsTab />,
|
||
},
|
||
{
|
||
id: "about",
|
||
title: "About",
|
||
content: <AboutTab update={update} checking={checking} check={check} />,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Full page behind the boundary — registered as the /punktfunk route.
|
||
export const PunktfunkRoute: FC = () => (
|
||
<PluginErrorBoundary>
|
||
<PunktfunkPage />
|
||
</PluginErrorBoundary>
|
||
);
|