460 lines
20 KiB
TypeScript
460 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useEditorStore } from '../store/editorStore';
|
|
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge, Play } from 'lucide-react';
|
|
|
|
function formatTimelineLikeTime(secs: number): string {
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
if (m > 0) return `${m}:${String(Math.floor(s)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`;
|
|
return `${s.toFixed(1)}s`;
|
|
}
|
|
|
|
export default function ZoneEditor() {
|
|
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
|
|
const [focusedZone, setFocusedZone] = useState<{ type: 'cut' | 'mute' | 'gain' | 'speed'; id: string } | null>(null);
|
|
const previewFrameRef = useRef<number | null>(null);
|
|
|
|
const {
|
|
cutRanges,
|
|
muteRanges,
|
|
gainRanges,
|
|
speedRanges,
|
|
duration,
|
|
setCurrentTime,
|
|
zonePreviewPaddingSeconds,
|
|
setZonePreviewPaddingSeconds,
|
|
globalGainDb,
|
|
setGlobalGainDb,
|
|
removeCutRange,
|
|
removeMuteRange,
|
|
removeGainRange,
|
|
removeSpeedRange,
|
|
updateGainRange,
|
|
updateSpeedRange,
|
|
} = useEditorStore();
|
|
|
|
const stopPreviewLoop = useCallback(() => {
|
|
if (previewFrameRef.current !== null) {
|
|
cancelAnimationFrame(previewFrameRef.current);
|
|
previewFrameRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => stopPreviewLoop, [stopPreviewLoop]);
|
|
|
|
const previewZone = useCallback((start: number, end: number) => {
|
|
const video = document.querySelector('video');
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
|
|
stopPreviewLoop();
|
|
|
|
const previewStart = Math.max(0, start - zonePreviewPaddingSeconds);
|
|
const maxDuration = Number.isFinite(duration) && duration > 0 ? duration : video.duration;
|
|
const previewEnd = Math.min(maxDuration || end + zonePreviewPaddingSeconds, end + zonePreviewPaddingSeconds);
|
|
|
|
video.currentTime = previewStart;
|
|
setCurrentTime(previewStart);
|
|
|
|
const tick = () => {
|
|
if (video.paused || video.ended) {
|
|
previewFrameRef.current = null;
|
|
return;
|
|
}
|
|
if (video.currentTime >= previewEnd) {
|
|
video.pause();
|
|
video.currentTime = previewEnd;
|
|
setCurrentTime(previewEnd);
|
|
previewFrameRef.current = null;
|
|
return;
|
|
}
|
|
previewFrameRef.current = requestAnimationFrame(tick);
|
|
};
|
|
|
|
void video.play();
|
|
previewFrameRef.current = requestAnimationFrame(tick);
|
|
}, [duration, setCurrentTime, stopPreviewLoop, zonePreviewPaddingSeconds]);
|
|
|
|
const renderPreviewButton = (start: number, end: number, accentClass: string) => (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
previewZone(start, end);
|
|
}}
|
|
className={`p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity ${accentClass}`}
|
|
title={`Play ${zonePreviewPaddingSeconds.toFixed(2)}s before and after zone`}
|
|
>
|
|
<Play className="w-3.5 h-3.5" />
|
|
</button>
|
|
);
|
|
|
|
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length;
|
|
|
|
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => {
|
|
switch (type) {
|
|
case 'cut':
|
|
return 'border-red-500/40 bg-red-500/5';
|
|
case 'mute':
|
|
return 'border-blue-500/40 bg-blue-500/20';
|
|
case 'gain':
|
|
return 'border-amber-500/40 bg-amber-500/5';
|
|
case 'speed':
|
|
return 'border-emerald-500/40 bg-emerald-500/5';
|
|
}
|
|
};
|
|
|
|
const activeFocusedZone = useMemo(() => {
|
|
if (!focusedZone) return null;
|
|
const exists = focusedZone.type === 'cut'
|
|
? cutRanges.some((range) => range.id === focusedZone.id)
|
|
: focusedZone.type === 'mute'
|
|
? muteRanges.some((range) => range.id === focusedZone.id)
|
|
: focusedZone.type === 'gain'
|
|
? gainRanges.some((range) => range.id === focusedZone.id)
|
|
: speedRanges.some((range) => range.id === focusedZone.id);
|
|
return exists ? focusedZone : null;
|
|
}, [cutRanges, focusedZone, gainRanges, muteRanges, speedRanges]);
|
|
|
|
const isZoneFocused = useCallback(
|
|
(type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => activeFocusedZone?.type === type && activeFocusedZone.id === id,
|
|
[activeFocusedZone],
|
|
);
|
|
|
|
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
|
if (!window.confirm("Delete this zone?")) return;
|
|
if (type === 'cut') removeCutRange(id);
|
|
else if (type === 'mute') removeMuteRange(id);
|
|
else if (type === 'gain') removeGainRange(id);
|
|
else removeSpeedRange(id);
|
|
setFocusedZone((current) => (current?.type === type && current.id === id ? null : current));
|
|
}, [removeCutRange, removeGainRange, removeMuteRange, removeSpeedRange]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement | null;
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')) {
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
setFocusedZone(null);
|
|
return;
|
|
}
|
|
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && activeFocusedZone) {
|
|
e.preventDefault();
|
|
removeZone(activeFocusedZone.type, activeFocusedZone.id);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
|
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
|
}, [activeFocusedZone, removeZone]);
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
Zone Editor
|
|
</h3>
|
|
<p className="text-xs text-editor-text-muted">
|
|
Manage all timeline zones ({totalZones} total)
|
|
</p>
|
|
</div>
|
|
<div className="min-w-[160px] rounded border border-editor-border bg-editor-surface px-2 py-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[10px] uppercase tracking-wide text-editor-text-muted">Preview</span>
|
|
<span className="text-[10px] text-editor-text-muted">before/after</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-1.5">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={10}
|
|
step={0.25}
|
|
value={zonePreviewPaddingSeconds}
|
|
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
|
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
|
title="Preview time before and after each zone"
|
|
/>
|
|
<span className="text-xs text-editor-text-muted">sec</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Mode Toggle */}
|
|
<div className="flex items-center gap-1 rounded bg-editor-surface border border-editor-border p-1">
|
|
<button
|
|
onClick={() => setViewMode('all')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
viewMode === 'all'
|
|
? 'bg-editor-accent text-white'
|
|
: 'text-editor-text-muted hover:text-editor-text'
|
|
}`}
|
|
title="Show all zones"
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('cut')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
viewMode === 'cut'
|
|
? 'bg-red-500/30 text-red-500'
|
|
: 'text-editor-text-muted hover:text-editor-text'
|
|
}`}
|
|
title="Show only Cut zones"
|
|
>
|
|
Cut
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('mute')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
viewMode === 'mute'
|
|
? 'bg-blue-500/20 text-blue-400'
|
|
: 'text-editor-text-muted hover:text-editor-text'
|
|
}`}
|
|
title="Show only Mute zones"
|
|
>
|
|
Mute
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('gain')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
viewMode === 'gain'
|
|
? 'bg-amber-500/30 text-amber-500'
|
|
: 'text-editor-text-muted hover:text-editor-text'
|
|
}`}
|
|
title="Show only Gain zones"
|
|
>
|
|
Gain
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('speed')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
viewMode === 'speed'
|
|
? 'bg-emerald-500/30 text-emerald-500'
|
|
: 'text-editor-text-muted hover:text-editor-text'
|
|
}`}
|
|
title="Show only Speed zones"
|
|
>
|
|
Speed
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{totalZones === 0 ? (
|
|
<div className="p-4 rounded-lg border border-dashed border-editor-border text-center">
|
|
<p className="text-xs text-editor-text-muted">
|
|
No zones yet. Create zones from the toolbar or by highlighting words.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* Cut Zones */}
|
|
{(viewMode === 'all' || viewMode === 'cut') && cutRanges.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-semibold text-red-500/80 flex items-center gap-2">
|
|
<Scissors className="w-3.5 h-3.5" />
|
|
Cut Zones ({cutRanges.length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{cutRanges.map((range) => (
|
|
<div
|
|
key={range.id}
|
|
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
|
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
|
</div>
|
|
</div>
|
|
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeZone('cut', range.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Delete cut zone"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mute Zones */}
|
|
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-semibold text-blue-400 flex items-center gap-2">
|
|
<Volume2 className="w-3.5 h-3.5" />
|
|
Mute Zones ({muteRanges.length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{muteRanges.map((range) => (
|
|
<div
|
|
key={range.id}
|
|
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
|
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
|
</div>
|
|
</div>
|
|
{renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeZone('mute', range.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Delete mute zone"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sound Gain */}
|
|
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
|
<SlidersHorizontal className="w-3.5 h-3.5" />
|
|
Sound Gain ({gainRanges.length})
|
|
</div>
|
|
|
|
{/* Global Gain Slider */}
|
|
<div className="px-2 py-2 rounded border border-amber-500/20 bg-amber-500/5 space-y-2">
|
|
<label className="text-xs text-editor-text-muted font-medium">Global Gain</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={-24}
|
|
max={24}
|
|
step={0.5}
|
|
value={globalGainDb}
|
|
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
|
className="flex-1 h-1.5"
|
|
/>
|
|
<input
|
|
type="number"
|
|
min={-24}
|
|
max={24}
|
|
step={0.5}
|
|
value={globalGainDb}
|
|
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
|
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
|
title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
|
|
/>
|
|
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{gainRanges.map((range) => (
|
|
<div
|
|
key={range.id}
|
|
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
|
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
|
</div>
|
|
<div className="text-editor-text-muted text-[10px]">
|
|
{range.gainDb > 0 ? '+' : ''}{range.gainDb.toFixed(1)} dB
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
min={-24}
|
|
max={24}
|
|
step={0.5}
|
|
value={range.gainDb}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
|
title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
|
|
/>
|
|
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeZone('gain', range.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Delete gain zone"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Speed Adjust */}
|
|
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
|
|
<Gauge className="w-3.5 h-3.5" />
|
|
Speed Adjust ({speedRanges.length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{speedRanges.map((range) => (
|
|
<div
|
|
key={range.id}
|
|
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
|
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
|
</div>
|
|
<div className="text-editor-text-muted text-[10px]">
|
|
{range.speed.toFixed(2)}x
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
min={0.25}
|
|
max={4}
|
|
step={0.05}
|
|
value={range.speed}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
|
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
|
title="Playback speed multiplier — 1.0x is normal, 2.0x is twice as fast"
|
|
/>
|
|
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeZone('speed', range.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Delete speed zone"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|