dragonflight/services/web-ui/public/screens-ingest.jsx
ZGaetano 7a6113fc90 capture: live port signal presence indicators on Capture screen and nav badge
- Capture screen now polls /cluster/devices/blackmagic/signal every 3s
- Per-port chips show signal state (RECEIVING/CONNECTING/LOST/ERROR/IDLE) with pulsing dot
- BMD SVG card diagram rendered per node card
- Sidebar nav badge on Capture item shows live/total port count (pulsing green dot)
2026-05-27 13:53:32 +00:00

1996 lines
83 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.

// 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((window.ZAMPP_API_PREFIX || '/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((window.ZAMPP_API_PREFIX || '/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?.PROJECTS || [];
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 });
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.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>
);
}
/* ===== YouTube importer ===== */
// Accept the same three URL shapes the API validates against.
const _YT_PATTERNS = [
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
];
function _looksLikeYouTube(s) {
return typeof s === 'string' && _YT_PATTERNS.some(re => re.test(s.trim()));
}
function YouTubeImport({ navigate }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [url, setUrl] = React.useState('');
const [queue, setQueue] = React.useState([]); // { id, url, status, progress, title, error, assetId, jobId }
const [submitting, setSubmitting] = React.useState(false);
const valid = _looksLikeYouTube(url);
const updateRow = React.useCallback((id, patch) => {
setQueue(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r));
}, []);
// Poll the asset row to pick up the title once yt-dlp resolves it, and the
// proxy job's progress so the queue row reflects the full lifecycle, not
// just the import step.
const pollRow = React.useCallback((row) => {
if (!row.assetId) return;
let stopped = false;
const tick = async () => {
if (stopped) return;
try {
const asset = await window.ZAMPP_API.fetch('/assets/' + row.assetId);
const patch = {};
if (asset.display_name && asset.display_name !== row.url) patch.title = asset.display_name;
if (asset.status === 'ready') {
patch.status = 'done';
patch.progress = 100;
window.dispatchEvent(new CustomEvent('df:assets-changed'));
} else if (asset.status === 'error') {
patch.status = 'error';
patch.error = patch.error || 'Import failed — check the Jobs screen for details.';
} else if (asset.status === 'processing') {
patch.status = 'processing';
}
if (Object.keys(patch).length) updateRow(row.id, patch);
if (asset.status === 'ready' || asset.status === 'error') return;
} catch { /* ignore */ }
setTimeout(tick, 3000);
};
tick();
return () => { stopped = true; };
}, [updateRow]);
const submit = React.useCallback(async () => {
if (!valid || !projectId || submitting) return;
setSubmitting(true);
const rowId = Date.now();
const row = {
id: rowId,
url: url.trim(),
status: 'queued',
progress: 0,
title: '',
error: null,
assetId: null,
jobId: null,
};
setQueue(prev => [row, ...prev]);
try {
const res = await window.ZAMPP_API.fetch('/imports/youtube', {
method: 'POST',
body: JSON.stringify({ url: row.url, projectId }),
});
updateRow(rowId, { assetId: res.assetId, jobId: res.jobId, status: 'downloading' });
pollRow({ ...row, assetId: res.assetId, jobId: res.jobId });
setUrl('');
} catch (e) {
updateRow(rowId, { status: 'error', error: e.message || 'Failed to start import' });
} finally {
setSubmitting(false);
}
}, [valid, projectId, submitting, url, updateRow, pollRow]);
return (
<div className="page">
<div className="page-header">
<h1>YouTube</h1>
<span className="subtitle">Paste a link we download and import the best available MP4.</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<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="field" style={{ marginBottom: 8 }}>
<label className="field-label">YouTube URL</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="field-input mono"
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
placeholder="https://www.youtube.com/watch?v=… or https://youtu.be/…"
style={{ flex: 1 }}
autoFocus
/>
<button
className="btn primary"
onClick={submit}
disabled={!valid || !projectId || submitting}
title={!valid ? 'Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)' : ''}
>
<Icon name="download" />Import
</button>
</div>
{url && !valid && (
<div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 4 }}>
That doesn't look like a YouTube URL.
</div>
)}
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
</div>
</div>
{queue.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
Queue <span className="badge neutral">{queue.length}</span>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => setQueue(q => q.filter(r => r.status !== 'done' && r.status !== 'error'))}>
Clear finished
</button>
</div>
<div className="panel">
{queue.map(r => {
const statusColor =
r.status === 'done' ? 'var(--success)' :
r.status === 'error' ? 'var(--danger)' : 'var(--text-3)';
return (
<div key={r.id} className="upload-row">
<Icon name="link" 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, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.title || r.url}
</span>
</div>
{r.title && (
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.url}>
{r.url}
</div>
)}
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
width: (r.status === 'done' ? 100 : r.progress) + '%',
height: '100%',
background: r.status === 'done' ? 'var(--success)' : r.status === 'error' ? 'var(--danger)' : 'var(--accent)',
transition: 'width 200ms',
}} />
</div>
{r.error && (
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{r.error}</div>
)}
</div>
<span className="mono" style={{ fontSize: 11.5, minWidth: 88, textAlign: 'right', color: statusColor }}>
{r.status === 'done' ? ' done'
: r.status === 'error' ? ' failed'
: r.status === 'processing'? 'processing'
: r.status === 'downloading' ? 'downloading'
: 'queued'}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
/* ===== Live preview (HLS) ====================================
Shared by RecorderRow + MonitorTile. The capture container writes
HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
let destroyed = false;
let retryTimer = 0;
let retryCount = 0;
const MAX_RETRIES = 8;
const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } };
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
const tryLoad = () => {
if (destroyed) return;
v.removeAttribute('src');
v.load();
v.src = url;
v.play().catch(() => {});
};
const onErr = () => {
if (destroyed || retryCount >= MAX_RETRIES) { setErr('playback failed'); return; }
retryCount++;
clearRetry();
retryTimer = setTimeout(tryLoad, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
setErr('connecting');
};
v.addEventListener('error', onErr);
v.addEventListener('playing', () => { retryCount = 0; setErr(null); }, { once: false });
tryLoad();
return () => { destroyed = true; clearRetry(); v.removeEventListener('error', onErr); };
}
if (!window.Hls) { setErr('hls.js missing'); return; }
let hls = null;
const startHls = () => {
if (destroyed) return;
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (data.fatal) {
if (retryCount >= MAX_RETRIES) { setErr(data.details || 'hls error'); return; }
retryCount++;
clearRetry();
try { hls.destroy(); } catch (_) {}
hls = null;
setErr('connecting');
retryTimer = setTimeout(startHls, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
}
});
hls.on(window.Hls.Events.MANIFEST_PARSED, () => { retryCount = 0; setErr(null); });
};
startHls();
v.play().catch(() => {});
return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } };
}, [assetId]);
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted={muted}
controls={controls}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
{err}
</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, '00') + ':' +
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(err => {
// apiFetch already redirects on 401 — don't log noise, interval
// will be cleared automatically when the component unmounts on redirect (#55)
if (err && err.message && err.message.includes('Unauthenticated')) return;
window.DF_LOG.warn('[recorders] poll error:', err?.message);
});
}, []);
React.useEffect(() => {
refresh();
const id = setInterval(refresh, 10000);
// Any screen that creates/starts/stops/deletes a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = () => refresh();
window.addEventListener('df:recorders-changed', onChange);
return () => {
clearInterval(id);
window.removeEventListener('df:recorders-changed', onChange);
};
}, [refresh]);
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 [clipName, setClipName] = React.useState('');
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' }));
// Ship the operator-typed clip name on start; stop has no body.
const body = (action === 'start' && clipName.trim())
? JSON.stringify({ clipName: clipName.trim() })
: undefined;
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
.then(() => {
setPending(false);
// Clear the input on a successful stop so the next take starts fresh.
if (action === 'stop') setClipName('');
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
// Stopping a recorder flips its asset from 'live' to 'ready' on the
// server side; tell the library/dashboard to re-pull.
if (action === 'stop') {
window.dispatchEvent(new CustomEvent('df:assets-changed'));
}
})
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = () => {
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => {
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.catch(e => setErr(e.message || 'Delete failed'));
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
: 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 signal-val" style={{ fontSize: 11, color: signalColor }}>
<span className={'signal-dot ' + displaySignal} style={{ background: 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 && (
<input
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
)}
{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" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function _captureSignalChip(sig) {
switch (sig) {
case 'receiving': return { label: 'RECEIVING', color: 'var(--success)', pulse: true };
case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)', pulse: true };
case 'lost': return { label: 'LOST', color: 'var(--danger)', pulse: false };
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
default: return { label: sig || '—', color: 'var(--text-4)', pulse: false };
}
}
function CapturePortChip({ port, sigEntry }) {
const sig = sigEntry ? sigEntry.signal : null;
const { label, color, pulse } = _captureSignalChip(sig);
const isReceiving = sig === 'receiving';
const portLabel = port.device ? port.device.split('/').pop() : `port ${port.index}`;
return (
<div
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}${label}` : label}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 5,
background: isReceiving ? 'rgba(45,212,168,0.08)' : 'var(--bg-2)',
border: `1px solid ${isReceiving ? 'rgba(45,212,168,0.35)' : 'var(--border)'}`,
minWidth: 120,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: sig ? color : 'var(--text-4)',
animation: pulse ? 'signalPulse 1.4s ease-in-out infinite' : 'none',
boxShadow: isReceiving ? `0 0 6px ${color}` : 'none',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--text-2)', fontWeight: 600 }}>
{portLabel}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 1 }}>
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
{label}
</span>
{sigEntry && sigEntry.currentFps != null && (
<span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
</div>
</div>
);
}
function CaptureNodeCard({ node, ports, portSignals }) {
const svgRef = React.useRef(null);
const nodeSignalMap = React.useMemo(() => {
const map = new Map();
ports.forEach(p => {
const entry = portSignals[`${node.node_id}:${p.index}`];
if (entry) map.set(p.index, entry.signal);
});
return map;
}, [node.node_id, ports, portSignals]);
React.useEffect(() => {
if (!svgRef.current || !window.BMDCards || ports.length === 0) return;
svgRef.current.innerHTML = '';
const svg = window.BMDCards.render({
model: ports[0].model || '',
deviceCount: ports.length,
compact: true,
portSignals: nodeSignalMap,
});
if (svg) svgRef.current.appendChild(svg);
}, [node.node_id, ports.length, nodeSignalMap]);
const receivingCount = ports.filter(p => {
const e = portSignals[`${node.node_id}:${p.index}`];
return e && e.signal === 'receiving';
}).length;
return (
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
{/* Node header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-2)',
}}>
<StatusDot status={node.online !== false ? 'online' : 'offline'} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{ports[0].model || 'DeckLink'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>
{node.hostname}{node.ip_address ? ` · ${node.ip_address}` : ''}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{receivingCount > 0 && (
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 3,
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
animation: 'signalPulse 1.4s ease-in-out infinite',
}}>
{receivingCount} LIVE
</span>
)}
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3,
background: 'rgba(91,124,250,0.12)', color: 'var(--accent)',
}}>
{ports.length} PORT{ports.length !== 1 ? 'S' : ''}
</span>
</div>
</div>
{/* Port chips */}
<div style={{ padding: '14px 16px', display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{ports.map(p => (
<CapturePortChip
key={p.index}
port={p}
sigEntry={portSignals[`${node.node_id}:${p.index}`] || null}
/>
))}
</div>
{/* BMD card SVG diagram */}
{window.BMDCards && (
<div ref={svgRef} className="bmd-card-diagram" style={{ padding: '0 16px 14px' }} />
)}
</div>
);
}
function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [portSignals, setPortSignals] = React.useState({});
const [lastPoll, setLastPoll] = React.useState(null);
// Group devices by node
const nodeGroups = React.useMemo(() => {
const map = new Map();
devices.forEach(d => {
const key = d.node_id || d.hostname || 'unknown';
if (!map.has(key)) map.set(key, { node_id: d.node_id, hostname: d.hostname, ip_address: d.ip_address, online: d.online, ports: [] });
map.get(key).ports.push(d);
});
return Array.from(map.values());
}, [devices]);
// Load device list once (changes rarely)
const loadDevices = React.useCallback(() => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
}, []);
// Poll signal state every 3s
React.useEffect(() => {
const poll = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
.then(entries => {
const map = {};
(entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; });
setPortSignals(map);
setLastPoll(new Date());
})
.catch(() => {});
};
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, []);
React.useEffect(() => { loadDevices(); }, []);
const totalPorts = devices.length;
const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length;
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
{totalPorts > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 8 }}>
{receivingPorts > 0 && (
<span style={{
fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4,
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
animation: 'signalPulse 1.4s ease-in-out infinite',
}}>
{receivingPorts}/{totalPorts} LIVE
</span>
)}
{lastPoll && (
<span style={{ fontSize: 10.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
updated {lastPoll.toLocaleTimeString()}
</span>
)}
</div>
)}
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
{totalPorts === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No DeckLink devices found in cluster.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{nodeGroups.map(node => (
<CaptureNodeCard
key={node.node_id || node.hostname}
node={node}
ports={node.ports}
portSignals={portSignals}
/>
))}
</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">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
: <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>
);
}
/* ===== Schedule ===== */
const _STATUS_BADGE = {
pending: { cls: 'neutral', label: 'pending' },
running: { cls: 'success', label: 'recording' },
completed: { cls: 'accent', label: 'completed' },
cancelled: { cls: 'warning', label: 'cancelled' },
failed: { cls: 'danger', label: 'failed' },
};
function _fmtWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function _durationMin(startISO, endISO) {
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}
// ── EPG (timeline) helpers ───────────────────────────────────────────────────
//
// The Schedule screen is a broadcast-control-room timeline: recorders are
// rows, time is the horizontal axis. Helpers below convert between Date and
// "minutes into local day" so we can position absolute-positioned event
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
//
function _dayStart(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function _dayEnd(d) {
const x = _dayStart(d);
x.setDate(x.getDate() + 1);
return x;
}
function _addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function _minutesIntoDay(date, dayStart) {
return Math.max(0, Math.min(24 * 60, (date - dayStart) / 60000));
}
function _eventOverlapsDay(ev, dayStart, dayEnd) {
const s = new Date(ev.start_at);
const e = new Date(ev.end_at);
return s < dayEnd && e > dayStart;
}
function _sameDay(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
function _fmtDay(d) {
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function _fmtTime(d) {
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function _fmtHour(h) {
// 0 → "12 AM", 12 → "12 PM", 18 → "6 PM"
const ampm = h < 12 ? 'AM' : 'PM';
const hr = ((h + 11) % 12) + 1;
return hr + ' ' + ampm;
}
function _fmtCountdown(ms) {
if (ms <= 0) return 'now';
const s = Math.floor(ms / 1000);
if (s < 60) return 'in ' + s + 's';
const m = Math.floor(s / 60);
if (m < 60) return 'in ' + m + 'm';
const h = Math.floor(m / 60);
return 'in ' + h + 'h ' + (m % 60) + 'm';
}
function _fmtElapsed(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
String(s % 60).padStart(2, '0');
}
// Pick a stable color for a project_id given the global PROJECTS list.
function _projectColor(projectId, projects) {
if (!projectId) return null;
const p = (projects || []).find(p => p.id === projectId);
return p?.color || null;
}
// ── EPG components ───────────────────────────────────────────────────────────
function _StatusStrip({ schedules, recorders, now, projects }) {
// What's recording: any schedule whose window contains `now` AND whose
// status is 'running'. (Manually-started recorders without a schedule
// surface on the Recorders screen; the schedule strip stays focused on
// planned events so the operator can trust it.)
const active = (schedules || []).filter(s => {
const start = new Date(s.start_at);
const end = new Date(s.end_at);
return start <= now && now < end && (s.status === 'running' || s.status === 'pending');
});
// Next up: earliest pending schedule strictly in the future.
const upcoming = (schedules || [])
.filter(s => s.status === 'pending' && new Date(s.start_at) > now)
.sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
const next = upcoming[0];
const recMap = {};
(recorders || []).forEach(r => { recMap[r.id] = r; });
return (
<div className="epg-status">
<div className="epg-status-row">
{active.length === 0 ? (
<>
<span className="epg-status-dot idle" />
<span className="epg-status-label">Nothing scheduled right now</span>
</>
) : (
<>
<span className="epg-status-dot live" />
<span className="epg-status-label">On air</span>
<div className="epg-status-active">
{active.map(s => {
const rec = recMap[s.recorder_id];
const elapsed = _fmtElapsed(now - new Date(s.start_at));
const endsAt = _fmtTime(new Date(s.end_at));
const color = _projectColor(rec?.project_id, projects);
return (
<span key={s.id} className="epg-status-pill">
{color && <span className="epg-status-pill-bar" style={{ background: color }} />}
<span className="epg-status-pill-name">{s.name}</span>
<span className="epg-status-pill-rec mono">{rec?.name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</span>
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
</span>
);
})}
</div>
</>
)}
</div>
<div className="epg-status-row sub">
{next ? (
<>
<span className="epg-status-label muted">Next up</span>
<span className="epg-status-next">{next.name}</span>
<span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || (next.recorder_id ? next.recorder_id.slice(0, 8) : 'unassigned')}</span>
<span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
</>
) : (
<span className="epg-status-label muted">No upcoming schedules</span>
)}
</div>
</div>
);
}
function _EpgRuler({ pph }) {
// 25 ticks so the last one (24:00) labels the right edge. The 24h column
// ends at 24*pph; the 25th tick is purely a label and has zero width.
const hours = [];
for (let h = 0; h <= 24; h++) hours.push(h);
return (
<div className="epg-ruler" style={{ width: 24 * pph }}>
{hours.map(h => (
<div key={h} className={'epg-ruler-tick ' + (h === 24 ? 'end' : '')}
style={{ left: h * pph }}>
<span>{h === 24 ? '' : _fmtHour(h)}</span>
</div>
))}
</div>
);
}
// Minimum schedule length the UI permits while dragging. Anything shorter
// rarely reflects a real plan and would let the operator accidentally
// dismiss a block to zero width.
const _EPG_MIN_MS = 5 * 60 * 1000;
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
// block lines up to the same grid an operator just placed a new one on.
const _EPG_SNAP_MIN = 15;
// Pointer travel (in px) before we treat the gesture as a drag rather
// than a click. Below this, pointerup fires the click handler.
const _EPG_DRAG_THRESHOLD = 4;
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
// Drag state: null when idle, otherwise the in-flight resize/move
// describing the original times and the current (snapped) preview times.
// We render from this preview while dragging so the block follows the
// cursor without round-tripping through props.
const [drag, setDrag] = React.useState(null);
const blockRef = React.useRef(null);
const eventStartMs = new Date(event.start_at).getTime();
const eventEndMs = new Date(event.end_at).getTime();
const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
const isFailed = event.status === 'failed';
const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
const color = _projectColor(recorder?.project_id, projects);
// Only pending schedules can be resized. The API rejects PUTs against
// running schedules outright; cancelling them is what an operator
// actually wants there. Terminal statuses are read-only.
const canDrag = event.status === 'pending';
const startDrag = (e, type) => {
if (!canDrag) return;
if (e.button !== 0) return; // ignore right-click
e.stopPropagation();
try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
setDrag({
type, pointerId: e.pointerId,
startX: e.clientX,
origStart: eventStartMs, origEnd: eventEndMs,
currStart: eventStartMs, currEnd: eventEndMs,
moved: false,
});
};
const onPointerMove = (ev) => {
if (!drag) return;
const dx = ev.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
const snapMs = _EPG_SNAP_MIN * 60 * 1000;
const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
const dayStartMs = dayStart.getTime();
const dayEndMs = dayEnd.getTime();
let cs = drag.origStart, ce = drag.origEnd;
if (drag.type === 'left') {
cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
} else if (drag.type === 'right') {
ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
} else if (drag.type === 'body') {
cs = drag.origStart + dMs;
ce = drag.origEnd + dMs;
// Clamp to the day; preserve duration when bumping against an edge.
if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; }
}
setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
};
const endDrag = (ev) => {
if (!drag) return;
try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
const d = drag;
setDrag(null);
if (!d.moved) {
// Treat as a click — open the edit modal.
onClick(event);
return;
}
if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
};
// Render from drag preview while a gesture is in flight so the block
// tracks the pointer; otherwise from the canonical event prop.
const dispStartMs = drag ? drag.currStart : eventStartMs;
const dispEndMs = drag ? drag.currEnd : eventEndMs;
const dispStart = new Date(dispStartMs);
const dispEnd = new Date(dispEndMs);
const startMin = _minutesIntoDay(dispStart, dayStart);
const endMin = _minutesIntoDay(dispEnd, dayStart);
const left = (startMin / 60) * pph;
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
const classes = ['epg-block'];
if (isLive) classes.push('live');
if (isFailed) classes.push('failed');
else if (isPast) classes.push('past');
if (drag && drag.moved) classes.push('dragging');
if (canDrag) classes.push('resizable');
return (
<div
ref={blockRef}
className={classes.join(' ')}
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
<span className="epg-block-bar" />
{/* Body click → edit, body drag → move. We hang the click on pointerup
so the threshold check above can demote a drag back to a click. */}
<div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
<span className="epg-block-name">{event.name}</span>
<span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
{isLive && <span className="epg-block-glyph live" title="on air"></span>}
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
</div>
{canDrag && (
<>
<span className="epg-block-handle left"
onPointerDown={(ev) => startDrag(ev, 'left')}
title="Drag to change start time" />
<span className="epg-block-handle right"
onPointerDown={(ev) => startDrag(ev, 'right')}
title="Drag to change end time" />
</>
)}
</div>
);
}
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
const handleRowPointerUp = (e) => {
// Open the new-schedule modal only on a real click in the empty
// gutter. Clicks on event blocks stopPropagation themselves; we also
// guard against the tail of a block-drag bubbling up here.
if (e.target !== e.currentTarget) return;
if (e.button !== 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
const start = new Date(dayStart);
start.setMinutes(minutes);
onEmptyClick(recorder, start);
};
return (
<div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
{dayEvents.map(s => (
<_EventBlock
key={s.id}
event={s}
recorder={recorder}
dayStart={dayStart}
dayEnd={dayEnd}
pph={pph}
now={now}
projects={projects}
onClick={onEventClick}
onContextMenu={onEventContextMenu}
onResize={onEventResize} />
))}
</div>
);
}
// ── Right-click menu for an EPG event block ─────────────────────────────────
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
// dismissed on outside click. Per-status action filtering mirrors the
// buttons rendered in the List view so the two surfaces stay consistent.
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState({ left: x, top: y });
React.useLayoutEffect(() => {
if (!ref.current) return;
const r = ref.current.getBoundingClientRect();
const margin = 8;
let nx = x, ny = y;
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
}, [x, y]);
const canEdit = schedule.status === 'pending' || schedule.status === 'failed';
const canCancel = schedule.status === 'pending' || schedule.status === 'running';
const canDelete = schedule.status !== 'running';
return (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{schedule.name}</div>
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit</button>}
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
{canDelete && <div className="ctx-divider" />}
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
</div>
);
}
function _NowLine({ now, dayStart, pph }) {
if (!_sameDay(now, dayStart)) return null;
const min = _minutesIntoDay(now, dayStart);
const x = (min / 60) * pph;
return (
<div className="epg-now" style={{ left: x }}>
<span className="epg-now-pip" />
</div>
);
}
function _RecorderGutter({ recorders, projects }) {
return (
<div className="epg-gutter-rows">
{recorders.map(r => {
const color = _projectColor(r.project_id, projects);
const isLive = r.status === 'recording';
const isErr = r.status === 'error';
return (
<div key={r.id} className="epg-gutter-row">
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
<div className="epg-gutter-meta">
<div className="epg-gutter-name">{r.name}</div>
<div className="epg-gutter-sub mono">{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
</div>
</div>
);
})}
</div>
);
}
function Schedule({ navigate }) {
const [schedules, setSchedules] = React.useState(null);
const [recorders, setRecorders] = React.useState([]);
const [showNew, setShowNew] = React.useState(false);
const [newDefaults, setNewDefaults] = React.useState(null);
const [editing, setEditing] = React.useState(null);
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
const [day, setDay] = React.useState(() => _dayStart(new Date()));
const [listFilter, setListFilter] = React.useState('upcoming');
const [now, setNow] = React.useState(() => new Date());
// Tick the now-line every second. We only re-render the components that
// consume `now`; the rest are React.memo or insensitive.
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
// Schedule data — pull everything once and filter client-side for the
// active view. /schedules caps at 200 rows so this stays cheap.
const apiFilter = view === 'list' ? listFilter : 'all';
const load = React.useCallback(() => {
window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
.then(d => setSchedules(d.schedules || []))
.catch(() => setSchedules([]));
}, [apiFilter]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([]));
}, []);
// Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
// catches transitions promptly.
React.useEffect(() => {
const anyLive = (schedules || []).some(s => s.status === 'running');
const id = setInterval(load, anyLive ? 4000 : 10_000);
return () => clearInterval(id);
}, [load, schedules]);
// The Recorders screen broadcasts these on create/delete; refresh the
// gutter so renamed or new recorders show up immediately.
React.useEffect(() => {
const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {});
window.addEventListener('df:recorders-changed', refresh);
return () => window.removeEventListener('df:recorders-changed', refresh);
}, []);
const projects = window.ZAMPP_DATA?.PROJECTS || [];
// Pixels per hour — wider on Today (high-res operations view), tighter
// when the user is scanning Week-at-a-glance.
const pph = view === 'week' ? 44 : 88;
const dayStart = _dayStart(day);
const dayEnd = _dayEnd(day);
// Scroll the canvas so "now" sits ~30% from the left edge on first paint
// for Today view. Re-runs when the user jumps days via the Today button.
const canvasRef = React.useRef(null);
React.useLayoutEffect(() => {
if (view !== 'today' || !canvasRef.current) return;
if (!_sameDay(now, dayStart)) {
canvasRef.current.scrollLeft = 0;
return;
}
const min = _minutesIntoDay(now, dayStart);
const x = (min / 60) * pph;
const target = Math.max(0, x - canvasRef.current.clientWidth * 0.3);
canvasRef.current.scrollLeft = target;
// Deliberately only re-run on view/day change, not on `now` ticking.
// Otherwise the canvas would re-scroll every second and trap the user.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, day, pph, recorders.length]);
const openNewAt = (recorder, start) => {
const end = new Date(start.getTime() + 30 * 60 * 1000);
setNewDefaults({ start, end, recorder_id: recorder.id });
setShowNew(true);
};
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
const cancel = (s) => {
if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return;
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
};
const remove = (s) => {
if (!confirm('Delete schedule "' + s.name + '"?')) return;
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
};
// Drag-resize commit: optimistically patch the in-memory schedule so the
// block stays put after the user lets go, then PUT the new times. The
// refetch reconciles in case the server adjusted anything (or rejected).
const handleResize = (s, newStart, newEnd) => {
setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
window.ZAMPP_API.fetch('/schedules/' + s.id, {
method: 'PUT',
body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
})
.then(load)
.catch(e => { alert('Resize failed: ' + e.message); load(); });
};
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
// Dismiss the context menu on any outside click — capture phase so a
// click on a menu item still fires before the menu unmounts.
React.useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctxMenu]);
const copyId = (id) => {
if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
};
// Days for Week view: the 7-day window starting at the Sunday of `day`.
const weekDays = React.useMemo(() => {
const sun = _addDays(dayStart, -dayStart.getDay());
return Array.from({ length: 7 }, (_, i) => _addDays(sun, i));
}, [dayStart]);
// List view filters schedules by client-side time bucket too.
const listSchedules = React.useMemo(() => {
if (!schedules) return null;
return [...schedules].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
}, [schedules]);
return (
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
<div className="epg-toolbar">
<div className="epg-date">
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day" aria-label="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day" aria-label="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
</div>
<div className="spacer" />
<div className="tab-group">
<button className={view === 'today' ? 'active' : ''} onClick={() => setView('today')}>Today</button>
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}>List</button>
</div>
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
<Icon name="plus" />New schedule
</button>
</div>
{schedules === null && (
<div className="epg-empty">Loading</div>
)}
{schedules !== null && recorders.length === 0 && (
<div className="epg-empty">
<div className="epg-empty-title">No recorders configured</div>
<div className="epg-empty-sub">Create a recorder before scheduling.</div>
<button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'today' && (
<div className="epg" ref={canvasRef}>
<div className="epg-corner">
<span className="mono">{_fmtDay(day)}</span>
</div>
<div className="epg-gutter">
<_RecorderGutter recorders={recorders} projects={projects} />
</div>
<div className="epg-canvas-head">
<_EpgRuler pph={pph} />
</div>
<div className="epg-canvas" style={{ width: 24 * pph }}>
<div className="epg-rows">
{recorders.map(r => (
<_EpgRow key={r.id}
recorder={r}
schedules={schedules}
dayStart={dayStart}
dayEnd={dayEnd}
pph={pph}
now={now}
projects={projects}
onEventClick={(s) => setEditing(s)}
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} />
))}
</div>
<_NowLine now={now} dayStart={dayStart} pph={pph} />
</div>
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'week' && (
<div className="epg-week">
{weekDays.map(d => {
const dEnd = _dayEnd(d);
const isToday = _sameDay(d, new Date());
return (
<div key={d.toISOString()} className={'epg-week-day' + (isToday ? ' today' : '')}>
<div className="epg-week-dayhead">
<span className="epg-week-dayname">{_fmtDay(d)}</span>
{isToday && <span className="epg-week-todaypip">today</span>}
</div>
<div className="epg-week-row-wrap" style={{ width: 24 * pph }}>
<_EpgRuler pph={pph} />
<div className="epg-rows">
{recorders.map(r => (
<_EpgRow key={r.id}
recorder={r}
schedules={schedules}
dayStart={d}
dayEnd={dEnd}
pph={pph}
now={now}
projects={projects}
onEventClick={(s) => setEditing(s)}
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} />
))}
</div>
{isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
</div>
</div>
);
})}
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'list' && (
<div className="epg-list">
<div className="tab-group" style={{ marginBottom: 12 }}>
<button className={listFilter === 'upcoming' ? 'active' : ''} onClick={() => setListFilter('upcoming')}>Upcoming</button>
<button className={listFilter === 'past' ? 'active' : ''} onClick={() => setListFilter('past')}>Past</button>
<button className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All</button>
</div>
{(listSchedules || []).length === 0 ? (
<div className="epg-empty"><div className="epg-empty-title">No {listFilter} schedules</div></div>
) : (
<div className="panel">
<div className="schedule-row head">
<div>Name</div><div>Recorder</div><div>Starts</div><div>Duration</div><div>Recurrence</div><div>Status</div><div></div>
</div>
{listSchedules.map(s => {
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
return (
<div key={s.id} className="schedule-row">
<div style={{ fontWeight: 500, fontSize: 13 }}>
{s.name}
{s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>}
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
<div><span className={'badge ' + badge.cls}>{badge.label}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
{s.status === 'pending' && <button className="btn ghost sm" onClick={() => setEditing(s)}>Edit</button>}
{(s.status === 'pending' || s.status === 'running') && <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>}
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') &&
<button className="btn ghost sm" onClick={() => remove(s)}>Delete</button>}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{showNew && <NewScheduleModal
recorders={recorders}
defaultRecorderId={newDefaults?.recorder_id}
defaultStart={newDefaults?.start}
defaultEnd={newDefaults?.end}
onClose={() => { setShowNew(false); setNewDefaults(null); }}
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
{ctxMenu && (
<_ScheduleContextMenu
schedule={ctxMenu.schedule}
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
)}
</div>
);
}
function EditScheduleModal({ schedule, onClose, onSaved }) {
const toLocalInput = (iso) => {
const d = new Date(iso);
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const [form, setForm] = React.useState({
name: schedule.name,
start_at: toLocalInput(schedule.start_at),
end_at: toLocalInput(schedule.end_at),
recurrence: schedule.recurrence || 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
setSaving(true);
window.ZAMPP_API.fetch('/schedules/' + schedule.id, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
}),
})
.then(onSaved)
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)} />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
style={{ color: 'var(--text-3)' }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned — delete + recreate to change.</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at} onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at} onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Saving' : 'Save changes'}</button>
</div>
</div>
</div>
);
}
function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd, defaultRecorderId }) {
// If the user clicked a day on the calendar we honour that; otherwise default
// to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
const toLocalInput = (d) => {
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const now = new Date();
now.setSeconds(0, 0);
const startDefault = defaultStart || new Date(now.getTime() + 5 * 60 * 1000);
const endDefault = defaultEnd || new Date(startDefault.getTime() + 30 * 60 * 1000);
const [form, setForm] = React.useState({
name: '',
recorder_id: defaultRecorderId || recorders[0]?.id || '',
start_at: toLocalInput(startDefault),
end_at: toLocalInput(endDefault),
recurrence: 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
if (!form.recorder_id) return setErr('Pick a recorder');
if (!form.start_at) return setErr('Start time is required');
if (!form.end_at) return setErr('End time is required');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
// Warn (but allow) start times in the past — the scheduler tick will fire
// them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now").
if (startD < new Date(Date.now() - 60_000)) {
if (!confirm('Start time is in the past recorder will fire immediately when saved.\nContinue?')) return;
}
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name: form.name.trim(),
recorder_id: form.recorder_id,
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
};
setSaving(true);
window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
.then(() => onCreated())
.catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
};
const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)}
onKeyDown={onKey} placeholder="Morning service stream" />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<select className="field-input" value={form.recorder_id}
onChange={e => set('recorder_id', e.target.value)}
style={{ appearance: 'auto' }}>
{recorders.length === 0 && <option value=""> No recorders defined </option>}
{recorders.map(r => (
<option key={r.id} value={r.id}>
{r.name} · {r.source_type?.toUpperCase() || '?'}
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at}
onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at}
onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recurring schedules queue the next occurrence as soon as the current one completes.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>
{saving ? 'Scheduling…' : 'Schedule recording'}
</button>
</div>
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });