Added config editor in front end
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m46s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 19:33:40 +10:00
parent f0f8b080ca
commit 8ffc011ca0
26 changed files with 1201 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { LayerStack } from "./components/LayerStack";
import { ConfigEditor } from "./components/ConfigEditor";
import { StackPresetToolbar } from "./components/StackPresetToolbar";
import { StatusPanels } from "./components/StatusPanels";
import { useRuntimeState } from "./hooks/useRuntimeState";
@@ -91,6 +92,7 @@ function App() {
<section className="dashboard-grid">
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
<StackPresetToolbar />
<ConfigEditor />
</section>
<LayerStack

View File

@@ -5,3 +5,21 @@ export function postJson(path, payload) {
body: JSON.stringify(payload),
});
}
export async function fetchJson(path) {
const response = await fetch(path);
const body = await response.json();
if (!response.ok) {
throw new Error(body?.error || `Request failed: ${response.status}`);
}
return body;
}
export async function postJsonResult(path, payload) {
const response = await postJson(path, payload);
const body = await response.json();
if (!response.ok || body?.ok === false) {
throw new Error(body?.error || `Request failed: ${response.status}`);
}
return body;
}

View File

@@ -0,0 +1,251 @@
import { Power, RefreshCw, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { fetchJson, postJsonResult } from "../api/controlApi";
const backendOptions = ["decklink", "ndi", "none"];
const resolutionOptions = ["720p", "1080p", "2160p"];
const frameRateOptions = ["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"];
const pixelFormatOptions = ["auto", "bgra8", "uyvy8"];
function cloneConfig(config) {
return JSON.parse(JSON.stringify(config ?? {}));
}
function readPath(object, path) {
return path.split(".").reduce((value, key) => value?.[key], object);
}
function writePath(object, path, value) {
const keys = path.split(".");
const root = cloneConfig(object);
let target = root;
for (let index = 0; index < keys.length - 1; ++index) {
const key = keys[index];
target[key] = target[key] ?? {};
target = target[key];
}
target[keys[keys.length - 1]] = value;
return root;
}
function Field({ label, children }) {
return (
<label className="config-field">
<span>{label}</span>
{children}
</label>
);
}
function TextField({ config, label, path, setConfig }) {
return (
<Field label={label}>
<input
type="text"
value={readPath(config, path) ?? ""}
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
/>
</Field>
);
}
function NumberField({ config, label, min, path, setConfig, step = 1 }) {
return (
<Field label={label}>
<input
min={min}
step={step}
type="number"
value={readPath(config, path) ?? 0}
onChange={(event) => setConfig((current) => writePath(current, path, Number(event.target.value)))}
/>
</Field>
);
}
function SelectField({ config, label, options, path, setConfig }) {
return (
<Field label={label}>
<select
value={readPath(config, path) ?? options[0]}
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
);
}
function ToggleField({ config, label, path, setConfig }) {
return (
<label className="toggle toggle--field config-toggle">
<input
checked={Boolean(readPath(config, path))}
type="checkbox"
onChange={(event) => setConfig((current) => writePath(current, path, event.target.checked))}
/>
<span>{label}</span>
</label>
);
}
export function ConfigEditor() {
const [draft, setDraft] = useState(null);
const [saved, setSaved] = useState(null);
const [path, setPath] = useState("");
const [restartRequired, setRestartRequired] = useState(false);
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
const dirty = useMemo(() => JSON.stringify(draft ?? {}) !== JSON.stringify(saved ?? {}), [draft, saved]);
async function loadConfig() {
setBusy(true);
setStatus("");
try {
const response = await fetchJson("/api/config");
const nextConfig = response.disk ?? response.active ?? {};
setDraft(cloneConfig(nextConfig));
setSaved(cloneConfig(nextConfig));
setPath(response.path ?? "");
setRestartRequired(Boolean(response.restartRequired));
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
useEffect(() => {
loadConfig();
}, []);
async function saveConfig() {
if (!draft) return;
setBusy(true);
setStatus("");
try {
await postJsonResult("/api/config/save", draft);
setSaved(cloneConfig(draft));
setRestartRequired(true);
setStatus("Saved");
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function restartApp() {
setBusy(true);
setStatus("");
try {
await postJsonResult("/api/app/restart", {});
setStatus("Restarting");
} catch (error) {
setStatus(error.message);
setBusy(false);
}
}
if (!draft) {
return (
<div className="panel config-panel">
<div className="panel__header">
<h3>Host config</h3>
<button type="button" className="icon-button" onClick={loadConfig} title="Reload config">
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
</div>
<p className="muted">{status || "Loading config."}</p>
</div>
);
}
return (
<div className="panel config-panel">
<div className="panel__header config-panel__header">
<div>
<h3>Host config</h3>
<p className="muted">{path || "runtime-host.json"}</p>
</div>
<div className="config-panel__actions">
<button type="button" className="icon-button" onClick={loadConfig} disabled={busy} title="Reload config">
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
<button type="button" className="button-with-icon" onClick={saveConfig} disabled={busy || !dirty}>
<Save size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Save</span>
</button>
<button
type="button"
className="button-with-icon config-panel__restart"
onClick={restartApp}
disabled={busy || dirty || !restartRequired}
>
<Power size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Restart</span>
</button>
</div>
</div>
<div className="config-grid">
<section className="config-section">
<h4>Runtime</h4>
<div className="config-fields config-fields--wide">
<TextField config={draft} label="Shader library" path="shaderLibrary" setConfig={setDraft} />
<TextField config={draft} label="Startup shader" path="runtimeShaderId" setConfig={setDraft} />
<NumberField config={draft} label="Server port" min={1} path="serverPort" setConfig={setDraft} />
<NumberField config={draft} label="Temporal cap" min={0} path="maxTemporalHistoryFrames" setConfig={setDraft} />
<ToggleField config={draft} label="Auto reload" path="autoReload" setConfig={setDraft} />
<ToggleField config={draft} label="Preview" path="previewEnabled" setConfig={setDraft} />
<NumberField config={draft} label="Preview fps" min={1} path="previewFps" setConfig={setDraft} step={0.01} />
</div>
</section>
<section className="config-section">
<h4>Input</h4>
<div className="config-fields">
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
<TextField config={draft} label="Device" path="input.device" setConfig={setDraft} />
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="input.resolution" setConfig={setDraft} />
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="input.frameRate" setConfig={setDraft} />
</div>
</section>
<section className="config-section">
<h4>Output</h4>
<div className="config-fields">
<SelectField config={draft} label="Backend" options={backendOptions} path="output.backend" setConfig={setDraft} />
<TextField config={draft} label="Device" path="output.device" setConfig={setDraft} />
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="output.resolution" setConfig={setDraft} />
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="output.frameRate" setConfig={setDraft} />
<SelectField config={draft} label="Pixel format" options={pixelFormatOptions} path="output.pixelFormat" setConfig={setDraft} />
<ToggleField config={draft} label="External key" path="output.keying.external" setConfig={setDraft} />
<ToggleField config={draft} label="Alpha required" path="output.keying.alphaRequired" setConfig={setDraft} />
</div>
</section>
<section className="config-section">
<h4>OSC</h4>
<div className="config-fields">
<TextField config={draft} label="Bind address" path="oscBindAddress" setConfig={setDraft} />
<NumberField config={draft} label="Port" min={1} path="oscPort" setConfig={setDraft} />
<NumberField config={draft} label="Smoothing" min={0} path="oscSmoothing" setConfig={setDraft} step={0.01} />
</div>
</section>
</div>
{(status || dirty || restartRequired) && (
<p className={`config-status${status && status !== "Saved" && status !== "Restarting" ? " config-status--error" : ""}`}>
{status || (dirty ? "Unsaved changes" : "Restart required")}
</p>
)}
</div>
);
}

View File

@@ -239,7 +239,8 @@ pre {
.panel--compiler,
.panel--telemetry,
.stack-panel {
.stack-panel,
.config-panel {
grid-column: 1 / -1;
}
@@ -509,6 +510,96 @@ pre {
min-width: 8.75rem;
}
.config-panel {
display: grid;
gap: 0.9rem;
}
.config-panel__header {
margin-bottom: 0;
}
.config-panel__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.config-panel__restart {
background: #b42318;
border-color: #8f1d13;
color: #fff7f5;
}
.config-panel__restart:hover:not(:disabled) {
background: #912018;
}
.config-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 1rem;
}
.config-section {
min-width: 0;
display: grid;
align-content: start;
gap: 0.65rem;
padding: 0.8rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: rgba(255, 255, 255, 0.025);
}
.config-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.config-fields--wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.config-field {
min-width: 0;
display: grid;
gap: 0.25rem;
color: var(--app-muted);
font-size: 0.78rem;
}
.config-field input,
.config-field select {
min-height: 38px;
padding: 0.5rem 0.6rem;
color: var(--app-text);
font-size: 0.9rem;
}
.config-toggle {
align-self: end;
min-height: 38px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: var(--app-surface-2);
color: var(--app-text);
}
.config-status {
margin: 0;
color: #c5efd3;
font-size: 0.84rem;
font-weight: 700;
}
.config-status--error {
color: #ffd0cf;
}
.stack-panel__screenshot {
min-width: 8.75rem;
}
@@ -1192,7 +1283,9 @@ pre {
}
.dashboard-grid,
.stack-panel__grid {
.stack-panel__grid,
.config-grid,
.config-fields--wide {
grid-template-columns: 1fr;
}
@@ -1226,6 +1319,7 @@ pre {
.definition-grid,
.summary-grid,
.kv-rows,
.config-fields,
.parameter-grid,
.parameter,
.parameter__header {