UI fix
This commit is contained in:
@@ -55,15 +55,49 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
|
|||||||
return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />;
|
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 (
|
return (
|
||||||
<Field label="Device">
|
<Field label="Device">
|
||||||
<div className="config-combo-field">
|
<div className="config-device-picker">
|
||||||
<input
|
<div className="config-device-picker__source">
|
||||||
list="input-ndi-sources"
|
<select
|
||||||
type="text"
|
value={selectValue}
|
||||||
value={readPath(config, "input.device") ?? ""}
|
onChange={(event) => {
|
||||||
onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
@@ -74,13 +108,14 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
|
|||||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<datalist id="input-ndi-sources">
|
<input
|
||||||
<option value="default" />
|
aria-label="Manual NDI source name"
|
||||||
<option value="auto" />
|
placeholder="Manual source name"
|
||||||
{ndiSources.map((source) => (
|
type="text"
|
||||||
<option key={source.name} value={source.name} />
|
value={currentDevice}
|
||||||
))}
|
onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))}
|
||||||
</datalist>
|
/>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { KvList } from "./KvList";
|
|
||||||
|
|
||||||
function formatNumber(value, digits = 3) {
|
function formatNumber(value, digits = 3) {
|
||||||
return Number(value ?? 0).toFixed(digits);
|
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) {
|
function formatEndpoint(endpoint) {
|
||||||
const backend = endpoint?.backend || "none";
|
const backend = formatBackend(endpoint?.backend);
|
||||||
const device = endpoint?.device ? ` ${endpoint.device}` : "";
|
const device = endpoint?.device ? ` ${endpoint.device}` : "";
|
||||||
return `${backend}${device}`;
|
return `${backend}${device}`;
|
||||||
}
|
}
|
||||||
@@ -16,9 +21,67 @@ function formatVideoMode(endpoint) {
|
|||||||
return `${resolution}${frameRate}`;
|
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 }) {
|
export function StatusPanels({ app, performance, runtime, video, videoOutput }) {
|
||||||
const budgetUsedPercent = Math.max(0, Math.min(100, Number(performance.budgetUsedPercent) || 0));
|
const budgetUsedPercent = Math.max(0, Math.min(100, Number(performance.budgetUsedPercent) || 0));
|
||||||
const outputEnabled = Boolean(videoOutput?.enabled);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -35,44 +98,20 @@ export function StatusPanels({ app, performance, runtime, video, videoOutput })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="telemetry-sections">
|
<div className="telemetry-compact">
|
||||||
<section className="telemetry-section" aria-labelledby="runtime-status-heading">
|
<section className="telemetry-section telemetry-section--compact" aria-labelledby="runtime-status-heading">
|
||||||
<h4 id="runtime-status-heading">Runtime</h4>
|
<h4 id="runtime-status-heading">Runtime</h4>
|
||||||
<KvList
|
<StatusRows rows={runtimeRows} />
|
||||||
variant="rows"
|
<div className="status-meter" aria-hidden="true">
|
||||||
values={[
|
<div className="progress-track">
|
||||||
["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">
|
|
||||||
<div className="progress-bar" style={{ width: `${budgetUsedPercent}%` }} />
|
<div className="progress-bar" style={{ width: `${budgetUsedPercent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<strong>{formatNumber(performance.budgetUsedPercent, 1)}%</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<h4 id="video-status-heading">Video</h4>
|
||||||
<KvList
|
<StatusRows rows={videoRows} />
|
||||||
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}`],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -292,6 +292,12 @@ pre {
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telemetry-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.telemetry-section {
|
.telemetry-section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -299,6 +305,60 @@ pre {
|
|||||||
gap: 0.55rem;
|
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 {
|
.mini-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -600,14 +660,19 @@ pre {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-combo-field {
|
.config-device-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-device-picker__source {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 38px;
|
grid-template-columns: minmax(0, 1fr) 38px;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-combo-field .icon-button {
|
.config-device-picker__source .icon-button {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
min-width: 38px;
|
min-width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -1328,6 +1393,10 @@ pre {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telemetry-compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.panel__header button,
|
.panel__header button,
|
||||||
.stack-panel__reload,
|
.stack-panel__reload,
|
||||||
.layer-card__actions button {
|
.layer-card__actions button {
|
||||||
|
|||||||
Reference in New Issue
Block a user