UI fix
All checks were successful
CI / React UI Build (push) Successful in 12s
CI / Native Windows Build And Tests (push) Successful in 2m8s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 20:10:57 +10:00
parent 2b995ac058
commit 216a561ede
3 changed files with 198 additions and 55 deletions

View File

@@ -55,32 +55,67 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />;
}
const currentDevice = readPath(config, "input.device") ?? "";
const presetOptions = ["default", "auto"];
const discoveredOptions = Array.from(new Set(ndiSources.map((source) => source.name).filter(Boolean))).filter(
(name) => !presetOptions.includes(name)
);
const selectOptions = [...presetOptions, ...discoveredOptions];
const selectValue = selectOptions.includes(currentDevice) ? currentDevice : currentDevice ? "__current__" : "default";
return (
<Field label="Device">
<div className="config-combo-field">
<div className="config-device-picker">
<div className="config-device-picker__source">
<select
value={selectValue}
onChange={(event) => {
if (event.target.value === "__current__") return;
setConfig((current) => writePath(current, "input.device", event.target.value));
}}
>
<optgroup label="Preset">
{presetOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</optgroup>
{currentDevice && !selectOptions.includes(currentDevice) && (
<option value="__current__">Current: {currentDevice}</option>
)}
<optgroup label="Discovered NDI sources">
{discoveredOptions.length > 0 ? (
discoveredOptions.map((sourceName) => (
<option key={sourceName} value={sourceName}>
{sourceName}
</option>
))
) : (
<option disabled value="__none">
{ndiSourcesBusy ? "Scanning..." : "No sources found"}
</option>
)}
</optgroup>
</select>
<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>
<input
list="input-ndi-sources"
aria-label="Manual NDI source name"
placeholder="Manual source name"
type="text"
value={readPath(config, "input.device") ?? ""}
value={currentDevice}
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>
);
}

View File

@@ -1,11 +1,16 @@
import { KvList } from "./KvList";
function formatNumber(value, digits = 3) {
return Number(value ?? 0).toFixed(digits);
}
function formatBackend(backend) {
if (backend === "ndi") return "NDI";
if (backend === "decklink") return "DeckLink";
if (backend === "none") return "None";
return backend || "None";
}
function formatEndpoint(endpoint) {
const backend = endpoint?.backend || "none";
const backend = formatBackend(endpoint?.backend);
const device = endpoint?.device ? ` ${endpoint.device}` : "";
return `${backend}${device}`;
}
@@ -16,9 +21,67 @@ function formatVideoMode(endpoint) {
return `${resolution}${frameRate}`;
}
function compactOutputStatus(videoOutput) {
const message = videoOutput?.statusMessage || "";
if (!message) return videoOutput?.enabled ? "Running" : "Disabled";
if (message.toLowerCase().includes("scheduled output running")) return "Running";
if (message.toLowerCase().includes("disabled")) return "Disabled";
return message;
}
function StatusRows({ rows }) {
return (
<dl className="status-rows">
{rows.map((row) => (
<div className="status-row" key={row.label}>
<dt>{row.label}</dt>
<dd>
<strong>{row.value}</strong>
{row.meta && <span>{row.meta}</span>}
</dd>
</div>
))}
</dl>
);
}
export function StatusPanels({ app, performance, runtime, video, videoOutput }) {
const budgetUsedPercent = Math.max(0, Math.min(100, Number(performance.budgetUsedPercent) || 0));
const outputEnabled = Boolean(videoOutput?.enabled);
const runtimeRows = [
{
label: "Render",
value: `${formatNumber(performance.renderMs, 2)} ms`,
meta: `${formatNumber(performance.budgetUsedPercent, 1)}% of ${formatNumber(performance.frameBudgetMs, 2)} ms`,
},
{
label: "Layers",
value: `${runtime.layerCount || 0}`,
meta: `Temporal cap ${app.maxTemporalHistoryFrames ?? 0}`,
},
{
label: "Control",
value: `127.0.0.1:${app.serverPort}`,
meta: `Auto reload ${app.autoReload ? "on" : "off"}`,
},
];
const videoRows = [
{
label: "Input",
value: formatEndpoint(app.input),
meta: formatVideoMode(app.input),
},
{
label: "Output",
value: formatEndpoint(app.output),
meta: `${formatVideoMode(app.output)} / ${video.width || 0} x ${video.height || 0}`,
},
{
label: "Schedule",
value: `${videoOutput?.scheduleFailures ?? 0} failures`,
meta: compactOutputStatus(videoOutput),
},
];
return (
<>
@@ -35,44 +98,20 @@ export function StatusPanels({ app, performance, runtime, video, videoOutput })
</div>
</div>
<div className="telemetry-sections">
<section className="telemetry-section" aria-labelledby="runtime-status-heading">
<div className="telemetry-compact">
<section className="telemetry-section telemetry-section--compact" aria-labelledby="runtime-status-heading">
<h4 id="runtime-status-heading">Runtime</h4>
<KvList
variant="rows"
values={[
["Layers", `${runtime.layerCount || 0}`],
["Auto reload", app.autoReload ? "On" : "Off"],
["Temporal cap", `${app.maxTemporalHistoryFrames ?? 0}`],
["Control URL", `127.0.0.1:${app.serverPort}`],
["Render", `${formatNumber(performance.renderMs, 2)} ms`],
["Smoothed", `${formatNumber(performance.smoothedRenderMs, 2)} ms`],
["Frame budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`],
]}
/>
<div className="meter-row">
<span>Budget used</span>
<div className="progress-track" aria-hidden="true">
<StatusRows rows={runtimeRows} />
<div className="status-meter" aria-hidden="true">
<div className="progress-track">
<div className="progress-bar" style={{ width: `${budgetUsedPercent}%` }} />
</div>
<strong>{formatNumber(performance.budgetUsedPercent, 1)}%</strong>
</div>
</section>
<section className="telemetry-section" aria-labelledby="video-status-heading">
<section className="telemetry-section telemetry-section--compact" aria-labelledby="video-status-heading">
<h4 id="video-status-heading">Video</h4>
<KvList
variant="rows"
values={[
["Input", formatEndpoint(app.input)],
["Input mode", formatVideoMode(app.input)],
["Output", formatEndpoint(app.output)],
["Output mode", formatVideoMode(app.output)],
["Output size", `${video.width || 0} x ${video.height || 0}`],
["Output status", videoOutput?.statusMessage || "Unknown"],
["Schedule failures", `${videoOutput?.scheduleFailures ?? 0}`],
]}
/>
<StatusRows rows={videoRows} />
</section>
</div>
</div>

View File

@@ -292,6 +292,12 @@ pre {
gap: 1.25rem;
}
.telemetry-compact {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.telemetry-section {
min-width: 0;
display: grid;
@@ -299,6 +305,60 @@ pre {
gap: 0.55rem;
}
.telemetry-section--compact {
gap: 0.5rem;
}
.status-rows {
display: grid;
gap: 0;
margin: 0;
padding: 0;
border-top: 1px solid var(--app-border);
}
.status-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(4.75rem, 0.38fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: baseline;
padding: 0.52rem 0;
border-bottom: 1px solid rgba(48, 57, 71, 0.7);
}
.status-row dt {
color: var(--app-muted);
font-size: 0.82rem;
}
.status-row dd {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.55rem;
align-items: baseline;
margin: 0;
overflow-wrap: anywhere;
}
.status-row strong {
color: #f2f6fb;
font-size: 0.96rem;
font-weight: 730;
line-height: 1.25;
}
.status-row span {
color: var(--app-muted);
font-size: 0.78rem;
line-height: 1.25;
}
.status-meter {
padding-top: 0.1rem;
}
.mini-status {
display: inline-flex;
align-items: center;
@@ -600,14 +660,19 @@ pre {
font-size: 0.9rem;
}
.config-combo-field {
.config-device-picker {
display: grid;
gap: 0.45rem;
}
.config-device-picker__source {
display: grid;
grid-template-columns: minmax(0, 1fr) 38px;
gap: 0.45rem;
align-items: stretch;
}
.config-combo-field .icon-button {
.config-device-picker__source .icon-button {
width: 38px;
min-width: 38px;
height: 38px;
@@ -1328,6 +1393,10 @@ pre {
grid-template-columns: 1fr;
}
.telemetry-compact {
grid-template-columns: 1fr;
}
.panel__header button,
.stack-panel__reload,
.layer-card__actions button {