NDI discovery
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m10s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 20:03:01 +10:00
parent 8ffc011ca0
commit 2b995ac058
21 changed files with 493 additions and 56 deletions

View File

@@ -27,6 +27,7 @@ function App() {
const [expandedLayerIds, setExpandedLayerIds] = useState([]);
const [dragLayerId, setDragLayerId] = useState(null);
const [dropTargetLayerId, setDropTargetLayerId] = useState(null);
const [configEditorOpen, setConfigEditorOpen] = useState(false);
const layers = appState?.layers ?? [];
const shaders = (appState?.shaders ?? []).filter((shader) => shader.available !== false);
@@ -41,6 +42,15 @@ function App() {
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
}, [layers]);
useEffect(() => {
if (!configEditorOpen) return undefined;
function handleKeyDown(event) {
if (event.key === "Escape") setConfigEditorOpen(false);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [configEditorOpen]);
if (!appState) {
return (
<main className="layout">
@@ -91,8 +101,7 @@ function App() {
<section className="dashboard-grid">
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
<StackPresetToolbar />
<ConfigEditor />
<StackPresetToolbar onOpenConfig={() => setConfigEditorOpen(true)} />
</section>
<LayerStack
@@ -108,6 +117,19 @@ function App() {
/>
<AppFooter />
{configEditorOpen && (
<div
className="modal-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setConfigEditorOpen(false);
}}
>
<div className="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="host-config-heading">
<ConfigEditor onClose={() => setConfigEditorOpen(false)} />
</div>
</div>
)}
</main>
);
}

View File

@@ -1,4 +1,4 @@
import { Power, RefreshCw, Save } from "lucide-react";
import { Power, RefreshCw, Save, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { fetchJson, postJsonResult } from "../api/controlApi";
@@ -50,6 +50,41 @@ function TextField({ config, label, path, setConfig }) {
);
}
function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSources, setConfig }) {
if (readPath(config, "input.backend") !== "ndi") {
return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />;
}
return (
<Field label="Device">
<div className="config-combo-field">
<input
list="input-ndi-sources"
type="text"
value={readPath(config, "input.device") ?? ""}
onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))}
/>
<button
type="button"
className="icon-button"
disabled={ndiSourcesBusy}
onClick={onRefreshNdiSources}
title="Refresh NDI sources"
>
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
</div>
<datalist id="input-ndi-sources">
<option value="default" />
<option value="auto" />
{ndiSources.map((source) => (
<option key={source.name} value={source.name} />
))}
</datalist>
</Field>
);
}
function NumberField({ config, label, min, path, setConfig, step = 1 }) {
return (
<Field label={label}>
@@ -64,12 +99,31 @@ function NumberField({ config, label, min, path, setConfig, step = 1 }) {
);
}
function SelectField({ config, label, options, path, setConfig }) {
function outputAlphaEnabled(config) {
const keying = readPath(config, "output.keying") ?? {};
return Boolean(keying.alphaRequired || keying.external);
}
function writeOutputAlpha(config, enabled, backend = readPath(config, "output.backend")) {
let next = writePath(config, "output.keying.alphaRequired", enabled);
next = writePath(next, "output.keying.external", enabled && backend === "decklink");
return next;
}
function writeOutputBackend(config, backend) {
return writeOutputAlpha(writePath(config, "output.backend", backend), outputAlphaEnabled(config), backend);
}
function SelectField({ config, label, onValueChange, options, path, setConfig }) {
return (
<Field label={label}>
<select
value={readPath(config, path) ?? options[0]}
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
onChange={(event) =>
setConfig((current) =>
onValueChange ? onValueChange(current, event.target.value) : writePath(current, path, event.target.value)
)
}
>
{options.map((option) => (
<option key={option} value={option}>
@@ -81,6 +135,19 @@ function SelectField({ config, label, options, path, setConfig }) {
);
}
function OutputAlphaField({ config, setConfig }) {
return (
<label className="toggle toggle--field config-toggle">
<input
checked={outputAlphaEnabled(config)}
type="checkbox"
onChange={(event) => setConfig((current) => writeOutputAlpha(current, event.target.checked))}
/>
<span>Output alpha</span>
</label>
);
}
function ToggleField({ config, label, path, setConfig }) {
return (
<label className="toggle toggle--field config-toggle">
@@ -94,15 +161,18 @@ function ToggleField({ config, label, path, setConfig }) {
);
}
export function ConfigEditor() {
export function ConfigEditor({ onClose }) {
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 [ndiSources, setNdiSources] = useState([]);
const [ndiSourcesBusy, setNdiSourcesBusy] = useState(false);
const dirty = useMemo(() => JSON.stringify(draft ?? {}) !== JSON.stringify(saved ?? {}), [draft, saved]);
const inputBackend = readPath(draft, "input.backend");
async function loadConfig() {
setBusy(true);
@@ -121,10 +191,32 @@ export function ConfigEditor() {
}
}
async function loadNdiSources() {
setNdiSourcesBusy(true);
try {
const response = await fetchJson("/api/ndi/sources");
if (response.ok === false) {
throw new Error(response.error || "NDI source discovery failed.");
}
setNdiSources(response.sources ?? []);
} catch (error) {
setStatus(error.message);
setNdiSources([]);
} finally {
setNdiSourcesBusy(false);
}
}
useEffect(() => {
loadConfig();
}, []);
useEffect(() => {
if (inputBackend === "ndi") {
loadNdiSources();
}
}, [inputBackend]);
async function saveConfig() {
if (!draft) return;
setBusy(true);
@@ -157,10 +249,17 @@ export function ConfigEditor() {
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>
<h3 id="host-config-heading">Host config</h3>
<div className="config-panel__actions">
<button type="button" className="icon-button" onClick={loadConfig} title="Reload config">
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
{onClose && (
<button type="button" className="icon-button" onClick={onClose} title="Close">
<X size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
)}
</div>
</div>
<p className="muted">{status || "Loading config."}</p>
</div>
@@ -171,7 +270,7 @@ export function ConfigEditor() {
<div className="panel config-panel">
<div className="panel__header config-panel__header">
<div>
<h3>Host config</h3>
<h3 id="host-config-heading">Host config</h3>
<p className="muted">{path || "runtime-host.json"}</p>
</div>
<div className="config-panel__actions">
@@ -191,10 +290,50 @@ export function ConfigEditor() {
<Power size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Restart</span>
</button>
{onClose && (
<button type="button" className="icon-button" onClick={onClose} title="Close">
<X size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="config-grid">
<section className="config-section">
<h4>Input</h4>
<div className="config-fields">
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
<InputDeviceField
config={draft}
ndiSources={ndiSources}
ndiSourcesBusy={ndiSourcesBusy}
onRefreshNdiSources={loadNdiSources}
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"
onValueChange={writeOutputBackend}
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} />
<OutputAlphaField config={draft} setConfig={setDraft} />
</div>
</section>
<section className="config-section">
<h4>Runtime</h4>
<div className="config-fields config-fields--wide">
@@ -208,29 +347,6 @@ export function ConfigEditor() {
</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">

View File

@@ -1,8 +1,8 @@
import { RefreshCw } from "lucide-react";
import { RefreshCw, Settings } from "lucide-react";
import { postJson } from "../api/controlApi";
export function StackPresetToolbar() {
export function StackPresetToolbar({ onOpenConfig }) {
return (
<div className="panel stack-panel">
<div className="panel__header stack-panel__header">
@@ -19,6 +19,10 @@ export function StackPresetToolbar() {
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Reload shaders</span>
</button>
<button type="button" className="button-with-icon stack-panel__config" onClick={onOpenConfig}>
<Settings size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Host config</span>
</button>
</div>
</div>
</div>

View File

@@ -510,6 +510,27 @@ pre {
min-width: 8.75rem;
}
.stack-panel__config {
min-width: 8.5rem;
}
.modal-backdrop {
position: fixed;
z-index: 100;
inset: 0;
display: grid;
align-items: start;
justify-items: center;
overflow-y: auto;
padding: 1.5rem;
background: rgba(5, 8, 12, 0.72);
}
.modal-dialog {
width: min(100%, 1080px);
margin: 0 auto;
}
.config-panel {
display: grid;
gap: 0.9rem;
@@ -538,7 +559,7 @@ pre {
.config-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@@ -579,6 +600,20 @@ pre {
font-size: 0.9rem;
}
.config-combo-field {
display: grid;
grid-template-columns: minmax(0, 1fr) 38px;
gap: 0.45rem;
align-items: stretch;
}
.config-combo-field .icon-button {
width: 38px;
min-width: 38px;
height: 38px;
min-height: 38px;
}
.config-toggle {
align-self: end;
min-height: 38px;
@@ -1316,6 +1351,10 @@ pre {
padding-top: 14px;
}
.modal-backdrop {
padding: 0.5rem;
}
.definition-grid,
.summary-grid,
.kv-rows,