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(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) => ( ); 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 (

Zone Editor

Manage all timeline zones ({totalZones} total)

Preview before/after
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" /> sec
{/* View Mode Toggle */}
{totalZones === 0 ? (

No zones yet. Create zones from the toolbar or by highlighting words.

) : (
{/* Cut Zones */} {(viewMode === 'all' || viewMode === 'cut') && cutRanges.length > 0 && (
Cut Zones ({cutRanges.length})
{cutRanges.map((range) => (
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' : ''}`} >
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
))}
)} {/* Mute Zones */} {(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
Mute Zones ({muteRanges.length})
{muteRanges.map((range) => (
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' : ''}`} >
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
{renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
))}
)} {/* Sound Gain */} {(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
Sound Gain ({gainRanges.length})
{/* Global Gain Slider */}
setGlobalGainDb(Number(e.target.value))} className="flex-1 h-1.5" /> 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" /> dB
{gainRanges.map((range) => (
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' : ''}`} >
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
{range.gainDb > 0 ? '+' : ''}{range.gainDb.toFixed(1)} dB
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')}
))}
)} {/* Speed Adjust */} {(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
Speed Adjust ({speedRanges.length})
{speedRanges.map((range) => (
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' : ''}`} >
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
{range.speed.toFixed(2)}x
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')}
))}
)}
)}
); }