Files
punktfunk-plugin-playnite/ui/src/App.tsx
T
enricobuehler 6694be9848
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped
feat: Playnite library sync plugin + exporter
A two-part Punktfunk plugin that syncs the Playnite library into the host
game library as the `playnite` provider:

- exporter/ — a minimal C# Playnite GenericPlugin ("Punktfunk Sync") that
  writes punktfunk-library.json on every library change. Reading Playnite's
  live-locked LiteDB from outside isn't robust, so this adapter runs inside
  Playnite. Builds net462 via reference assemblies; packaged as a .pext.
- src/ — the TS plugin (on @punktfunk/host, rom-manager shape): watches the
  export, embeds covers as data URLs, reconciles via
  PUT /library/provider/playnite, with a console-hosted web UI + standalone
  fallback + CLI. Launch = playnite://playnite/start/<id>, run by the host
  in the interactive session so Playnite performs the real launch.

Verified locally: backend tsc + 23 tests + biome clean, SPA build, C#
exporter build + .pext pack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:35:35 +02:00

425 lines
10 KiB
TypeScript

import {
AlertTriangle,
CheckCircle2,
FolderSearch,
Gamepad2,
Library,
Loader2,
RefreshCw,
Save,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import {
type Config,
getConfig,
putConfig,
runSync,
type Status,
useStatusStream,
} from "./api.js";
const relTime = (ms?: number): string => {
if (!ms) return "never";
const s = Math.round((Date.now() - ms) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.round(s / 60)}m ago`;
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
return `${Math.round(s / 86400)}d ago`;
};
function Card({
title,
icon,
children,
right,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
right?: ReactNode;
}) {
return (
<section className="rounded-[var(--radius)] border border-border bg-card p-5">
<header className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-muted uppercase">
<span className="text-brand">{icon}</span>
{title}
</h2>
{right}
</header>
{children}
</section>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
<span>
<span className="block text-sm">{label}</span>
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
>
<span
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</label>
);
}
const inputCls =
"w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-brand";
export function App() {
const status = useStatusStream();
const [config, setConfig] = useState<Config>();
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<string>();
useEffect(() => {
getConfig()
.then(setConfig)
.catch((e) => setMsg(String(e)));
}, []);
const patch = (p: Partial<Config>) =>
setConfig((c) => (c ? { ...c, ...p } : c));
const save = async () => {
if (!config) return;
setSaving(true);
setMsg(undefined);
try {
setConfig(await putConfig(config));
setMsg("Saved — re-syncing with the new settings.");
} catch (e) {
setMsg(String(e));
} finally {
setSaving(false);
}
};
const sync = async () => {
setSyncing(true);
setMsg(undefined);
try {
await runSync();
setMsg("Sync requested.");
} catch (e) {
setMsg(String(e));
} finally {
setSyncing(false);
}
};
return (
<div className="mx-auto max-w-3xl px-5 py-8">
<header className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand text-brand-fg">
<Gamepad2 size={22} />
</span>
<div>
<h1 className="text-xl font-semibold">Playnite</h1>
<p className="text-xs text-muted">
Sync your Playnite library into the Punktfunk game library.
</p>
</div>
</header>
{status ? (
<div className="grid gap-5">
<Connection status={status} />
<LibrarySummary status={status} onSync={sync} syncing={syncing} />
{config && (
<Settings
config={config}
patch={patch}
onSave={save}
saving={saving}
/>
)}
</div>
) : (
<p className="text-sm text-muted">Loading</p>
)}
{msg && (
<p className="mt-5 rounded-lg border border-border bg-surface px-4 py-2 text-sm text-muted">
{msg}
</p>
)}
</div>
);
}
function Connection({ status }: { status: Status }) {
const found = Boolean(status.location.file);
return (
<Card title="Playnite connection" icon={<FolderSearch size={16} />}>
{found ? (
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 shrink-0 text-ok" size={18} />
<div className="min-w-0 text-sm">
<p>
Exporter connected updated{" "}
<span className="text-fg">
{relTime(status.location.mtime ?? undefined)}
</span>
.
</p>
<p className="mt-1 truncate font-mono text-xs text-muted">
{status.location.file}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0 text-warn" size={18} />
<div className="text-sm">
<p>
No exporter output found. Install the{" "}
<span className="text-fg">Punktfunk Sync</span> extension in
Playnite (then restart Playnite).
</p>
{status.exportError && (
<p className="mt-1 text-xs text-muted">{status.exportError}</p>
)}
<p className="mt-2 text-xs text-muted">
Looked under:{" "}
{status.location.dir ?? "no Playnite data dir found"}
</p>
</div>
</div>
)}
</Card>
);
}
function LibrarySummary({
status,
onSync,
syncing,
}: {
status: Status;
onSync: () => void;
syncing: boolean;
}) {
const report = status.lastReport;
const busy = syncing || status.syncing;
const sources = report
? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
: [];
return (
<Card
title="Library"
icon={<Library size={16} />}
right={
<button
type="button"
onClick={onSync}
disabled={busy}
className="flex items-center gap-2 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
{busy ? (
<Loader2 className="animate-spin" size={15} />
) : (
<RefreshCw size={15} />
)}
Sync now
</button>
}
>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{status.lastSync?.count ?? 0}
</span>
<span className="text-sm text-muted">
title(s) synced · last {relTime(status.lastSync?.at)}
</span>
</div>
{sources.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{sources.map(([src, n]) => (
<span
key={src}
className="rounded-full border border-border bg-surface px-2.5 py-1 text-xs"
>
{src} <span className="text-muted">{n}</span>
</span>
))}
</div>
)}
{report && report.skipped.length > 0 && (
<p className="mt-3 text-xs text-muted">
{report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "}
{report.skipped[0]?.reason})
</p>
)}
{report?.warnings.map((w) => (
<p key={w} className="mt-2 text-xs text-warn">
{w}
</p>
))}
</Card>
);
}
function Settings({
config,
patch,
onSave,
saving,
}: {
config: Config;
patch: (p: Partial<Config>) => void;
onSave: () => void;
saving: boolean;
}) {
const csv = (a: string[]) => a.join(", ");
const parseCsv = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
return (
<Card
title="Settings"
icon={<Gamepad2 size={16} />}
right={
<button
type="button"
onClick={onSave}
disabled={saving}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm hover:bg-elevated disabled:opacity-50"
>
{saving ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Save size={15} />
)}
Save
</button>
}
>
<div className="divide-y divide-border">
<Toggle
label="Installed games only"
hint="Uncheck to also sync games Playnite hasn't installed yet."
checked={config.filter.installedOnly}
onChange={(v) =>
patch({ filter: { ...config.filter, installedOnly: v } })
}
/>
<Toggle
label="Include hidden games"
checked={config.filter.includeHidden}
onChange={(v) =>
patch({ filter: { ...config.filter, includeHidden: v } })
}
/>
<Toggle
label="Embed cover art"
hint="Inlines each cover as a data URL. Turn off for a lighter library payload."
checked={config.art.mode === "dataurl"}
onChange={(v) =>
patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } })
}
/>
<Toggle
label="Embed background art too"
hint="Backgrounds are large — off by default."
checked={config.art.includeBackground}
onChange={(v) =>
patch({ art: { ...config.art, includeBackground: v } })
}
/>
</div>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Only these sources (comma-separated, blank = all)
</span>
<input
className={inputCls}
placeholder="Steam, GOG, Epic"
defaultValue={csv(config.filter.sources)}
onBlur={(e) =>
patch({
filter: { ...config.filter, sources: parseCsv(e.target.value) },
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">Exclude sources</span>
<input
className={inputCls}
placeholder="Xbox"
defaultValue={csv(config.filter.excludeSources)}
onBlur={(e) =>
patch({
filter: {
...config.filter,
excludeSources: parseCsv(e.target.value),
},
})
}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Poll every (min)
</span>
<input
type="number"
min={1}
className={inputCls}
defaultValue={config.sync.pollMinutes}
onBlur={(e) =>
patch({
sync: {
...config.sync,
pollMinutes: Math.max(1, Number(e.target.value) || 5),
},
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
Playnite dir override
</span>
<input
className={inputCls}
placeholder="(auto-detect)"
defaultValue={config.playniteDir ?? ""}
onBlur={(e) =>
patch({ playniteDir: e.target.value.trim() || undefined })
}
/>
</label>
</div>
</div>
</Card>
);
}