NDI discovery
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user