// 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?.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 (
Upload
Drop video, audio, or stills — we proxy and index automatically.
Project
setProjectId(e.target.value)}
style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? No projects
: PROJECTS.map(p => {p.name} )}
e.preventDefault()}
onClick={() => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = true;
inp.onchange = handleDrop;
inp.click();
}}>
Drop files here or click to browse
Video, audio, and image files
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
{f} )}
{files.length > 0 && (
Queue {files.length}
setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done
{files.map(f => (
{f.name}
{f.size}
{f.status === 'error' && (
{f.error}
)}
{f.status === 'done' ? '✓ done'
: f.status === 'error' ? '✗ failed'
: f.progress + '%'}
))}
)}
);
}
/* ===== 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 (
YouTube
Paste a link — we download and import the best available MP4.
Project
setProjectId(e.target.value)}
style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? No projects
: PROJECTS.map(p => {p.name} )}
YouTube URL
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
/>
Import
{url && !valid && (
That doesn't look like a YouTube URL.
)}
Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
{queue.length > 0 && (
Queue {queue.length}
setQueue(q => q.filter(r => r.status !== 'done' && r.status !== 'error'))}>
Clear finished
{queue.map(r => {
const statusColor =
r.status === 'done' ? 'var(--success)' :
r.status === 'error' ? 'var(--danger)' : 'var(--text-3)';
return (
{r.title || r.url}
{r.title && (
{r.url}
)}
{r.error && (
{r.error}
)}
{r.status === 'done' ? '✓ done'
: r.status === 'error' ? '✗ failed'
: r.status === 'processing'? 'processing'
: r.status === 'downloading' ? 'downloading'
: 'queued'}
);
})}
)}
);
}
/* ===== 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 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 (
);
}
/* ===== 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;
console.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 (
Recorders
Live ingest from SRT, RTMP, and SDI sources
{(liveCount > 0 || errCount > 0) && (
{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}
)}
Refresh
New recorder
{recorders.length === 0 ? (
No recorders configured.
Add recorder
) : (
{recorders.map(r => )}
)}
);
}
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 (
{isRec && recorder.live_asset_id
?
: isRec
?
:
}
{recorder.name}
{recorder.status.toUpperCase()}
{recorder.source}
{recorder.url}
{recorder.codec} ·
{recorder.res}
{err &&
{err}
}
{liveStatus?.lastError && isRec && (
{liveStatus.lastError}
)}
{liveStatus?.currentFps != null && (
FPS
{Number(liveStatus.currentFps).toFixed(1)}
)}
{!isRec && (
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
?
{pending ? '…' : <> Stop>}
:
{pending ? '…' : <> Record>}
}
);
}
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 (
Capture
DeckLink SDI ingest
Refresh
No DeckLink devices found in cluster.
);
}
const active = devices[activeIdx] || devices[0];
return (
Capture
DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster
Refresh
{devices.map((d, i) => (
setActiveIdx(i)}>
{d.model || d.device || 'DeckLink'} — {d.hostname}
))}
{active.model || active.device || 'DeckLink'}
{active.hostname} · {active.ip_address}
Connect a source and click Refresh to see port status.
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, 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 (
Monitors
Multi-cam live monitoring
{[2, 3, 4].map(n => (
setGrid(n)}>{n}×{n}
))}
{feeds.length === 0 ? (
No active feeds. Start a recorder to see live video here.
) : (
{feeds.map((f, i) => )}
)}
);
}
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 (
);
}
return (
{isLive && feed.live_asset_id
?
:
}
{isLive &&
}
{isLive && REC }
{feed.status === 'stopped' && IDLE }
{feed.status === 'idle' && IDLE }
{feed.status === 'error' && ERR }
{isLive && (
)}
{feed.name}
{feed.elapsed && feed.elapsed !== '—' && {feed.elapsed} }
);
}
/* ===== 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 (
{active.length === 0 ? (
<>
Nothing scheduled right now
>
) : (
<>
On air
{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 (
{color && }
{s.name}
{rec?.name || s.recorder_id.slice(0, 8)}
{elapsed} · ends {endsAt}
);
})}
>
)}
{next ? (
<>
Next up
{next.name}
{recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)}
{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}
>
) : (
No upcoming schedules
)}
);
}
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 (
{hours.map(h => (
{h === 24 ? '' : _fmtHour(h)}
))}
);
}
// 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 (
{ 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 : '')}>
{/* 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. */}
startDrag(ev, 'body')}>
{event.name}
{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}
{isLive && ● }
{isFailed && ! }
{canDrag && (
<>
startDrag(ev, 'left')}
title="Drag to change start time" />
startDrag(ev, 'right')}
title="Drag to change end time" />
>
)}
);
}
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 (
{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} />
))}
);
}
// ── 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 (
e.stopPropagation()}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
{schedule.name}
{canEdit &&
Edit…}
{canCancel &&
Cancel run}
Copy schedule ID
{canDelete &&
}
{canDelete &&
Delete schedule}
);
}
function _NowLine({ now, dayStart, pph }) {
if (!_sameDay(now, dayStart)) return null;
const min = _minutesIntoDay(now, dayStart);
const x = (min / 60) * pph;
return (
);
}
function _RecorderGutter({ recorders, projects }) {
return (
{recorders.map(r => {
const color = _projectColor(r.project_id, projects);
const isLive = r.status === 'recording';
const isErr = r.status === 'error';
return (
{r.name}
{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && }
);
})}
);
}
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 (
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
setDay(_addDays(day, -1))} title="Previous day">
{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}
setDay(_addDays(day, 1))} title="Next day">
setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today
setView('today')}>Today
setView('week')}>Week
setView('list')}>List
Refresh
New schedule
{schedules === null && (
Loading…
)}
{schedules !== null && recorders.length === 0 && (
No recorders configured
Create a recorder before scheduling.
navigate('recorders')}>Go to Recorders
)}
{schedules !== null && recorders.length > 0 && view === 'today' && (
{_fmtDay(day)}
<_RecorderGutter recorders={recorders} projects={projects} />
<_EpgRuler pph={pph} />
{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} />
))}
<_NowLine now={now} dayStart={dayStart} pph={pph} />
)}
{schedules !== null && recorders.length > 0 && view === 'week' && (
{weekDays.map(d => {
const dEnd = _dayEnd(d);
const isToday = _sameDay(d, new Date());
return (
{_fmtDay(d)}
{isToday && today }
<_EpgRuler pph={pph} />
{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} />
))}
{isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
);
})}
)}
{schedules !== null && recorders.length > 0 && view === 'list' && (
setListFilter('upcoming')}>Upcoming
setListFilter('past')}>Past
setListFilter('all')}>All
{(listSchedules || []).length === 0 ? (
No {listFilter} schedules
) : (
Name
Recorder
Starts
Duration
Recurrence
Status
{listSchedules.map(s => {
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
return (
{s.name}
{s.error_message &&
{s.error_message}
}
{s.recorder_name || s.recorder_id.slice(0, 8)}
{_fmtWhen(s.start_at)}
{_durationMin(s.start_at, s.end_at)} min
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
{badge.label}
{s.status === 'pending' && setEditing(s)}>Edit }
{(s.status === 'pending' || s.status === 'running') && cancel(s)}>Cancel }
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') &&
remove(s)}>Delete }
);
})}
)}
)}
{showNew &&
{ setShowNew(false); setNewDefaults(null); }}
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
{editing && 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); }} />
)}
);
}
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 (
e.stopPropagation()}>
Name
set('name', e.target.value)} />
Recurrence
set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
One-shot (no repeat)
Daily
Weekly
{err &&
{err}
}
Cancel
{saving ? 'Saving…' : 'Save changes'}
);
}
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 (
e.stopPropagation()}>
Name
set('name', e.target.value)}
onKeyDown={onKey} placeholder="Morning service stream" />
Recorder
set('recorder_id', e.target.value)}
style={{ appearance: 'auto' }}>
{recorders.length === 0 && — No recorders defined — }
{recorders.map(r => (
{r.name} · {r.source_type?.toUpperCase() || '?'}
))}
Recurrence
set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
One-shot (no repeat)
Daily
Weekly
Recurring schedules queue the next occurrence as soon as the current one completes.
{err &&
{err}
}
Cancel
{saving ? 'Scheduling…' : 'Schedule recording'}
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });