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

264 lines
12 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 } = 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 [sdiPort, setSdiPort] = React.useState(1);
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; }
setSubmitting(true);
setSubmitErr(null);
const body = {
name: name.trim(),
source_type: sourceType.toLowerCase(),
source_config: sourceType === 'SRT' ? { url: srtUrl }
: sourceType === 'RTMP' ? { url: rtmpUrl }
: { port: sdiPort },
project_id: projectId || undefined,
generate_proxy: proxyOn,
};
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 }}>
The 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 }}>
The recorder will pull this RTMP stream.
</div>
</div>
)}
{sourceType === 'SDI' && (
<div className="field">
<label className="field-label">Capture device &amp; port</label>
{sdiDevices === null && (
<div style={{ padding: '12px 0', color: 'var(--text-3)', fontSize: 12 }}>Loading DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<div style={{ padding: '12px 0', color: 'var(--text-3)', fontSize: 12 }}>
No DeckLink devices found in cluster. Ensure the capture node is online.
</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<div className="sdi-port-mini">
{sdiDevices.map((dev, di) => (
<div key={di} className="sdi-mini-card" style={{ marginBottom: 8 }}>
<div className="sdi-mini-label">{(dev.model || dev.device || 'DECKLINK').toUpperCase()} · {dev.hostname}</div>
{(dev.ports || Array.from({ length: dev.port_count || 2 }, (_, i) => ({ idx: i + 1, label: 'SDI ' + (i + 1) }))).map(p => (
<button key={p.idx}
className={`sdi-mini-port ${sdiPort === p.idx && di === 0 ? 'active' : ''} ${p.active ? 'live' : ''}`}
onClick={() => setSdiPort(p.idx)}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>SDI {p.idx}</span>
{p.label && <span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>{p.label}</span>}
{p.active && <span className="badge success" style={{ marginLeft: 'auto' }}>{p.signal || 'SIGNAL'}</span>}
</button>
))}
</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 subtle" onClick={() => {
const url = sourceType === 'SRT' ? srtUrl : sourceType === 'RTMP' ? rtmpUrl : null;
if (url) window.ZAMPP_API.fetch('/recorders/probe', { method: 'POST', body: JSON.stringify({ url, source_type: sourceType.toLowerCase() }) }).catch(() => {});
}}><Icon name="signal" />Probe source</button>
<button className="btn primary" onClick={handleCreate} disabled={submitting}>
{submitting ? 'Creating…' : 'Create recorder'}
</button>
</div>
</div>
</div>
);
}
window.NewRecorderModal = NewRecorderModal;