diff --git a/plugins/decky-vault/src/components/LibraryAppPanel.tsx b/plugins/decky-vault/src/components/LibraryAppPanel.tsx new file mode 100644 index 0000000..c4176b7 --- /dev/null +++ b/plugins/decky-vault/src/components/LibraryAppPanel.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react" +import { PanelSection, PanelSectionRow, DropdownItem, staticClasses } from "@decky/ui" +import { FaCheck, FaTimes, FaChartLine } from "react-icons/fa" +import { + fetchPluginGame, + fetchPluginDevices, + setPluginApiBaseUrl, + type PluginGameResponse, + type PluginDeviceRow, + type PluginEntry, +} from "../lib/plugin-api" + +interface Props { + appId: number + title: string + hardwareSlug: string | null // detected device + baseUrl: string +} + +function EntryCard({ e }: { e: PluginEntry }) { + const [expanded, setExpanded] = useState(false) + const settingsCount = Array.isArray(e.settingsJson) + ? (e.settingsJson as Array<{ settings: unknown[] }>).reduce((s, c) => s + (c.settings?.length ?? 0), 0) + : 0 + const label = e.isPinned ? "Pinned" : e.upvotes > 0 ? `${e.upvotes}👍` : "Recent" + return ( + +
setExpanded((v) => !v)} + style={{ padding: "8px 10px", borderRadius: "8px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.10)", cursor: "pointer" }} + > +
+ {e.fpsAvg} FPS avg + {label} +
+
+ {e.fpsLow ?? "—"} low · {e.fpsOnePercentLow ?? "—"} 1% · {e.fpsHigh ?? "—"} high + {e.tdpWatts ? ` · ${e.tdpWatts}W` : ""} + {e.upscalerType && e.upscalerType !== "none" ? ` · ${e.upscalerType}` : ""} + {e.protonVersion ? ` · Proton ${e.protonVersion}` : ""} +
+ {expanded && ( +
+
By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}
+
{settingsCount} settings
+ {e.osVersion &&
OS: {e.osVersion}
} +
+ )} +
+
+ ) +} + +export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) { + const [data, setData] = useState(null) + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [device, setDevice] = useState(hardwareSlug ?? "") // "" = all devices + + useEffect(() => { + setPluginApiBaseUrl(baseUrl) + let cancelled = false + async function load() { + setLoading(true) + const d = await fetchPluginGame(appId, device || null, 3) + if (!cancelled) { setData(d); setLoading(false) } + const devs = await fetchPluginDevices(appId) + if (!cancelled) setDevices(devs) + } + load() + return () => { cancelled = true } + }, [appId, device, baseUrl]) + + if (loading) { + return ( + + +
Loading DeckyVault…
+
+
+ ) + } + + const deviceOptions = [ + { label: "All devices", data: "" }, + ...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []), + ...devices + .filter((d) => d.slug !== hardwareSlug) + .map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })), + ] + + if (!data || !data.game) { + return ( + + +
+ Not in DeckyVault yet. Open {title} on{" "} + deckyvault.xyz to add it. +
+
+
+ ) + } + + return ( + + +
+ In DeckyVault +
+
+ + {/* Est FPS */} + +
+ Est FPS +
+
+ {data.estFps ? ( + +
+ {data.estFps.avg} avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high + · {data.estFps.count} entries +
+
+ ) : ( + +
+ No entries for this device yet — be the first: open the DeckyVault plugin and record. +
+
+ )} + + {/* Device switcher */} + + setDevice(opt.data as string)} + /> + + + {/* Top entries */} + {data.topEntries.length > 0 && ( + <> + +
+ Top entries +
+
+ {data.topEntries.map((e) => )} + + )} +
+ ) +} \ No newline at end of file diff --git a/plugins/decky-vault/src/index.tsx b/plugins/decky-vault/src/index.tsx index 1b3613a..6f02b64 100644 --- a/plugins/decky-vault/src/index.tsx +++ b/plugins/decky-vault/src/index.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react" import { PanelSection, PanelSectionRow, @@ -5,9 +6,11 @@ import { } from "@decky/ui" import { definePlugin, + routerHook, } from "@decky/api" import { FaChartLine } from "react-icons/fa" import MainPanel from "./components/main-panel" +import { registerLibraryAppPatch, setLibraryAppPanelProps } from "./patches/LibraryApp" import { useSettings, useSession, useGameDetection } from "./lib/store" import { readAndParseMangohudLog, @@ -24,6 +27,10 @@ import { function Content() { const { settings, updateSetting, loaded } = useSettings() + // keep the library panel's props in sync with settings + useEffect(() => { + setLibraryAppPanelProps({ hardwareSlug: settings.hardwareSlug, baseUrl: settings.baseUrl }) + }, [settings.hardwareSlug, settings.baseUrl]) const { recordingState, session, @@ -199,6 +206,8 @@ function DeckyVaultIcon() { } export default definePlugin(() => { + const libraryAppPatch = registerLibraryAppPatch() + return { name: "DeckyVault", titleView:
DeckyVault
, @@ -206,6 +215,7 @@ export default definePlugin(() => { icon: , alwaysRender: true, onDismount() { + try { routerHook.removePatch("/library/app/:appid", libraryAppPatch) } catch (e) { console.error("[DeckyVault] removePatch failed:", e) } console.log("[DeckyVault] Plugin unloading") }, } diff --git a/plugins/decky-vault/src/patches/LibraryApp.tsx b/plugins/decky-vault/src/patches/LibraryApp.tsx new file mode 100644 index 0000000..3648fd3 --- /dev/null +++ b/plugins/decky-vault/src/patches/LibraryApp.tsx @@ -0,0 +1,93 @@ +import { + afterPatch, + appDetailsClasses, + createReactTreePatcher, + findInReactTree, +} from "@decky/ui" +import { routerHook } from "@decky/api" +import type { ReactElement } from "react" +import LibraryAppPanel from "../components/LibraryAppPanel" + +// Mirror of HLTB-for-Deck's patchAppPage, guarded so a Steam UI change +// degrades to "section not shown" instead of crashing Steam. +function isSteamGameType(appType: number) { + return appType === 1 || appType === 8 // Game, Demo +} + +// These are supplied by the plugin at registration time (read from settings). +let panelProps: { hardwareSlug: string | null; baseUrl: string } = { hardwareSlug: null, baseUrl: "https://deckyvault.xyz" } +export function setLibraryAppPanelProps(p: { hardwareSlug: string | null; baseUrl: string }) { + panelProps = p +} + +export function registerLibraryAppPatch() { + return routerHook.addPatch("/library/app/:appid", (routerTree: any) => { + try { + const routeProps = findInReactTree(routerTree, (x: any) => x?.renderFunc) + if (!routeProps) return routerTree + + const patchHandler = createReactTreePatcher( + [ + (tree: any) => { + const child = findInReactTree( + tree, + (x: any) => x?.props?.children?.props?.overview, + ) + if (!child) return null + const overview = child.props.children.props.overview + if (!overview || !isSteamGameType(overview.app_type)) return null + return child.props.children + }, + ], + (_: Record[], ret: ReactElement) => { + try { + const container = findInReactTree( + ret, + (x: any) => + Array.isArray(x?.props?.children) && + x?.props?.className?.includes(appDetailsClasses.InnerContainer), + ) + const arr = container?.props?.children + if (!Array.isArray(arr)) { + console.debug("[DeckyVault] app-details: no splicable container (non-game page?)") + return ret + } + const idx = arr.findIndex((child: any) => { + const p = child?.props + return ( + p?.childFocusDisabled !== undefined && + p?.navRef !== undefined && + p?.children?.props?.details !== undefined && + p?.children?.props?.overview !== undefined && + p?.children?.props?.bFastRender !== undefined + ) + }) + if (idx > -1) { + const overview = arr[idx]?.props?.children?.props?.overview + arr.splice( + idx, + 0, + , + ) + } else { + console.debug("[DeckyVault] app-details: splicing anchor not found") + } + } catch (err) { + console.error("[DeckyVault] app-details splice failed:", err) + } + return ret + }, + ) + + afterPatch(routeProps, "renderFunc", patchHandler) + } catch (err) { + console.error("[DeckyVault] library patch failed (degraded):", err) + } + return routerTree + }) +} \ No newline at end of file