539 lines
21 KiB
JavaScript
539 lines
21 KiB
JavaScript
// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
|
||
|
||
/* ===== Upload helpers ===== */
|
||
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
|
||
const _PART_SIZE = 10 * 1024 * 1024; // 10 MB chunks for multipart
|
||
|
||
function _xhrPost(url, formData, onProgress) {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.withCredentials = true;
|
||
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
|
||
} else {
|
||
let msg = xhr.status + ' ' + xhr.statusText;
|
||
try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
|
||
reject(new Error(msg));
|
||
}
|
||
};
|
||
xhr.onerror = () => reject(new Error('Network error'));
|
||
xhr.open('POST', url);
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
async function _uploadFile(file, projectId, onProgress) {
|
||
const mime = file.type || 'application/octet-stream';
|
||
|
||
if (file.size <= _SIMPLE_MAX) {
|
||
const fd = new FormData();
|
||
fd.append('file', file, file.name);
|
||
fd.append('filename', file.name);
|
||
fd.append('projectId', projectId);
|
||
fd.append('contentType', mime);
|
||
return _xhrPost('/api/v1/upload/simple', fd,
|
||
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
|
||
}
|
||
|
||
// — Multipart —
|
||
const init = await window.ZAMPP_API.fetch('/upload/init', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
|
||
});
|
||
const { assetId, uploadId, key } = init;
|
||
const totalParts = Math.ceil(file.size / _PART_SIZE);
|
||
const parts = [];
|
||
|
||
for (let i = 0; i < totalParts; i++) {
|
||
const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
|
||
const fd = new FormData();
|
||
fd.append('file', chunk, file.name);
|
||
fd.append('uploadId', uploadId);
|
||
fd.append('key', key);
|
||
fd.append('partNumber', String(i + 1));
|
||
const partRes = await _xhrPost('/api/v1/upload/part', fd,
|
||
(loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
|
||
parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
|
||
}
|
||
|
||
await window.ZAMPP_API.fetch('/upload/complete', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ uploadId, key, assetId, parts }),
|
||
});
|
||
return { id: assetId };
|
||
}
|
||
|
||
/* ===== Upload ===== */
|
||
function Upload({ navigate }) {
|
||
const { PROJECTS } = window.ZAMPP_DATA;
|
||
const [files, setFiles] = React.useState([]);
|
||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||
|
||
const updateFile = React.useCallback((id, patch) => {
|
||
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
|
||
}, []);
|
||
|
||
const startUpload = React.useCallback((entry, pid) => {
|
||
_uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
|
||
.then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
|
||
.catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
|
||
}, [updateFile]);
|
||
|
||
const handleDrop = React.useCallback((e) => {
|
||
e.preventDefault();
|
||
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
|
||
const pid = projectId || PROJECTS[0]?.id || '';
|
||
const newEntries = dropped.map((f, i) => ({
|
||
id: Date.now() + i,
|
||
name: f.name,
|
||
size: window.ZAMPP_API.fmtSize(f.size),
|
||
file: f,
|
||
progress: 0,
|
||
status: 'uploading',
|
||
error: null,
|
||
}));
|
||
setFiles(prev => [...prev, ...newEntries]);
|
||
newEntries.forEach(entry => startUpload(entry, pid));
|
||
}, [projectId, startUpload]);
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<h1>Upload</h1>
|
||
<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>
|
||
<label className="field-label">Project</label>
|
||
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
|
||
style={{ appearance: 'auto' }}>
|
||
{PROJECTS.length === 0
|
||
? <option value="">No projects</option>
|
||
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dropzone"
|
||
onDrop={handleDrop}
|
||
onDragOver={e => e.preventDefault()}
|
||
onClick={() => {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.multiple = true;
|
||
inp.onchange = handleDrop;
|
||
inp.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</div>
|
||
<div className="dropzone-formats">
|
||
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
|
||
<span key={f} className="badge outline">{f}</span>)}
|
||
</div>
|
||
</div>
|
||
|
||
{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 === 'uploading'))}>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|aiff)$/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)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
|
||
transition: 'width 200ms',
|
||
}} />
|
||
</div>
|
||
{f.status === 'error' && (
|
||
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
|
||
)}
|
||
</div>
|
||
<span className="mono" style={{
|
||
fontSize: 11.5, minWidth: 60, textAlign: 'right',
|
||
color: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--text-3)',
|
||
}}>
|
||
{f.status === 'done' ? '✓ done'
|
||
: f.status === 'error' ? '✗ failed'
|
||
: f.progress + '%'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Recorders ===== */
|
||
function _normRecorder(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 || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
|
||
codec: r.recording_codec || '—',
|
||
res: r.recording_resolution || '—',
|
||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||
elapsed,
|
||
bitrate: '—',
|
||
health: 100,
|
||
audio: false,
|
||
};
|
||
}
|
||
|
||
function Recorders({ navigate, onNew }) {
|
||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
|
||
|
||
const refresh = React.useCallback(() => {
|
||
window.ZAMPP_API.fetch('/recorders')
|
||
.then(raw => {
|
||
const norm = (raw || []).map(_normRecorder);
|
||
window.ZAMPP_DATA.RECORDERS = norm;
|
||
setRecorders(norm);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
refresh();
|
||
const id = setInterval(refresh, 10000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
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" />
|
||
{(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 ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
|
||
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
|
||
</div>
|
||
<div className="page-body">
|
||
{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={refresh} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||
const [recorder, setRecorder] = React.useState(initialRecorder);
|
||
const [pending, setPending] = React.useState(false);
|
||
const [err, setErr] = React.useState(null);
|
||
const [liveStatus, setLiveStatus] = React.useState(null);
|
||
const isRec = recorder.status === 'recording';
|
||
|
||
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
||
|
||
// Poll the status endpoint every 3s while recording for live feedback.
|
||
React.useEffect(() => {
|
||
if (!isRec) { setLiveStatus(null); return; }
|
||
const poll = () => {
|
||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
|
||
.then(s => setLiveStatus(s))
|
||
.catch(() => {});
|
||
};
|
||
poll();
|
||
const id = setInterval(poll, 3000);
|
||
return () => clearInterval(id);
|
||
}, [isRec, recorder.id]);
|
||
|
||
const displayElapsed = React.useMemo(() => {
|
||
if (liveStatus && liveStatus.duration != null) {
|
||
const d = Math.max(0, liveStatus.duration);
|
||
return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
|
||
String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
|
||
String(d % 60).padStart(2, '0');
|
||
}
|
||
return recorder.elapsed;
|
||
}, [liveStatus, recorder.elapsed]);
|
||
|
||
const displaySignal = liveStatus
|
||
? (liveStatus.signal || '—')
|
||
: (isRec ? 'connecting…' : '—');
|
||
|
||
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
|
||
: displaySignal === 'stopped' ? 'var(--danger)'
|
||
: 'var(--text-3)';
|
||
|
||
const toggle = () => {
|
||
if (pending) return;
|
||
const action = isRec ? 'stop' : 'start';
|
||
setPending(true);
|
||
setErr(null);
|
||
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
|
||
.then(() => { setPending(false); onRefresh(); })
|
||
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
||
};
|
||
|
||
return (
|
||
<div className={'recorder-row ' + recorder.status}>
|
||
<div className="recorder-preview">
|
||
{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 }}>
|
||
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
|
||
<span className={'badge ' + badgeForStatus(recorder.status)}>
|
||
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
|
||
</span>
|
||
<span className="badge outline">{recorder.source}</span>
|
||
</div>
|
||
<div className="recorder-sub mono">{recorder.url}</div>
|
||
<div className="recorder-sub">
|
||
<span>{recorder.codec}</span><span>·</span>
|
||
<span>{recorder.res}</span>
|
||
</div>
|
||
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
|
||
{liveStatus?.lastError && isRec && (
|
||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
|
||
)}
|
||
</div>
|
||
<div className="recorder-stats">
|
||
<div className="recorder-stat">
|
||
<div className="stat-label">Elapsed</div>
|
||
<div className="stat-val mono">{displayElapsed}</div>
|
||
</div>
|
||
<div className="recorder-stat">
|
||
<div className="stat-label">Signal</div>
|
||
<div className="stat-val" style={{ fontSize: 11, color: signalColor }}>{displaySignal}</div>
|
||
</div>
|
||
{liveStatus?.currentFps != null && (
|
||
<div className="recorder-stat">
|
||
<div className="stat-label">FPS</div>
|
||
<div className="stat-val mono">{Number(liveStatus.currentFps).toFixed(1)}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="recorder-actions">
|
||
{isRec
|
||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||
</button>
|
||
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
||
{pending ? '…' : <><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';
|
||
}
|
||
|
||
/* ===== Capture ===== */
|
||
function Capture({ navigate }) {
|
||
const [devices, setDevices] = React.useState([]);
|
||
const [activeIdx, setActiveIdx] = React.useState(0);
|
||
|
||
const loadDevices = () => {
|
||
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
|
||
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
|
||
.catch(() => setDevices([]));
|
||
};
|
||
|
||
React.useEffect(() => { loadDevices(); }, []);
|
||
|
||
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={loadDevices}><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 — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
|
||
<div className="spacer" />
|
||
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
|
||
</div>
|
||
<div className="page-body">
|
||
<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 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 style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Monitors ===== */
|
||
function Monitors({ navigate }) {
|
||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
|
||
const [grid, setGrid] = React.useState(4);
|
||
|
||
React.useEffect(() => {
|
||
const refresh = () => {
|
||
window.ZAMPP_API.fetch('/recorders')
|
||
.then(raw => {
|
||
const norm = (raw || []).map(_normRecorder);
|
||
window.ZAMPP_DATA.RECORDERS = norm;
|
||
setRecorders(norm);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
refresh();
|
||
const id = setInterval(refresh, 5000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
const videoFeeds = recorders.filter(r => !r.audio);
|
||
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
|
||
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</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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="page-body">
|
||
{feeds.length === 0 ? (
|
||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
|
||
) : (
|
||
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
|
||
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MonitorTile({ feed, seed }) {
|
||
const [levels, setLevels] = React.useState([0.65, 0.78]);
|
||
const isLive = feed.status === 'recording';
|
||
|
||
React.useEffect(() => {
|
||
if (!isLive) return;
|
||
const id = setInterval(() => {
|
||
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
|
||
}, 180);
|
||
return () => clearInterval(id);
|
||
}, [isLive]);
|
||
|
||
if (feed.kind === 'audio') {
|
||
return (
|
||
<div className="monitor-tile audio">
|
||
<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' }}>
|
||
<AudioMeter level={levels[0]} vertical />
|
||
<AudioMeter level={levels[1]} vertical />
|
||
</div>
|
||
<div className="monitor-tile-label">
|
||
<span className="badge live">LIVE</span>
|
||
<span className="name">{feed.name}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="monitor-tile">
|
||
<FauxFrame />
|
||
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
|
||
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
||
{isLive && <span className="badge live">REC</span>}
|
||
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
|
||
{feed.status === 'idle' && <span className="badge neutral">IDLE</span>}
|
||
{feed.status === 'error' && <span className="badge danger">ERR</span>}
|
||
</div>
|
||
{isLive && (
|
||
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
|
||
<AudioMeter level={levels[0]} vertical />
|
||
<AudioMeter level={levels[1]} vertical />
|
||
</div>
|
||
)}
|
||
<div className="monitor-tile-label">
|
||
<span className="name">{feed.name}</span>
|
||
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, { Upload, Recorders, Capture, Monitors });
|