feat(ui): wire ingest screens to real API (recorders, capture devices): screens-ingest.jsx
This commit is contained in:
parent
835545e061
commit
406f28c663
1 changed files with 201 additions and 252 deletions
|
|
@ -1,29 +1,28 @@
|
|||
// screens-ingest.jsx — Upload, Recorders, Capture (SDI), Monitors
|
||||
// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
|
||||
|
||||
const { RECORDERS, NODES, SDI_PORTS_zampp2, PROJECTS } = window.ZAMPP_DATA;
|
||||
|
||||
/* ========== Upload ========== */
|
||||
/* ===== Upload ===== */
|
||||
function Upload({ navigate }) {
|
||||
const [files, setFiles] = React.useState([
|
||||
{ id: 1, name: "Drone_Aerial_Lap_4.mov", size: "12.4 GB", progress: 68, status: "uploading" },
|
||||
{ id: 2, name: "Interview_Director.mxf", size: "2.1 GB", progress: 100, status: "done" },
|
||||
{ id: 3, name: "Sponsor_Logo_v4.mov", size: "120 MB", progress: 100, status: "done" },
|
||||
{ id: 4, name: "Pit_Cam_3.mp4", size: "4.8 GB", progress: 24, status: "uploading" },
|
||||
{ id: 5, name: "Backstage_Audio.wav", size: "920 MB", progress: 0, status: "queued" },
|
||||
]);
|
||||
const [files, setFiles] = React.useState([]);
|
||||
const [project, setProject] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
const i = setInterval(() => {
|
||||
setFiles(fs => fs.map(f => {
|
||||
if (f.status !== "uploading") return f;
|
||||
const next = f.progress + Math.random() * 4;
|
||||
if (next >= 100) return { ...f, progress: 100, status: "done" };
|
||||
return { ...f, progress: next };
|
||||
}));
|
||||
}, 600);
|
||||
return () => clearInterval(i);
|
||||
const { PROJECTS } = window.ZAMPP_DATA;
|
||||
if (PROJECTS.length > 0 && !project) setProject(PROJECTS[0].id);
|
||||
}, []);
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
|
||||
const newFiles = dropped.map((f, i) => ({
|
||||
id: Date.now() + i, name: f.name,
|
||||
size: window.ZAMPP_API.fmtSize(f.size),
|
||||
file: f, progress: 0, status: 'queued',
|
||||
}));
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const { PROJECTS } = window.ZAMPP_DATA;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
|
|
@ -31,108 +30,139 @@ function Upload({ navigate }) {
|
|||
<span className="subtitle">Drop video, audio, or stills — we proxy and index automatically.</span>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||
<div>
|
||||
<label className="field-label">Project</label>
|
||||
<div className="select-faux"><span>Protour 2026</span><Icon name="chevronDown" size={12} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Bin</label>
|
||||
<div className="select-faux"><span>Master files</span><Icon name="chevronDown" size={12} /></div>
|
||||
<div className="select-faux">
|
||||
<span>{PROJECTS.find(p => p.id === project)?.name || (PROJECTS.length ? PROJECTS[0].name : 'No projects')}</span>
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dropzone">
|
||||
<Icon name="upload" size={32} style={{ color: "var(--text-3)" }} />
|
||||
<div className="dropzone" onDrop={handleDrop} onDragOver={e => e.preventDefault()}
|
||||
onClick={() => { const i = document.createElement('input'); i.type='file'; i.multiple=true; i.onchange=handleDrop; i.click(); }}>
|
||||
<Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
|
||||
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files — up to 5 GB each</div>
|
||||
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files</div>
|
||||
<div className="dropzone-formats">
|
||||
{["MOV", "MP4", "MXF", "ProRes", "DNxHR", "WAV", "AIFF"].map(f => <span key={f} className="badge outline">{f}</span>)}
|
||||
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f => <span key={f} className="badge outline">{f}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
Queue
|
||||
<span className="badge neutral">{files.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm">Pause all</button>
|
||||
<button className="btn ghost sm">Clear done</button>
|
||||
</div>
|
||||
<div className="panel">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="upload-row">
|
||||
<Icon name={f.name.match(/\.(wav|aif|mp3)$/i) ? "audio" : "video"} size={16} style={{ color: "var(--text-3)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
|
||||
<span className="muted" style={{ fontSize: 11, fontFamily: "var(--font-mono)" }}>{f.size}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
width: `${f.progress}%`, height: "100%",
|
||||
background: f.status === "done" ? "var(--success)" : "var(--accent)",
|
||||
transition: "width 200ms",
|
||||
}} />
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
Queue <span className="badge neutral">{files.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status !== 'done'))}>Clear done</button>
|
||||
</div>
|
||||
<div className="panel">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="upload-row">
|
||||
<Icon name={f.name.match(/\.(wav|aif|mp3)$/i) ? 'audio' : 'video'} size={16} style={{ color: 'var(--text-3)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
|
||||
<span className="muted" style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{f.size}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ width: f.progress + '%', height: '100%', background: f.status === 'done' ? 'var(--success)' : 'var(--accent)', transition: 'width 200ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', minWidth: 60, textAlign: 'right' }}>
|
||||
{f.status === 'done' ? '✓ done' : f.status === 'queued' ? 'queued' : Math.round(f.progress) + '%'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 60, textAlign: "right" }}>
|
||||
{f.status === "done" ? "✓ done" : f.status === "queued" ? "queued" : `${Math.round(f.progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Recorders (live ingest dashboard) ========== */
|
||||
/* ===== Recorders ===== */
|
||||
function Recorders({ navigate, onNew }) {
|
||||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
|
||||
|
||||
// Poll every 10s for recorder status changes
|
||||
React.useEffect(() => {
|
||||
const refresh = () => {
|
||||
window.ZAMPP_API.fetch('/recorders')
|
||||
.then(raw => {
|
||||
const norm = (raw || []).map(r => {
|
||||
let elapsed = '—';
|
||||
if (r.status === 'recording' && r.started_at) {
|
||||
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
||||
elapsed = String(Math.floor(s/3600)).padStart(2,'0')+':'+String(Math.floor((s%3600)/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
|
||||
}
|
||||
const cfg = r.source_config || {};
|
||||
return { ...r, source: r.source_type||'—', url: cfg.url||cfg.address||r.source_type||'—', codec: r.recording_codec||'—', res: r.recording_resolution||'—', node: r.node_id||'primary', elapsed, bitrate:'—', health:100, audio:false };
|
||||
});
|
||||
window.ZAMPP_DATA.RECORDERS = norm;
|
||||
setRecorders(norm);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
const i = setInterval(refresh, 10000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
const liveCount = recorders.filter(r => r.status === 'recording').length;
|
||||
const errCount = recorders.filter(r => r.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Recorders</h1>
|
||||
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
|
||||
<div className="spacer" />
|
||||
<div className="status-pip">
|
||||
<span className="dot" />
|
||||
<span>4 recording · 1 armed · 1 error</span>
|
||||
</div>
|
||||
{(liveCount > 0 || errCount > 0) && (
|
||||
<div className="status-pip">
|
||||
<span className="dot" />
|
||||
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="recorders-list">
|
||||
{RECORDERS.map(r => <RecorderRow key={r.id} recorder={r} />)}
|
||||
</div>
|
||||
{recorders.length === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
No recorders configured.
|
||||
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="recorders-list">
|
||||
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={() => { window.ZAMPP_API.fetch('/recorders').then(raw => setRecorders((raw||[]).map(x => { const cfg=x.source_config||{}; return {...x,source:x.source_type,url:cfg.url||cfg.address||x.source_type,codec:x.recording_codec,res:x.recording_resolution,node:x.node_id,elapsed:'—',bitrate:'—',health:100,audio:false}; }))).catch(()=>{}); }} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecorderRow({ recorder }) {
|
||||
const isRec = recorder.status === "recording";
|
||||
function RecorderRow({ recorder, onRefresh }) {
|
||||
const isRec = recorder.status === 'recording';
|
||||
|
||||
const toggle = () => {
|
||||
const action = isRec ? 'stop' : 'start';
|
||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
|
||||
.then(onRefresh).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`recorder-row ${recorder.status}`}>
|
||||
<div className={'recorder-row ' + recorder.status}>
|
||||
<div className="recorder-preview">
|
||||
{recorder.audio ? (
|
||||
<div className="recorder-audio-prev">
|
||||
<Waveform seed={recorder.id.length} />
|
||||
<AudioMeter level={0.7} />
|
||||
</div>
|
||||
) : isRec ? (
|
||||
<LiveStrip seed={recorder.id.length * 3} count={6} />
|
||||
) : (
|
||||
<div className="recorder-empty">
|
||||
<Icon name={recorder.status === "error" ? "alert" : "video"} size={20} style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
)}
|
||||
{isRec
|
||||
? <LiveStrip seed={recorder.id.length * 3} count={6} />
|
||||
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
|
||||
</div>
|
||||
<div className="recorder-info">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
|
||||
<span className={`badge ${badgeForStatus(recorder.status)}`}>
|
||||
<span className={'badge ' + badgeForStatus(recorder.status)}>
|
||||
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="badge outline">{recorder.source}</span>
|
||||
|
|
@ -140,8 +170,7 @@ function RecorderRow({ recorder }) {
|
|||
<div className="recorder-sub mono">{recorder.url}</div>
|
||||
<div className="recorder-sub">
|
||||
<span>{recorder.codec}</span><span>·</span>
|
||||
<span>{recorder.res}</span><span>·</span>
|
||||
<span>node: {recorder.node}</span>
|
||||
<span>{recorder.res}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recorder-stats">
|
||||
|
|
@ -150,211 +179,133 @@ function RecorderRow({ recorder }) {
|
|||
<div className="stat-val mono">{recorder.elapsed}</div>
|
||||
</div>
|
||||
<div className="recorder-stat">
|
||||
<div className="stat-label">Bitrate</div>
|
||||
<div className="stat-val mono">{recorder.bitrate}</div>
|
||||
</div>
|
||||
<div className="recorder-stat" style={{ minWidth: 80 }}>
|
||||
<div className="stat-label">Health</div>
|
||||
<div className="stat-val">
|
||||
<HealthBar value={recorder.health} />
|
||||
</div>
|
||||
<div className="stat-label">Status</div>
|
||||
<div className="stat-val"><StatusDot status={recorder.status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recorder-actions">
|
||||
{isRec ? (
|
||||
<button className="btn danger sm"><span className="rec-dot" />Stop</button>
|
||||
) : recorder.status === "error" ? (
|
||||
<button className="btn subtle sm"><Icon name="refresh" />Reconnect</button>
|
||||
) : (
|
||||
<button className="btn subtle sm"><span className="rec-dot" style={{ background: "var(--live)" }} />Record</button>
|
||||
)}
|
||||
{isRec
|
||||
? <button className="btn danger sm" onClick={toggle}><span className="rec-dot" />Stop</button>
|
||||
: <button className="btn subtle sm" onClick={toggle}><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</button>}
|
||||
<button className="icon-btn"><Icon name="more" /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function badgeForStatus(s) {
|
||||
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
|
||||
}
|
||||
|
||||
function HealthBar({ value }) {
|
||||
const color = value > 80 ? "var(--success)" : value > 40 ? "var(--warning)" : "var(--danger)";
|
||||
const color = value > 80 ? 'var(--success)' : value > 40 ? 'var(--warning)' : 'var(--danger)';
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ width: 56, height: 5, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{ width: `${value}%`, height: "100%", background: color }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 56, height: 5, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ width: value + '%', height: '100%', background: color }} />
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{value}%</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{value}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function badgeForStatus(s) {
|
||||
return { recording: "live", armed: "accent", idle: "neutral", error: "danger", offline: "neutral" }[s] || "neutral";
|
||||
}
|
||||
|
||||
/* ========== Capture (rich SDI port picker) ========== */
|
||||
/* ===== Capture ===== */
|
||||
function Capture({ navigate }) {
|
||||
const [activePort, setActivePort] = React.useState(1);
|
||||
const ports = SDI_PORTS_zampp2;
|
||||
const [devices, setDevices] = React.useState([]);
|
||||
const [activeIdx, setActiveIdx] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
|
||||
.then(devs => setDevices(devs || []))
|
||||
.catch(() => setDevices([]));
|
||||
}, []);
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Capture</h1>
|
||||
<span className="subtitle">DeckLink SDI ingest</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}
|
||||
><Icon name="refresh" />Refresh</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
No DeckLink devices found in cluster.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active = devices[activeIdx] || devices[0];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Capture</h1>
|
||||
<span className="subtitle">DeckLink SDI ingest — multi-port routing across cluster nodes</span>
|
||||
<span className="subtitle">DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm"><Icon name="refresh" />Refresh ports</button>
|
||||
<button className="btn primary"><Icon name="plus" />New capture</button>
|
||||
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}><Icon name="refresh" />Refresh</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "440px 1fr", gap: 20, alignItems: "start" }}>
|
||||
<DeckLinkVisual ports={ports} activePort={activePort} onSelect={setActivePort} />
|
||||
<CaptureDetail port={ports[activePort - 1]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeckLinkVisual({ ports, activePort, onSelect }) {
|
||||
return (
|
||||
<div className="decklink-card">
|
||||
<div className="decklink-head">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>DeckLink Duo 2</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>zampp2 · 172.18.91.217</div>
|
||||
</div>
|
||||
<span className="badge success"><StatusDot status="online" /> ONLINE</span>
|
||||
</div>
|
||||
<div className="decklink-body">
|
||||
<div className="decklink-card-face">
|
||||
<div className="decklink-label">DECKLINK DUO 2</div>
|
||||
<div className="decklink-leds">
|
||||
<div className="decklink-led on" />
|
||||
<div className="decklink-led" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bnc-ports">
|
||||
{ports.map(p => (
|
||||
<button
|
||||
key={p.idx}
|
||||
className={`bnc-port ${activePort === p.idx ? "active" : ""} ${p.active ? "live" : ""} ${p.recording ? "recording" : ""}`}
|
||||
onClick={() => onSelect(p.idx)}
|
||||
>
|
||||
<div className="bnc-connector">
|
||||
<div className="bnc-pin" />
|
||||
<div className="bnc-ring" />
|
||||
</div>
|
||||
<div className="bnc-info">
|
||||
<div className="bnc-num">SDI {p.idx}</div>
|
||||
<div className="bnc-label">{p.label}</div>
|
||||
<div className="bnc-sig mono">{p.signal}</div>
|
||||
</div>
|
||||
{p.recording && <span className="bnc-rec" />}
|
||||
{p.active && (
|
||||
<div className="bnc-signal-bar">
|
||||
<div className="bnc-signal-fill" style={{ width: `${p.level * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
{devices.map((d, i) => (
|
||||
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
|
||||
{d.model || d.device || 'DeckLink'} — {d.hostname}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="decklink-foot">
|
||||
<span>Ports: <strong>4</strong></span>
|
||||
<span>Active: <strong>{ports.filter(p => p.active).length}</strong></span>
|
||||
<span>Recording: <strong style={{ color: "var(--live)" }}>{ports.filter(p => p.recording).length}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptureDetail({ port }) {
|
||||
if (!port.active) {
|
||||
return (
|
||||
<div className="capture-detail empty">
|
||||
<Icon name="video" size={28} style={{ opacity: 0.3 }} />
|
||||
<div style={{ marginTop: 8, fontWeight: 500 }}>No signal on SDI {port.idx}</div>
|
||||
<div className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>Connect a source, then click Refresh.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="capture-detail">
|
||||
<div className="capture-preview">
|
||||
<FauxFrame seed={port.idx} />
|
||||
<div className="scanlines" />
|
||||
{port.recording && (
|
||||
<div style={{ position: "absolute", top: 12, left: 12 }}>
|
||||
<span className="badge live">REC</span>
|
||||
<div className="panel" style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<StatusDot status={active.online ? 'online' : 'offline'} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="capture-overlay-meters">
|
||||
<AudioMeter level={0.7} vertical />
|
||||
<AudioMeter level={0.6} vertical />
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
|
||||
</div>
|
||||
<div className="capture-tc">
|
||||
<span className="mono">{port.signal}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="capture-stats">
|
||||
<CaptureStat label="Resolution" value={port.signal.split(/[ip]/)[0] + (port.signal.includes("p") ? "p" : "i")} />
|
||||
<CaptureStat label="Frame rate" value={port.signal.match(/[\d.]+$/)?.[0] || "—"} />
|
||||
<CaptureStat label="Color" value="YUV 4:2:2 10-bit" />
|
||||
<CaptureStat label="Audio" value="2.0 / 48 kHz" />
|
||||
<CaptureStat label="Source level" value={`${Math.round(port.level * 100)}%`} />
|
||||
<CaptureStat label="Dropped frames" value="0" />
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
|
||||
{!port.recording ? (
|
||||
<button className="btn primary"><span className="rec-dot" style={{ background: "white" }} />Arm + Record</button>
|
||||
) : (
|
||||
<button className="btn danger"><span className="rec-dot" />Stop</button>
|
||||
)}
|
||||
<button className="btn subtle">Take still</button>
|
||||
<button className="btn ghost">Route to recorder</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptureStat({ label, value }) {
|
||||
return (
|
||||
<div className="capture-stat">
|
||||
<div className="capture-stat-label">{label}</div>
|
||||
<div className="capture-stat-value mono">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Monitors (multi-cam grid) ========== */
|
||||
/* ===== Monitors ===== */
|
||||
function Monitors({ navigate }) {
|
||||
const { RECORDERS } = window.ZAMPP_DATA;
|
||||
const [grid, setGrid] = React.useState(4);
|
||||
const allFeeds = [
|
||||
...RECORDERS.filter(r => !r.audio).map(r => ({ ...r, kind: "video" })),
|
||||
{ id: "audio1", name: "FOH Mix", kind: "audio" },
|
||||
{ id: "audio2", name: "Stage Mics", kind: "audio" },
|
||||
{ id: "audio3", name: "PGM Bus", kind: "audio" },
|
||||
|
||||
const videoFeeds = RECORDERS.filter(r => !r.audio);
|
||||
const audioFeeds = [
|
||||
{ id: '__audio1', name: 'FOH Mix', kind: 'audio' },
|
||||
{ id: '__audio2', name: 'PGM Bus', kind: 'audio' },
|
||||
];
|
||||
const feeds = allFeeds.slice(0, grid * grid - (grid === 2 ? 1 : 0));
|
||||
const gridSize = grid;
|
||||
const allFeeds = [
|
||||
...videoFeeds.map(r => ({ ...r, kind: 'video' })),
|
||||
...audioFeeds,
|
||||
];
|
||||
const feeds = allFeeds.slice(0, grid * grid);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Monitors</h1>
|
||||
<span className="subtitle">Multi-cam live monitoring across all active feeds</span>
|
||||
<span className="subtitle">Multi-cam live monitoring</span>
|
||||
<div className="spacer" />
|
||||
<div className="tab-group">
|
||||
{[2, 3, 4].map(n => (
|
||||
<button key={n} className={grid === n ? "active" : ""} onClick={() => setGrid(n)}>{n}×{n}</button>
|
||||
<button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn ghost sm"><Icon name="layout" />Layouts</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="monitors-grid" style={{ gridTemplateColumns: `repeat(${gridSize}, 1fr)` }}>
|
||||
{feeds.map((f, i) => (
|
||||
<MonitorTile key={f.id} feed={f} seed={i + 1} />
|
||||
))}
|
||||
<div className="monitors-grid" style={{ gridTemplateColumns: 'repeat(' + grid + ', 1fr)' }}>
|
||||
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
|
||||
{feeds.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -362,13 +313,13 @@ function Monitors({ navigate }) {
|
|||
}
|
||||
|
||||
function MonitorTile({ feed, seed }) {
|
||||
if (feed.kind === "audio") {
|
||||
if (feed.kind === 'audio') {
|
||||
return (
|
||||
<div className="monitor-tile audio">
|
||||
<div style={{ flex: 1, display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||
<Waveform seed={seed * 7} color="var(--accent)" />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, padding: "0 16px 16px", justifyContent: "center" }}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
|
||||
<AudioMeter level={0.65} vertical />
|
||||
<AudioMeter level={0.78} vertical />
|
||||
</div>
|
||||
|
|
@ -381,17 +332,15 @@ function MonitorTile({ feed, seed }) {
|
|||
}
|
||||
return (
|
||||
<div className="monitor-tile">
|
||||
<FauxFrame seed={seed} />
|
||||
<div className="scanlines" />
|
||||
<div style={{ position: "absolute", top: 8, left: 8, display: "flex", gap: 6 }}>
|
||||
{feed.status === "recording" && <span className="badge live">REC</span>}
|
||||
{feed.status === "armed" && <span className="badge accent">ARMED</span>}
|
||||
{feed.status === "error" && <span className="badge danger">ERR</span>}
|
||||
{feed.status === "idle" && <span className="badge neutral">IDLE</span>}
|
||||
<FauxFrame />
|
||||
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
||||
{feed.status === 'recording' && <span className="badge live">REC</span>}
|
||||
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
|
||||
{feed.status === 'error' && <span className="badge danger">ERR</span>}
|
||||
</div>
|
||||
<div className="monitor-tile-label">
|
||||
<span className="name">{feed.name}</span>
|
||||
{feed.elapsed && feed.elapsed !== "00:00:00" && <span className="time mono">{feed.elapsed}</span>}
|
||||
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue