// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
/* ===== Upload ===== */
function Upload({ navigate }) {
const [files, setFiles] = React.useState([]);
const [project, setProject] = React.useState('');
React.useEffect(() => {
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 (
Upload
Drop video, audio, or stills — we proxy and index automatically.
{PROJECTS.find(p => p.id === project)?.name || (PROJECTS.length ? PROJECTS[0].name : 'No projects')}
e.preventDefault()}
onClick={() => { const i = document.createElement('input'); i.type='file'; i.multiple=true; i.onchange=handleDrop; i.click(); }}>
Drop files here or click to browse
Video, audio, and image files
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f => {f})}
{files.length > 0 && (
Queue {files.length}
{files.map(f => (
{f.status === 'done' ? '✓ done' : f.status === 'queued' ? 'queued' : Math.round(f.progress) + '%'}
))}
)}
);
}
/* ===== 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 (
Recorders
Live ingest from SRT, RTMP, and SDI sources
{(liveCount > 0 || errCount > 0) && (
{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}
)}
{recorders.length === 0 ? (
No recorders configured.
) : (
{recorders.map(r => { 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(()=>{}); }} />)}
)}
);
}
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 (
{recorder.name}
{recorder.status.toUpperCase()}
{recorder.source}
{recorder.url}
{recorder.codec}·
{recorder.res}
Elapsed
{recorder.elapsed}
{isRec
?
: }
);
}
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)';
return (
);
}
/* ===== Capture ===== */
function Capture({ navigate }) {
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 (
Capture
DeckLink SDI ingest
No DeckLink devices found in cluster.
);
}
const active = devices[activeIdx] || devices[0];
return (
Capture
DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster
{devices.map((d, i) => (
))}
{active.model || active.device || 'DeckLink'}
{active.hostname} · {active.ip_address}
Connect a source and click Refresh to see port status.
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const { RECORDERS } = window.ZAMPP_DATA;
const [grid, setGrid] = React.useState(4);
const videoFeeds = RECORDERS.filter(r => !r.audio);
const audioFeeds = [
{ id: '__audio1', name: 'FOH Mix', kind: 'audio' },
{ id: '__audio2', name: 'PGM Bus', kind: 'audio' },
];
const allFeeds = [
...videoFeeds.map(r => ({ ...r, kind: 'video' })),
...audioFeeds,
];
const feeds = allFeeds.slice(0, grid * grid);
return (
Monitors
Multi-cam live monitoring
{[2, 3, 4].map(n => (
))}
{feeds.map((f, i) =>
)}
{feeds.length === 0 && (
No active feeds. Start a recorder to see live video here.
)}
);
}
function MonitorTile({ feed, seed }) {
if (feed.kind === 'audio') {
return (
);
}
return (
{feed.status === 'recording' && REC}
{feed.status === 'stopped' && IDLE}
{feed.status === 'error' && ERR}
{feed.name}
{feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors });