dragonflight/services/web-ui/public/screens-editor.jsx
ZGaetano 07f8ffa6d5 feat: editor coming-soon bumper + embedded Premiere panel downloads
- Editor: overlay Coming Soon screen over NLE timeline (code preserved,
  bumper sits at z-index 100 with backdrop blur). Links to download
  ZXP and Windows installer directly from the bumper.

- Settings → Capture SDKs: new Premiere Panel section lists v1.0.0
  and v1.0.1 with ZXP + Windows Installer download buttons.
  Both releases embedded as static files in web-ui under /downloads/.

- nginx: /downloads/ location serves files as Content-Disposition
  attachment with 24h cache.

Files added:
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe
2026-05-26 14:34:28 +00:00

729 lines
No EOL
33 KiB
JavaScript

// screens-editor.jsx — NLE timeline editor
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
function Editor() {
const [projectId, setProjectId] = React.useState(null);
const [sequences, setSequences] = React.useState([]);
const [currentSeq, setCurrentSeq] = React.useState(null);
const [assets, setAssets] = React.useState([]);
const [bins, setBins] = React.useState([]);
const [sourceAsset, setSourceAsset] = React.useState(null);
const [srcIn, setSrcIn] = React.useState(null);
const [srcOut, setSrcOut] = React.useState(null);
const [playing, setPlaying] = React.useState(false);
const [playheadFrames, setPlayheadFrames] = React.useState(0);
const [saveStatus, setSaveStatus] = React.useState('');
const [selectedClipId, setSelectedClipId] = React.useState(null);
const [history, setHistory] = React.useState([[]]);
const [historyIdx, setHistoryIdx] = React.useState(0);
const [scale, setScale] = React.useState(100);
const [tool, setTool] = React.useState('select');
const [seqFilter, setSeqFilter] = React.useState('');
const [showNewSeq, setShowNewSeq] = React.useState(false);
const [newSeqName, setNewSeqName] = React.useState('');
const [isDirty, setIsDirty] = React.useState(false);
const [insertTrack, setInsertTrack] = React.useState(0);
const [renamingSeq, setRenamingSeq] = React.useState(false);
const [renameVal, setRenameVal] = React.useState('');
const [clipMenu, setClipMenu] = React.useState(null);
const srcVideoRef = React.useRef(null);
const pgmVideoRef = React.useRef(null);
const tlRef = React.useRef(null);
const saveTimerRef = React.useRef(null);
const streamCacheRef = React.useRef({});
const tlInitRef = React.useRef(false);
// Refs so Timeline callbacks always read current values without stale closure issues
const historyRef = React.useRef([[]]);
const historyIdxRef = React.useRef(0);
const currentSeqRef = React.useRef(null);
const isDirtyRef = React.useRef(false);
const assetsRef = React.useRef([]);
React.useEffect(() => {
const data = window.ZAMPP_DATA;
setAssets(data.ASSETS || []);
setBins(data.BINS || []);
const pid = data.PROJECTS && data.PROJECTS.length ? data.PROJECTS[0].id : null;
setProjectId(pid);
if (pid) loadSequences(pid);
}, []);
React.useEffect(() => { currentSeqRef.current = currentSeq; }, [currentSeq]);
React.useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]);
React.useEffect(() => { assetsRef.current = assets; }, [assets]);
React.useEffect(() => {
const refreshAssets = function() {
window.ZAMPP_API.refreshAssets()
.then(function(list) { setAssets(list); })
.catch(function() {});
window.ZAMPP_API.fetch('/bins')
.then(function(list) { setBins((list || []).map(function(b) { return { ...b, count: b.asset_count || 0, icon: b.type || 'grid' }; })); })
.catch(function() {});
};
const onAssetsChanged = function() { refreshAssets(); };
window.addEventListener('df:assets-changed', onAssetsChanged);
return function() { window.removeEventListener('df:assets-changed', onAssetsChanged); };
}, []);
React.useEffect(() => {
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
tlInitRef.current = true;
window.Timeline.init(tlRef.current, {
fps: 59.94,
scale: 100,
onClipsChanged: handleClipsChanged,
onPlayheadMoved: handlePlayheadMoved,
onClipContextMenu: handleClipContextMenu,
onExternalDrop: handleExternalDrop,
});
}, []);
React.useEffect(() => {
if (window.Timeline && tlInitRef.current) {
window.Timeline.setScale(scale);
}
}, [scale]);
React.useEffect(() => {
if (!clipMenu) return;
const close = function() { setClipMenu(null); };
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
return function() {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
};
}, [clipMenu]);
async function loadSequences(pid) {
try {
const r = await window.ZAMPP_API.getSequences(pid);
const list = Array.isArray(r) ? r : [];
setSequences(list);
if (list.length) openSequence(list[0].id);
} catch (e) { console.error('Failed to load sequences', e); }
}
async function openSequence(id) {
try {
const r = await window.ZAMPP_API.getSequence(id);
const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() }));
const seq = { ...r, clips };
setCurrentSeq(seq);
currentSeqRef.current = seq;
setPlayheadFrames(0);
setSelectedClipId(null);
historyRef.current = [clips];
historyIdxRef.current = 0;
setHistory([clips]);
setHistoryIdx(0);
setIsDirty(false);
isDirtyRef.current = false;
setSaveStatus('');
renderTimelineClips(clips);
} catch (e) { console.error('Failed to open sequence', e); }
}
async function createNewSequence() {
if (!projectId) return;
const name = newSeqName.trim() || ('Sequence ' + (sequences.length + 1));
try {
const r = await window.ZAMPP_API.createSequence({ project_id: projectId, name });
setSequences(prev => [r, ...prev]);
setNewSeqName('');
setShowNewSeq(false);
openSequence(r.id);
} catch (e) { console.error('Failed to create sequence', e); }
}
async function renameSequence() {
if (!currentSeq || !renameVal.trim()) { setRenamingSeq(false); return; }
try {
await window.ZAMPP_API.updateSequence(currentSeq.id, { name: renameVal.trim() });
setSequences(prev => prev.map(s => s.id === currentSeq.id ? { ...s, name: renameVal.trim() } : s));
setCurrentSeq(prev => prev ? { ...prev, name: renameVal.trim() } : prev);
} catch (e) { console.error('Rename failed', e); }
setRenamingSeq(false);
}
async function deleteSequence() {
if (!currentSeq) return;
if (!window.confirm('Delete sequence "' + currentSeq.name + '"? This cannot be undone.')) return;
try {
await window.ZAMPP_API.deleteSequence(currentSeq.id);
const remaining = sequences.filter(s => s.id !== currentSeq.id);
setSequences(remaining);
setCurrentSeq(null);
currentSeqRef.current = null;
setHistory([[]]);
setHistoryIdx(0);
if (remaining.length) openSequence(remaining[0].id);
} catch (e) { console.error('Delete failed', e); }
}
function renderTimelineClips(clips) {
if (window.Timeline && tlRef.current) {
window.Timeline.render(clips || [], { fps: 59.94 });
}
}
function handleClipsChanged(clips) {
setCurrentSeq(prev => {
const next = prev ? { ...prev, clips } : prev;
currentSeqRef.current = next;
return next;
});
const newHistory = historyRef.current.slice(0, historyIdxRef.current + 1);
newHistory.push(clips.map(c => ({ ...c })));
if (newHistory.length > 50) newHistory.shift();
historyRef.current = newHistory;
historyIdxRef.current = newHistory.length - 1;
setHistory(newHistory);
setHistoryIdx(newHistory.length - 1);
markDirty();
}
function handlePlayheadMoved(frames) {
setPlayheadFrames(Math.max(0, frames));
}
function handleClipContextMenu(payload) {
setSelectedClipId(payload.clip._id);
setClipMenu(payload);
}
async function handleExternalDrop(payload) {
const raw = payload.dataTransfer.getData('application/json') || payload.dataTransfer.getData('text/plain');
let assetId = raw;
if (!assetId) return;
try {
const parsed = JSON.parse(raw);
assetId = parsed.assetId || parsed.id || assetId;
} catch (_) {}
const asset = assetsRef.current.find(function(a) { return a.id === assetId; });
if (!asset) return;
let streamUrl = streamCacheRef.current[asset.id];
if (!streamUrl) {
try {
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
if (r && r.url) { streamUrl = r.url; streamCacheRef.current[asset.id] = r.url; }
} catch (e) { console.error('Failed to get stream URL', e); }
}
window.Timeline.addClip({ ...asset, streamUrl }, 0, (asset.duration_ms || 10000) / 1000, payload.track, payload.timelineFrames);
}
function markDirty() {
setIsDirty(true);
isDirtyRef.current = true;
setSaveStatus('Unsaved');
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(saveSequence, 2000);
}
async function saveSequence() {
const seq = currentSeqRef.current;
if (!seq || !isDirtyRef.current) return;
clearTimeout(saveTimerRef.current);
setSaveStatus('Saving…');
try {
const clips = (seq.clips || []).map(c => ({
asset_id: c.asset_id,
track: c.track,
timeline_in_frames: c.timeline_in_frames,
timeline_out_frames: c.timeline_out_frames,
source_in_frames: c.source_in_frames,
source_out_frames: c.source_out_frames,
}));
await window.ZAMPP_API.syncSequenceClips(seq.id, clips);
setIsDirty(false);
isDirtyRef.current = false;
setSaveStatus('Saved');
setTimeout(() => setSaveStatus(''), 2000);
} catch (e) {
setSaveStatus('Save failed');
console.error('Auto-save failed', e);
}
}
function undo() {
if (historyIdxRef.current <= 0) return;
const idx = historyIdxRef.current - 1;
historyIdxRef.current = idx;
setHistoryIdx(idx);
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
setCurrentSeq(prev => {
const next = prev ? { ...prev, clips } : prev;
currentSeqRef.current = next;
return next;
});
renderTimelineClips(clips);
markDirty();
}
function redo() {
if (historyIdxRef.current >= historyRef.current.length - 1) return;
const idx = historyIdxRef.current + 1;
historyIdxRef.current = idx;
setHistoryIdx(idx);
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
setCurrentSeq(prev => {
const next = prev ? { ...prev, clips } : prev;
currentSeqRef.current = next;
return next;
});
renderTimelineClips(clips);
markDirty();
}
async function loadSourceAsset(asset) {
setSourceAsset(asset);
setSrcIn(null);
setSrcOut(null);
const vid = srcVideoRef.current;
if (!vid) return;
vid.src = '';
const cache = streamCacheRef.current;
let url = cache[asset.id];
if (!url) {
try {
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
if (r && r.url) { url = r.url; cache[asset.id] = url; }
} catch (e) { console.error('Failed to get stream URL', e); }
}
if (url) { vid.src = url; vid.load(); }
}
function markSrcIn() {
const vid = srcVideoRef.current;
if (vid) setSrcIn(vid.currentTime);
}
function markSrcOut() {
const vid = srcVideoRef.current;
if (vid) setSrcOut(vid.currentTime);
}
function addToTimeline() {
if (!sourceAsset || !currentSeq) return;
if (!window.Timeline) return;
const vid = srcVideoRef.current;
const sIn = srcIn != null ? srcIn : 0;
const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000));
window.Timeline.addClip(
{ ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] },
sIn, sOut, insertTrack
);
}
function exportEDL() {
if (!currentSeq) return;
const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl';
window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e));
}
function openClipInSource(clip) {
const asset = assets.find(function(a) { return a.id === clip.asset_id; });
if (asset) loadSourceAsset(asset);
setClipMenu(null);
}
function deleteClip(clip) {
const clips = ((currentSeqRef.current && currentSeqRef.current.clips) || []).filter(function(c) { return c._id !== clip._id; });
setClipMenu(null);
handleClipsChanged(clips);
renderTimelineClips(clips);
}
function duplicateClip(clip) {
const duration = clip.timeline_out_frames - clip.timeline_in_frames;
const dupe = { ...clip, _id: _uid(), timeline_in_frames: clip.timeline_out_frames, timeline_out_frames: clip.timeline_out_frames + duration };
const clips = [ ...((currentSeqRef.current && currentSeqRef.current.clips) || []), dupe ];
setClipMenu(null);
handleClipsChanged(clips);
renderTimelineClips(clips);
}
function splitClipAtPlayhead(clip) {
const split = playheadFrames;
if (split <= clip.timeline_in_frames || split >= clip.timeline_out_frames) { setClipMenu(null); return; }
const offset = split - clip.timeline_in_frames;
const left = { ...clip, _id: _uid(), timeline_out_frames: split, source_out_frames: clip.source_in_frames + offset };
const right = { ...clip, _id: _uid(), timeline_in_frames: split, source_in_frames: clip.source_in_frames + offset };
const clips = ((currentSeqRef.current && currentSeqRef.current.clips) || []).flatMap(function(c) {
return c._id === clip._id ? [left, right] : [c];
});
setClipMenu(null);
handleClipsChanged(clips);
renderTimelineClips(clips);
}
const srcInLabel = srcIn != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcIn)) : 'In set') : 'Mark In';
const srcOutLabel = srcOut != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcOut)) : 'Out set') : 'Mark Out';
return (
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── COMING SOON bumper — overlays the entire editor ── */}
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(10, 12, 18, 0.92)',
backdropFilter: 'blur(6px)',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 20, pointerEvents: 'all',
}}>
<div style={{
width: 64, height: 64,
background: 'linear-gradient(135deg, var(--accent), hsl(250 80% 65%))',
borderRadius: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 40px rgba(91, 124, 250, 0.4)',
}}>
<Icon name="editor" size={30} />
</div>
<div style={{ textAlign: 'center', maxWidth: 420 }}>
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}>
NLE Editor Coming Soon
</div>
<div style={{ fontSize: 14, color: 'var(--text-3)', lineHeight: 1.6 }}>
The browser-based timeline editor is under active development.
In the meantime, use the <strong style={{ color: 'var(--text-2)' }}>Premiere Pro panel</strong> for
frame-accurate editing and growing-file workflows download it from
<strong style={{ color: 'var(--text-2)' }}> Settings Capture SDKs</strong>.
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<a href="/downloads/dragonflight-premiere-panel-1.0.1.zxp" download style={{ textDecoration: 'none' }}>
<button className="btn primary">Download ZXP</button>
</a>
<a href="/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe" download style={{ textDecoration: 'none' }}>
<button className="btn ghost">Windows Installer</button>
</a>
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
Dragonflight Premiere Panel v1.0.1
</div>
</div>
{/* ── Top toolbar ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0, height: 40 }}>
<Icon name="editor" size={14} />
{renamingSeq ? (
<input value={renameVal} autoFocus onChange={e => setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameSequence(); if (e.key === 'Escape') setRenamingSeq(false); }}
onBlur={renameSequence}
style={{ fontSize: 12, height: 24, padding: '0 6px', background: 'var(--bg-surface)', border: '1px solid var(--accent)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }} />
) : (
<select value={currentSeq ? currentSeq.id : ''} onChange={e => openSequence(e.target.value)}
style={{ fontSize: 12, height: 24, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }}>
{sequences.length === 0 && <option value="">No sequences</option>}
{sequences.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} onClick={() => setShowNewSeq(true)} title="New sequence">
<Icon name="plus" size={12} />
</button>
{currentSeq && (
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} title="Rename sequence"
onClick={() => { setRenameVal(currentSeq.name); setRenamingSeq(true); }}>
<Icon name="edit" size={12} />
</button>
)}
{currentSeq && (
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} title="Delete sequence"
onClick={deleteSequence}>
<Icon name="trash" size={12} />
</button>
)}
<span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
<button className="btn ghost sm" onClick={undo} disabled={historyIdx <= 0} title="Undo (Ctrl+Z)"></button>
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)"></button>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
{saveStatus && <span style={{ fontSize: 10, color: saveStatus === 'Saved' ? 'var(--signal-good)' : 'var(--signal-warn)' }}>{saveStatus}</span>}
</div>
{/* ── 2x2 grid ── */}
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '35vh 1fr', overflow: 'hidden' }}>
{/* Source monitor */}
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
<video ref={srcVideoRef} preload="metadata" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
{sourceAsset ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcVideoRef.current ? srcVideoRef.current.currentTime : 0)) : '00:00:00;00') : '--:--:--;--'}
</span>
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcIn} title="Mark In (I)">{srcInLabel}</button>
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcOut} title="Mark Out (O)">{srcOutLabel}</button>
<div style={{ flex: 1 }} />
<select value={insertTrack} onChange={e => setInsertTrack(Number(e.target.value))}
title="Target track"
style={{ fontSize: 10, height: 20, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)', marginRight: 4 }}>
<option value={0}>V1</option>
<option value={1}>V2</option>
<option value={100}>A1</option>
<option value={101}>A2</option>
</select>
<button className="btn primary sm" style={{ fontSize: 10, padding: '2px 8px' }} onClick={addToTimeline} title="Insert at playhead">Insert</button>
</div>
</div>
{/* Program monitor */}
<ProgramMonitor
videoRef={pgmVideoRef}
currentSeq={currentSeq}
playheadFrames={playheadFrames}
setPlayheadFrames={setPlayheadFrames}
streamCacheRef={streamCacheRef}
/>
{/* Media panel (bottom left) */}
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', overflow: 'hidden', background: 'var(--bg-panel)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)' }}>Media</span>
<select value={seqFilter} onChange={e => setSeqFilter(e.target.value)}
style={{ fontSize: 10, height: 20, marginLeft: 'auto', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)' }}>
<option value="">All bins</option>
{bins.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{assets.filter(a => !seqFilter || a.bin_id === seqFilter).length === 0 ? (
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center' }}>No assets</div>
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
<div key={a.id} className="media-asset-item"
draggable="true"
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'grab', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
onDragStart={function(e) {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', a.id);
e.dataTransfer.setData('application/json', JSON.stringify({ assetId: a.id }));
}}
onDoubleClick={() => loadSourceAsset(a)}>
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
<AssetThumb asset={a} size="sm" />
</div>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</span>
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>{a.duration}</span>
</div>
))}
</div>
</div>
{/* Timeline (bottom right) */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0 }}>
<ToolBtn active={tool === 'select'} label="V" title="Select (V)" onClick={() => { setTool('select'); if (window.Timeline) window.Timeline.setTool('select'); }} />
<ToolBtn active={tool === 'razor'} label="C" title="Razor (C)" onClick={() => { setTool('razor'); if (window.Timeline) window.Timeline.setTool('razor'); }} />
<ToolBtn active={tool === 'hand'} label="H" title="Hand (H)" onClick={() => { setTool('hand'); if (window.Timeline) window.Timeline.setTool('hand'); }} />
<span style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 4px' }} />
<input type="range" min="20" max="500" value={scale} style={{ width: 60, height: 3 }}
onChange={e => {
const s = Number(e.target.value);
setScale(s);
}} />
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace', width: 28 }}>{scale}px</span>
<span style={{ marginLeft: 8, fontSize: 10, color: 'var(--text-tertiary)' }}>Drag media here. Right-click clips for actions.</span>
</div>
<div ref={tlRef} className="timeline-container" style={{ flex: 1, minHeight: 0 }} />
</div>
</div>
{clipMenu && (
<ClipContextMenu
clip={clipMenu.clip}
x={clipMenu.x}
y={clipMenu.y}
onClose={function() { setClipMenu(null); }}
onOpenSource={openClipInSource}
onDuplicate={duplicateClip}
onSplit={splitClipAtPlayhead}
onDelete={deleteClip}
/>
)}
{/* ── New sequence modal ── */}
{showNewSeq && (
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setShowNewSeq(false)}>
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 8, padding: 24, minWidth: 320 }}
onClick={e => e.stopPropagation()}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>New sequence</div>
<input value={newSeqName} onChange={e => setNewSeqName(e.target.value)}
placeholder="Sequence name"
style={{ width: '100%', height: 32, padding: '0 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, color: 'var(--text-primary)', fontSize: 13, marginBottom: 12 }}
onKeyDown={e => { if (e.key === 'Enter') createNewSequence(); }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button className="btn ghost sm" onClick={() => setShowNewSeq(false)}>Cancel</button>
<button className="btn primary sm" onClick={createNewSequence}>Create</button>
</div>
</div>
</div>
)}
{/* ── Overlay keyboard handler ── */}
<EditorKeyboard
onUndo={undo} onRedo={redo} onSave={saveSequence}
onMarkIn={markSrcIn} onMarkOut={markSrcOut}
currentSeq={currentSeq}
/>
</div>
);
}
function ToolBtn({ active, label, title, onClick }) {
return (
<button onClick={onClick} title={title}
style={{
width: 24, height: 22, fontSize: 11, fontWeight: 600,
background: active ? 'var(--accent-subtle)' : 'var(--bg-surface)',
border: '1px solid ' + (active ? 'var(--accent-border)' : 'var(--border)'),
borderRadius: 3, color: active ? 'var(--accent)' : 'var(--text-secondary)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{label}</button>
);
}
function ClipContextMenu({ clip, x, y, onClose, onOpenSource, onDuplicate, onSplit, onDelete }) {
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]);
return (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{clip.display_name || clip.name || 'Clip'}</div>
<button onClick={function() { onClose(); onOpenSource(clip); }}><Icon name="play" size={11} />Load in source</button>
<button onClick={function() { onClose(); onDuplicate(clip); }}><Icon name="copy" size={11} />Duplicate</button>
<button onClick={function() { onClose(); onSplit(clip); }}><Icon name="cut" size={11} />Split at playhead</button>
<div className="ctx-divider" />
<button className="danger" onClick={function() { onClose(); onDelete(clip); }}><Icon name="trash" size={11} />Remove clip</button>
</div>
);
}
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
const [pgmPlaying, setPgmPlaying] = React.useState(false);
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
const [pgmClips, setPgMclips] = React.useState([]);
function getV1Clips() {
if (!currentSeq || !currentSeq.clips) return [];
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_in_frames);
}
function togglePlay() {
if (pgmPlaying) {
stopPgm();
return;
}
const v1 = getV1Clips();
if (!v1.length) return;
setPgMclips(v1);
const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames);
setPgmClipIdx(idx >= 0 ? idx : 0);
setPgmPlaying(true);
}
function stopPgm() {
setPgmPlaying(false);
setPgmClipIdx(-1);
const vid = videoRef.current;
if (vid) vid.pause();
}
React.useEffect(() => {
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
const clip = pgmClips[pgmClipIdx];
if (!clip) { stopPgm(); return; }
const vid = videoRef.current;
if (!vid) return;
const cache = streamCacheRef.current;
(async () => {
let url = cache[clip.asset_id];
if (!url) {
try {
const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream');
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
} catch (e) { console.error('Stream fetch failed', e); return; }
}
if (vid.src !== url) { vid.src = url; vid.load(); }
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
vid.currentTime = srcInSecs;
vid.play().catch(() => {});
})();
}, [pgmPlaying, pgmClipIdx]);
function advanceClip() {
const next = pgmClipIdx + 1;
if (next >= pgmClips.length) { stopPgm(); return; }
setPgmClipIdx(next);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
<video ref={videoRef} preload="auto" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }}
onTimeUpdate={() => {
if (!pgmPlaying) return;
const clip = pgmClips[pgmClipIdx];
if (!clip) return;
const fps = window.TC ? window.TC.FPS : 59.94;
const vid = videoRef.current;
if (!vid) return;
// Sync timeline playhead to current program position
const elapsed = vid.currentTime - (clip.source_in_frames / fps);
const tlFrames = clip.timeline_in_frames + Math.round(elapsed * fps);
setPlayheadFrames(Math.max(0, tlFrames));
if (window.Timeline) window.Timeline.setPlayhead(Math.max(0, tlFrames));
const srcOutSecs = clip.source_out_frames / fps;
if (vid.currentTime >= srcOutSecs) {
advanceClip();
}
}}
onEnded={advanceClip}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<button className="btn ghost sm" style={{ fontSize: 11, padding: '2px 6px' }} onClick={togglePlay}>{pgmPlaying ? '⏸' : '▶'}</button>
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
</span>
</div>
</div>
);
}
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
React.useEffect(() => {
function handler(e) {
const tag = document.activeElement.tagName;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); onSave(); return; }
if (e.key === 'i' || e.key === 'I') { onMarkIn(); return; }
if (e.key === 'o' || e.key === 'O') { onMarkOut(); return; }
if (e.key === 'v' || e.key === 'V') { if (window.Timeline) window.Timeline.setTool('select'); return; }
if (e.key === 'c' || e.key === 'C') { if (window.Timeline) window.Timeline.setTool('razor'); return; }
if (e.key === 'h' || e.key === 'H') { if (window.Timeline) window.Timeline.setTool('hand'); return; }
if (e.code === 'Space' && !currentSeq) { e.preventDefault(); /* handled by PGM */ }
}
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq]);
return null;
}
window.Editor = Editor;