Fix asset filmstrip and editor UX
This commit is contained in:
parent
c501d88c63
commit
87f14b7c71
2 changed files with 248 additions and 19 deletions
|
|
@ -34,6 +34,8 @@ function AssetDetail({ asset, onClose }) {
|
|||
const [streamLoading, setStreamLoading] = React.useState(false);
|
||||
const [videoDuration, setVideoDuration] = React.useState(0);
|
||||
const [retrying, setRetrying] = React.useState(false);
|
||||
const [filmFrames, setFilmFrames] = React.useState([]);
|
||||
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
|
||||
const videoRef = React.useRef(null);
|
||||
|
||||
const assetId = asset && asset.id;
|
||||
|
|
@ -49,6 +51,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
setVideoDuration(0);
|
||||
setCurrentMs(0);
|
||||
setPlaying(false);
|
||||
setFilmFrames([]);
|
||||
setStreamLoading(true);
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
||||
.then(function(r) {
|
||||
|
|
@ -75,6 +78,67 @@ function AssetDetail({ asset, onClose }) {
|
|||
return function() { hls.destroy(); };
|
||||
}, [streamUrl, streamType]);
|
||||
|
||||
// Build filmstrip from real video when browser-playable media exists.
|
||||
React.useEffect(() => {
|
||||
if (!streamUrl || totalMs <= 0 || streamType === 'hls') {
|
||||
setFilmFrames([]);
|
||||
setFilmstripLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const build = async function() {
|
||||
setFilmstripLoading(true);
|
||||
try {
|
||||
const probe = document.createElement('video');
|
||||
probe.crossOrigin = 'anonymous';
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
probe.preload = 'auto';
|
||||
probe.src = streamUrl;
|
||||
await new Promise(function(resolve, reject) {
|
||||
probe.onloadedmetadata = resolve;
|
||||
probe.onerror = reject;
|
||||
});
|
||||
const frameCount = 28;
|
||||
const width = 160;
|
||||
const height = 90;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const nextFrames = [];
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const at = frameCount === 1 ? 0 : (probe.duration * i) / (frameCount - 1);
|
||||
await new Promise(function(resolve) {
|
||||
const done = function() {
|
||||
try {
|
||||
ctx.drawImage(probe, 0, 0, width, height);
|
||||
nextFrames.push(canvas.toDataURL('image/jpeg', 0.72));
|
||||
} catch (_) {
|
||||
nextFrames.push(null);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const onSeeked = function() {
|
||||
probe.removeEventListener('seeked', onSeeked);
|
||||
done();
|
||||
};
|
||||
probe.addEventListener('seeked', onSeeked);
|
||||
probe.currentTime = Math.min(Math.max(at, 0), Math.max(0, probe.duration - 0.05));
|
||||
});
|
||||
if (cancelled) return;
|
||||
}
|
||||
if (!cancelled) setFilmFrames(nextFrames);
|
||||
} catch (_) {
|
||||
if (!cancelled) setFilmFrames([]);
|
||||
} finally {
|
||||
if (!cancelled) setFilmstripLoading(false);
|
||||
}
|
||||
};
|
||||
build();
|
||||
return function() { cancelled = true; };
|
||||
}, [streamUrl, streamType, totalMs]);
|
||||
|
||||
// Fake playback timer — only used when no real video stream
|
||||
React.useEffect(() => {
|
||||
if (!playing || totalMs <= 0 || streamUrl) return;
|
||||
|
|
@ -384,7 +448,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
)}
|
||||
|
||||
{totalMs > 0 && (
|
||||
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
|
||||
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} frames={filmFrames} loading={filmstripLoading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -479,22 +543,27 @@ function PlaybackBar({ current, total, onSeek, comments }) {
|
|||
);
|
||||
}
|
||||
|
||||
function FilmStrip({ seed, current, total, onSeek, comments }) {
|
||||
function FilmStrip({ seed, current, total, onSeek, comments, frames, loading }) {
|
||||
const ref = React.useRef(null);
|
||||
const frames = 28;
|
||||
const fallbackFrames = 28;
|
||||
const handle = function(e) {
|
||||
if (!ref.current || total <= 0) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
onSeek(((e.clientX - r.left) / r.width) * total);
|
||||
};
|
||||
const pct = total > 0 ? (current / total) * 100 : 0;
|
||||
const items = Array.isArray(frames) && frames.length ? frames : Array.from({ length: fallbackFrames }).map(function(_, i) { return null; });
|
||||
return (
|
||||
<div className="filmstrip-wrap">
|
||||
<div className="filmstrip" ref={ref} onClick={handle}>
|
||||
{Array.from({ length: frames }).map(function(_, i) {
|
||||
{items.map(function(src, i) {
|
||||
return (
|
||||
<div key={i} className="film-frame" style={{ background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
|
||||
<FauxFrame seed={(seed + i) % 6} />
|
||||
<div key={i} className="film-frame" style={src ? undefined : { background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
|
||||
{src ? (
|
||||
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<FauxFrame seed={(seed + i) % 6} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -515,6 +584,7 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
|
|||
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
|
||||
})}
|
||||
</div>
|
||||
{loading && <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Building filmstrip…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function Editor() {
|
|||
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);
|
||||
|
|
@ -37,6 +38,8 @@ function Editor() {
|
|||
// 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);
|
||||
|
||||
React.useEffect(() => {
|
||||
const data = window.ZAMPP_DATA;
|
||||
|
|
@ -47,6 +50,23 @@ function Editor() {
|
|||
if (pid) loadSequences(pid);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => { currentSeqRef.current = currentSeq; }, [currentSeq]);
|
||||
React.useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]);
|
||||
|
||||
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;
|
||||
|
|
@ -55,6 +75,8 @@ function Editor() {
|
|||
scale: 100,
|
||||
onClipsChanged: handleClipsChanged,
|
||||
onPlayheadMoved: handlePlayheadMoved,
|
||||
onClipContextMenu: handleClipContextMenu,
|
||||
onExternalDrop: handleExternalDrop,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -64,6 +86,17 @@ function Editor() {
|
|||
}
|
||||
}, [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);
|
||||
|
|
@ -79,6 +112,7 @@ function Editor() {
|
|||
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];
|
||||
|
|
@ -86,6 +120,7 @@ function Editor() {
|
|||
setHistory([clips]);
|
||||
setHistoryIdx(0);
|
||||
setIsDirty(false);
|
||||
isDirtyRef.current = false;
|
||||
setSaveStatus('');
|
||||
renderTimelineClips(clips);
|
||||
} catch (e) { console.error('Failed to open sequence', e); }
|
||||
|
|
@ -121,6 +156,7 @@ function Editor() {
|
|||
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);
|
||||
|
|
@ -134,7 +170,11 @@ function Editor() {
|
|||
}
|
||||
|
||||
function handleClipsChanged(clips) {
|
||||
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||
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();
|
||||
|
|
@ -149,19 +189,46 @@ function Editor() {
|
|||
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 = assets.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() {
|
||||
if (!currentSeq || !isDirty) return;
|
||||
const seq = currentSeqRef.current;
|
||||
if (!seq || !isDirtyRef.current) return;
|
||||
clearTimeout(saveTimerRef.current);
|
||||
setSaveStatus('Saving\u2026');
|
||||
setSaveStatus('Saving…');
|
||||
try {
|
||||
const clips = (currentSeq.clips || []).map(c => ({
|
||||
const clips = (seq.clips || []).map(c => ({
|
||||
asset_id: c.asset_id,
|
||||
track: c.track,
|
||||
timeline_in_frames: c.timeline_in_frames,
|
||||
|
|
@ -169,8 +236,9 @@ function Editor() {
|
|||
source_in_frames: c.source_in_frames,
|
||||
source_out_frames: c.source_out_frames,
|
||||
}));
|
||||
await window.ZAMPP_API.syncSequenceClips(currentSeq.id, clips);
|
||||
await window.ZAMPP_API.syncSequenceClips(seq.id, clips);
|
||||
setIsDirty(false);
|
||||
isDirtyRef.current = false;
|
||||
setSaveStatus('Saved');
|
||||
setTimeout(() => setSaveStatus(''), 2000);
|
||||
} catch (e) {
|
||||
|
|
@ -185,7 +253,11 @@ function Editor() {
|
|||
historyIdxRef.current = idx;
|
||||
setHistoryIdx(idx);
|
||||
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
||||
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||
setCurrentSeq(prev => {
|
||||
const next = prev ? { ...prev, clips } : prev;
|
||||
currentSeqRef.current = next;
|
||||
return next;
|
||||
});
|
||||
renderTimelineClips(clips);
|
||||
markDirty();
|
||||
}
|
||||
|
|
@ -196,7 +268,11 @@ function Editor() {
|
|||
historyIdxRef.current = idx;
|
||||
setHistoryIdx(idx);
|
||||
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
||||
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||
setCurrentSeq(prev => {
|
||||
const next = prev ? { ...prev, clips } : prev;
|
||||
currentSeqRef.current = next;
|
||||
return next;
|
||||
});
|
||||
renderTimelineClips(clips);
|
||||
markDirty();
|
||||
}
|
||||
|
|
@ -247,6 +323,42 @@ function Editor() {
|
|||
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';
|
||||
|
||||
|
|
@ -283,8 +395,8 @@ function Editor() {
|
|||
</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)">\u21A9</button>
|
||||
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">\u21AA</button>
|
||||
<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>
|
||||
|
|
@ -339,7 +451,13 @@ function Editor() {
|
|||
<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"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
|
||||
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" />
|
||||
|
|
@ -364,11 +482,25 @@ function Editor() {
|
|||
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' }}
|
||||
|
|
@ -411,6 +543,33 @@ function ToolBtn({ active, label, title, onClick }) {
|
|||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -456,7 +615,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
|||
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; await vid.load(); }
|
||||
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(() => {});
|
||||
|
|
@ -492,7 +651,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
|||
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 ? '\u23F8' : '\u25B6'}</button>
|
||||
<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>
|
||||
|
|
@ -522,4 +681,4 @@ function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSe
|
|||
return null;
|
||||
}
|
||||
|
||||
window.Editor = Editor;
|
||||
window.Editor = Editor;
|
||||
|
|
|
|||
Loading…
Reference in a new issue