dragonflight/services/web-ui/public/modal-new-recorder.jsx

301 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// modal-new-recorder.jsx — New Recorder dialog (SRT / RTMP / SDI)
function NewRecorderModal({ open, onClose }) {
const { PROJECTS, NODES } = window.ZAMPP_DATA;
const [name, setName] = React.useState('');
const [sourceType, setSourceType] = React.useState('SRT');
const [srtUrl, setSrtUrl] = React.useState('srt://10.0.4.18:4200');
const [rtmpUrl, setRtmpUrl] = React.useState('rtmp://stream.local/live/cam_a');
const [sdiDeviceIdx, setSdiDeviceIdx] = React.useState(0);
const [sdiNodeId, setSdiNodeId] = React.useState(() => {
const n = window.ZAMPP_DATA.NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
const [proxyTab, setProxyTab] = React.useState('video');
const [proxyOn, setProxyOn] = React.useState(true);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
React.useEffect(() => {
if (sourceType !== 'SDI' || sdiDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(d => setSdiDevices(Array.isArray(d) ? d : []))
.catch(() => setSdiDevices([]));
}, [sourceType]);
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
setSubmitting(true);
setSubmitErr(null);
const body = {
name: name.trim(),
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
};
if (sourceType === 'SRT') {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else {
// SDI: device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
}
window.ZAMPP_API.fetch('/recorders', { method: 'POST', body: JSON.stringify(body) })
.then(() => { setSubmitting(false); onClose(); })
.catch(e => { setSubmitting(false); setSubmitErr(e.message || 'Failed to create recorder'); });
};
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div>
</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Recorder name</label>
<input className="field-input" placeholder="e.g. Studio A Stage Cam"
value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="field">
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
onClick={() => setSourceType(t.id)}>
<div className="source-type-icon"><Icon name={t.icon} size={16} /></div>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>{t.desc}</div>
</div>
</button>
))}
</div>
</div>
{sourceType === 'SRT' && (
<div className="field">
<label className="field-label">Source URL</label>
<input className="field-input mono" placeholder="srt://192.168.1.100:4200"
value={srtUrl} onChange={e => setSrtUrl(e.target.value)} />
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder connects out to this URL (caller mode).
</div>
</div>
)}
{sourceType === 'RTMP' && (
<div className="field">
<label className="field-label">Source URL</label>
<input className="field-input mono" placeholder="rtmp://server/live/streamkey"
value={rtmpUrl} onChange={e => setRtmpUrl(e.target.value)} />
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder pulls this RTMP stream.
</div>
</div>
)}
{sourceType === 'SDI' && (
<div className="field">
<label className="field-label">Capture device</label>
{sdiDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<div className="sdi-port-mini">
{sdiDevices.map((dev, di) => (
<div key={di} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
{(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
</div>
{Array.from({ length: dev.port_count || 4 }, (_, i) => i).map(idx => (
<button key={idx}
className={`sdi-mini-port ${sdiDeviceIdx === idx && sdiNodeId === (dev.node_id || dev.hostname || '') ? 'active' : ''}`}
onClick={() => { setSdiDeviceIdx(idx); setSdiNodeId(dev.node_id || dev.hostname || ''); }}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>SDI {idx + 1}</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {idx}</span>
</button>
))}
</div>
))}
</div>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<div style={{ padding: '8px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
No DeckLink devices auto-detected. Configure manually:
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={sdiNodeId}
onChange={e => setSdiNodeId(e.target.value)} style={{ appearance: 'auto' }}>
{NODES.length === 0
? <option value="">No cluster nodes</option>
: NODES.map(n => {
const id = n.id || n.hostname || n.name || '';
return <option key={id} value={id}>{id}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">Device index</label>
<select className="field-input" value={sdiDeviceIdx}
onChange={e => setSdiDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
{[0, 1, 2, 3].map(i =>
<option key={i} value={i}>SDI {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
)}
</div>
)}
<div className="modal-section">
<div className="modal-section-head">
<span>Master recording</span>
<span style={{ flex: 1 }} />
<div className="tab-group">
{['video', 'audio', 'container'].map(t => (
<button key={t} className={recTab === t ? 'active' : ''} onClick={() => setRecTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
<div className="modal-section-body">
{recTab === 'video' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Video codec" value="ProRes 422 HQ" select />
<Field label="Resolution" value="2160p59.94 (source)" select />
<Field label="Color space" value="Rec. 709" select />
<Field label="Bit depth" value="10-bit" select />
</div>
)}
{recTab === 'audio' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Audio codec" value="PCM" select />
<Field label="Sample rate" value="48 kHz" select />
<Field label="Channels" value="2.0 stereo" select />
<Field label="Bit depth" value="24-bit" select />
</div>
)}
{recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Container" value="MOV (QuickTime)" select />
<Field label="Segment" value="None (single file)" select />
</div>
)}
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={proxyOn} onChange={e => setProxyOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Generate proxy</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
SDI sources record proxy in parallel. Network sources generate proxy after stop.
</div>
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head">
<span>Proxy</span>
<span style={{ flex: 1 }} />
<div className="tab-group">
{['video', 'audio', 'container'].map(t => (
<button key={t} className={proxyTab === t ? 'active' : ''} onClick={() => setProxyTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
<div className="modal-section-body">
{proxyTab === 'video' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Video codec" value="H.264 (NVENC)" select />
<Field label="Resolution" value="1280×720" select />
<Field label="Bitrate" value="2 Mbps" select />
<Field label="GOP" value="2 s" select />
</div>
)}
{proxyTab === 'audio' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Audio codec" value="AAC" select />
<Field label="Bitrate" value="128 kbps" select />
</div>
)}
{proxyTab === 'container' && (
<Field label="Container" value="fMP4 (HLS-ready)" select />
)}
</div>
</div>
)}
<div className="modal-section">
<div className="modal-section-head"><span>Destination</span></div>
<div className="modal-section-body">
<div className="field">
<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>
{submitErr && (
<div style={{
padding: '10px 14px', background: 'var(--danger-soft)',
border: '1px solid var(--danger)', borderRadius: 6,
fontSize: 12.5, color: 'var(--danger)', marginTop: 4,
}}>{submitErr}</div>
)}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<span style={{ flex: 1 }} />
<button className="btn primary" onClick={handleCreate} disabled={submitting}>
{submitting ? 'Creating…' : 'Create recorder'}
</button>
</div>
</div>
</div>
);
}
window.NewRecorderModal = NewRecorderModal;