UI fix
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user