dragonflight/services/web-ui/public/screens-ingest.jsx
Zac Gaetano 9ad88e4df4 feat(ingest): YouTube importer — paste link, asset travels normal pipeline
Adds Ingest → YouTube. UI takes a URL + project, API enqueues a BullMQ
"import" job, worker shells out to yt-dlp, lands the MP4 in S3 at the
same originals/{assetId}/... path uploads use, then hands off to the
existing proxy queue. Imported assets share one lifecycle with uploads
from that point on.

Worker container picks up yt-dlp + python3 (apk on alpine, apt on the
GPU variant). The new 'import' queue is registered in jobs.js so it
appears in the Jobs SSE stream and retry/delete work for free.

Spec: docs/superpowers/specs/2026-05-23-youtube-importer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:05:41 -04:00

1591 lines
66 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('/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 }))
.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;
} 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;
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
const onErr = () => setErr('playback failed');
v.addEventListener('error', onErr);
return () => v.removeEventListener('error', onErr);
}
if (!window.Hls) { setErr('hls.js missing'); return; }
const 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) setErr(data.details || 'hls error');
});
return () => { 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(() => {});
}, []);
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" 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 Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [activeIdx, setActiveIdx] = React.useState(0);
const loadDevices = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
};
React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No DeckLink devices found in cluster.
</div>
</div>
</div>
);
}
const active = devices[activeIdx] || devices[0];
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
{devices.map((d, i) => (
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
{d.model || d.device || 'DeckLink'} — {d.hostname}
</button>
))}
</div>
<div className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<StatusDot status={active.online ? 'online' : 'offline'} />
<div>
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
</div>
</div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
</div>
</div>
</div>
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const videoFeeds = recorders.filter(r => !r.audio);
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
const feeds = allFeeds.slice(0, grid * grid);
return (
<div className="page">
<div className="page-header">
<h1>Monitors</h1>
<span className="subtitle">Multi-cam live monitoring</span>
<div className="spacer" />
<div className="tab-group">
{[2, 3, 4].map(n => (
<button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
))}
</div>
</div>
<div className="page-body">
{feeds.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
)}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';
React.useEffect(() => {
if (!isLive) return;
const id = setInterval(() => {
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
}, 180);
return () => clearInterval(id);
}, [isLive]);
if (feed.kind === 'audio') {
return (
<div className="monitor-tile audio">
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
<Waveform seed={seed * 7} color="var(--accent)" />
</div>
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
<div className="monitor-tile-label">
<span className="badge live">LIVE</span>
<span className="name">{feed.name}</span>
</div>
</div>
);
}
return (
<div className="monitor-tile">
{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.slice(0, 8)}</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.slice(0, 8)}</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>
);
}
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) {
const s = new Date(event.start_at);
const e = new Date(event.end_at);
const startMin = _minutesIntoDay(s, dayStart);
const endMin = _minutesIntoDay(e, dayStart);
const left = (startMin / 60) * pph;
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
const isLive = event.status === 'running' || (event.status === 'pending' && s <= now && now < e);
const isFailed = event.status === 'failed';
const isPast = (event.status === 'completed' || event.status === 'cancelled') || e < now;
const color = _projectColor(recorder?.project_id, projects);
const classes = ['epg-block'];
if (isLive) classes.push('live');
if (isFailed) classes.push('failed');
else if (isPast) classes.push('past');
return (
<button
className={classes.join(' ')}
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
onClick={(ev) => { ev.stopPropagation(); onClick(event); }}
title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}>
<span className="epg-block-bar" />
<span className="epg-block-name">{event.name}</span>
<span className="epg-block-time mono">{_fmtTime(s)}</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>}
</button>
);
}
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick }) {
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
const handleRowClick = (e) => {
// Translate clicked x to a Date in this row's day. Snap to 15-minute
// increments so the resulting modal pre-fill looks intentional.
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 / 15) * 15));
const start = new Date(dayStart);
start.setMinutes(minutes);
onEmptyClick(recorder, start);
};
return (
<div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}>
{dayEvents.map(s => (
<_EventBlock
key={s.id}
event={s}
recorder={recorder}
dayStart={dayStart}
dayEnd={dayEnd}
pph={pph}
now={now}
projects={projects}
onClick={onEventClick} />
))}
</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 [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));
};
// 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"><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"><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)}
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)}
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.slice(0, 8)}</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(); }} />}
</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" 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" 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 });