feat(plugin): inject DeckyVault section into library app-details page
This commit is contained in:
@@ -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 (
|
||||
<PanelSectionRow>
|
||||
<div
|
||||
onClick={() => 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" }}
|
||||
>
|
||||
<div className={staticClasses.Text} style={{ display: "flex", justifyContent: "space-between", fontSize: "13px" }}>
|
||||
<strong>{e.fpsAvg} FPS avg</strong>
|
||||
<span style={{ opacity: 0.7 }}>{label}</span>
|
||||
</div>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, marginTop: 2 }}>
|
||||
{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}` : ""}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.7, marginTop: 6, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 6 }}>
|
||||
<div>By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}</div>
|
||||
<div>{settingsCount} settings</div>
|
||||
{e.osVersion && <div>OS: {e.osVersion}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) {
|
||||
const [data, setData] = useState<PluginGameResponse | null>(null)
|
||||
const [devices, setDevices] = useState<PluginDeviceRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [device, setDevice] = useState<string>(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 (
|
||||
<PanelSection title="DeckyVault">
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.6 }}>Loading DeckyVault…</div>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<PanelSection title="DeckyVault">
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7 }}>
|
||||
<FaTimes /> Not in DeckyVault yet. Open <strong>{title}</strong> on{" "}
|
||||
<a href={`${baseUrl}/games`}>deckyvault.xyz</a> to add it.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection title="DeckyVault">
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<FaCheck style={{ color: "#2ecc71" }} /> In DeckyVault
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
|
||||
{/* Est FPS */}
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<FaChartLine /> Est FPS
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{data.estFps ? (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
|
||||
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
|
||||
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
) : (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
|
||||
No entries for this device yet — be the first: open the DeckyVault plugin and record.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{/* Device switcher */}
|
||||
<PanelSectionRow>
|
||||
<DropdownItem
|
||||
label="Device"
|
||||
rgOptions={deviceOptions}
|
||||
selectedOption={device}
|
||||
onChange={(opt) => setDevice(opt.data as string)}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
|
||||
{/* Top entries */}
|
||||
{data.topEntries.length > 0 && (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Top entries
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
@@ -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: <div className={staticClasses.Title}>DeckyVault</div>,
|
||||
@@ -206,6 +215,7 @@ export default definePlugin(() => {
|
||||
icon: <DeckyVaultIcon />,
|
||||
alwaysRender: true,
|
||||
onDismount() {
|
||||
try { routerHook.removePatch("/library/app/:appid", libraryAppPatch) } catch (e) { console.error("[DeckyVault] removePatch failed:", e) }
|
||||
console.log("[DeckyVault] Plugin unloading")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>[], 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,
|
||||
<LibraryAppPanel
|
||||
appId={overview?.appid}
|
||||
title={overview?.display_name ?? ""}
|
||||
hardwareSlug={panelProps.hardwareSlug}
|
||||
baseUrl={panelProps.baseUrl}
|
||||
/>,
|
||||
)
|
||||
} 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user