import { useCallback, useRef, useEffect, useMemo, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { useLicenseStore } from '../store/licenseStore'; import { Virtuoso } from 'react-virtuoso'; import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react'; interface TranscriptEditorProps { cutMode: boolean; muteMode: boolean; gainMode: boolean; gainModeDb: number; speedMode: boolean; speedModeValue: number; } export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, }: TranscriptEditorProps) { const words = useEditorStore((s) => s.words); const segments = useEditorStore((s) => s.segments); const cutRanges = useEditorStore((s) => s.cutRanges); const muteRanges = useEditorStore((s) => s.muteRanges); const gainRanges = useEditorStore((s) => s.gainRanges); const speedRanges = useEditorStore((s) => s.speedRanges); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex); const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices); const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex); const videoPath = useEditorStore((s) => s.videoPath); const backendUrl = useEditorStore((s) => s.backendUrl); const replaceWordRange = useEditorStore((s) => s.replaceWordRange); const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange); const addCutRange = useEditorStore((s) => s.addCutRange); const addMuteRange = useEditorStore((s) => s.addMuteRange); const addGainRange = useEditorStore((s) => s.addGainRange); const addSpeedRange = useEditorStore((s) => s.addSpeedRange); const getWordAtTime = useEditorStore((s) => s.getWordAtTime); const canEdit = useLicenseStore((s) => s.canEdit); const selectionStart = useRef(null); const wasDragging = useRef(false); const virtuosoRef = useRef(null); const zoneDragStart = useRef(null); const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [activeMatchIdx, setActiveMatchIdx] = useState(0); const searchInputRef = useRef(null); const updateWordText = useEditorStore((s) => s.updateWordText); const [editingWordIndex, setEditingWordIndex] = useState(null); const [editText, setEditText] = useState(''); const editInputRef = useRef(null); const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]); const matchIndices = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return [] as number[]; const matches: number[] = []; for (let i = 0; i < words.length; i++) { if (words[i].word.toLowerCase().includes(q)) matches.push(i); } return matches; }, [searchQuery, words]); const matchSet = useMemo(() => new Set(matchIndices), [matchIndices]); const safeActiveMatchIdx = matchIndices.length === 0 ? 0 : Math.min(activeMatchIdx, matchIndices.length - 1); const jumpToMatch = useCallback((idx: number) => { if (matchIndices.length === 0) return; const nextIdx = ((idx % matchIndices.length) + matchIndices.length) % matchIndices.length; setActiveMatchIdx(nextIdx); const wordIndex = matchIndices[nextIdx]; const el = document.getElementById(`word-${wordIndex}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }, [matchIndices]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; const isInInput = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'); if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchOpen(true); requestAnimationFrame(() => searchInputRef.current?.focus()); return; } if (!searchOpen) return; if (e.key === 'Escape') { e.preventDefault(); setSearchOpen(false); return; } if (e.key === 'Enter' && !isInInput) { e.preventDefault(); jumpToMatch(safeActiveMatchIdx + (e.shiftKey ? -1 : 1)); return; } if (e.key === 'Enter' && isInInput && target === searchInputRef.current) { e.preventDefault(); jumpToMatch(safeActiveMatchIdx + (e.shiftKey ? -1 : 1)); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [jumpToMatch, searchOpen, safeActiveMatchIdx]); const [activeWordIndex, setActiveWordIndex] = useState(-1); useEffect(() => { if (words.length === 0) return; const interval = setInterval(() => { const video = document.querySelector('video') as HTMLVideoElement | null; if (!video) return; const idx = getWordAtTime(video.currentTime); setActiveWordIndex((prev) => (prev === idx ? prev : idx)); }, 250); return () => clearInterval(interval); }, [words, getWordAtTime]); // Auto-scroll to active segment via Virtuoso useEffect(() => { if (activeWordIndex < 0 || segments.length === 0) return; const segIdx = segments.findIndex((seg) => { const start = seg.globalStartIndex ?? 0; return activeWordIndex >= start && activeWordIndex < start + seg.words.length; }); if (segIdx >= 0 && virtuosoRef.current) { virtuosoRef.current.scrollIntoView({ index: segIdx, behavior: 'smooth', align: 'center' }); } }, [activeWordIndex, segments]); const handleWordMouseDown = useCallback( (index: number, e: React.MouseEvent) => { e.preventDefault(); // Ctrl+click → seek video to this word's start time if (e.ctrlKey) { const word = words[index]; if (word) { const video = document.querySelector('video') as HTMLVideoElement | null; if (video) video.currentTime = word.start; } return; } if (cutMode || muteMode || gainMode || speedMode) { zoneDragStart.current = index; setZoneDragRange({ start: index, end: index }); selectionStart.current = null; return; } wasDragging.current = false; if (e.shiftKey && selectedWordIndices.length > 0) { const first = selectedWordIndices[0]; const start = Math.min(first, index); const end = Math.max(first, index); const indices = []; for (let i = start; i <= end; i++) indices.push(i); setSelectedWordIndices(indices); } else { selectionStart.current = index; setSelectedWordIndices([index]); } }, [words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode, speedMode], ); const handleWordMouseEnter = useCallback( (index: number) => { setHoveredWordIndex(index); if (zoneDragStart.current !== null) { setZoneDragRange({ start: Math.min(zoneDragStart.current, index), end: Math.max(zoneDragStart.current, index), }); return; } if (selectionStart.current !== null) { wasDragging.current = true; const start = Math.min(selectionStart.current, index); const end = Math.max(selectionStart.current, index); const indices = []; for (let i = start; i <= end; i++) indices.push(i); setSelectedWordIndices(indices); } }, [setHoveredWordIndex, setSelectedWordIndices], ); const handleMouseUp = useCallback(() => { if (zoneDragStart.current !== null && zoneDragRange) { const startWord = words[zoneDragRange.start]; const endWord = words[zoneDragRange.end]; if (startWord && endWord && canEdit) { if (cutMode) addCutRange(startWord.start, endWord.end); if (muteMode) addMuteRange(startWord.start, endWord.end); if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb); if (speedMode) addSpeedRange(startWord.start, endWord.end, speedModeValue); } } zoneDragStart.current = null; setZoneDragRange(null); selectionStart.current = null; }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange, canEdit]); const handleClickOutside = useCallback( (e: React.MouseEvent) => { if (wasDragging.current) { wasDragging.current = false; return; } if ((e.target as HTMLElement).dataset.wordIndex === undefined) { setSelectedWordIndices([]); } }, [setSelectedWordIndices], ); const startEditing = useCallback((index: number) => { const word = words[index]; if (!word) return; setEditingWordIndex(index); setEditText(word.word); requestAnimationFrame(() => { editInputRef.current?.focus(); editInputRef.current?.select(); }); }, [words]); const commitEdit = useCallback(() => { if (editingWordIndex === null) return; const trimmed = editText.trim(); if (trimmed && trimmed !== words[editingWordIndex]?.word) { updateWordText(editingWordIndex, trimmed); } setEditingWordIndex(null); setEditText(''); }, [editingWordIndex, editText, words, updateWordText]); const cancelEdit = useCallback(() => { setEditingWordIndex(null); setEditText(''); }, []); const [isReTranscribing, setIsReTranscribing] = useState(false); const reTranscribeGuard = useRef(false); const handleReTranscribe = useCallback(async () => { if (!videoPath || selectedWordIndices.length === 0 || reTranscribeGuard.current) return; reTranscribeGuard.current = true; setIsReTranscribing(true); // Snapshot indices and word timings before the async gap const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startWord = words[sorted[0]]; const endWord = words[sorted[sorted.length - 1]]; if (!startWord || !endWord) { reTranscribeGuard.current = false; setIsReTranscribing(false); return; } try { const res = await fetch(`${backendUrl}/transcribe/segment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: videoPath, start: startWord.start, end: endWord.end, }), }); if (!res.ok) { let detail = res.statusText; try { const body = await res.json(); if (body?.detail) detail = String(body.detail); } catch { /* keep statusText fallback */ } throw new Error(`Re-transcribe failed: ${detail}`); } const data = await res.json(); replaceWordRange(sorted[0], sorted[sorted.length - 1], data.words); } catch (err) { console.error('Re-transcribe error:', err); alert(err instanceof Error ? err.message : 'Re-transcribe failed'); } finally { reTranscribeGuard.current = false; setIsReTranscribing(false); } }, [videoPath, selectedWordIndices, words, backendUrl, replaceWordRange]); const handleWordDoubleClick = useCallback((index: number) => { if (cutMode || muteMode || gainMode || speedMode) return; if (!canEdit) return; startEditing(index); }, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]); // Focus edit input when it appears useEffect(() => { if (editingWordIndex !== null && editInputRef.current) { editInputRef.current.focus(); editInputRef.current.select(); } }, [editingWordIndex]); // Global key handler for edit mode useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (editingWordIndex === null) return; if (e.key === 'Enter') { e.preventDefault(); commitEdit(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [editingWordIndex, commitEdit, cancelEdit]); const cutSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addCutRange(startTime, endTime); }, [selectedWordIndices, words, addCutRange]); const muteSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addMuteRange(startTime, endTime); }, [selectedWordIndices, words, addMuteRange]); const gainSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addGainRange(startTime, endTime, gainModeDb); }, [selectedWordIndices, words, addGainRange, gainModeDb]); const speedSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addSpeedRange(startTime, endTime, speedModeValue); }, [selectedWordIndices, words, addSpeedRange, speedModeValue]); const getCutRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return cutRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, cutRanges], ); const getMuteRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return muteRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, muteRanges], ); const getGainRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return gainRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, gainRanges], ); const getSpeedRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return speedRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, speedRanges], ); const renderSegment = useCallback( (index: number) => { const segment = segments[index]; if (!segment) return null; return (
{segment.speaker && (
{segment.speaker}
)}

{segment.words.map((word, localIndex) => { const globalIndex = (segment.globalStartIndex ?? 0) + localIndex; const isSelected = selectedSet.has(globalIndex); const isActive = globalIndex === activeWordIndex; const isHovered = globalIndex === hoveredWordIndex; const isZoneDragSelected = zoneDragRange ? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end : false; const cutRange = getCutRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex); const gainRange = getGainRangeForWord(globalIndex); const speedRange = getSpeedRangeForWord(globalIndex); const isSearchMatch = matchSet.has(globalIndex); const isActiveSearchMatch = matchIndices.length > 0 && matchIndices[safeActiveMatchIdx] === globalIndex; const isEditing = globalIndex === editingWordIndex; // Low-confidence highlighting const CONFIDENCE_THRESHOLD_KEY = 'talkedit:confidenceThreshold'; const storedThreshold = typeof window !== 'undefined' ? Number(window.localStorage.getItem(CONFIDENCE_THRESHOLD_KEY)) : 0; const confidenceThreshold = Number.isFinite(storedThreshold) ? storedThreshold : 0.6; const isLowConfidence = word.confidence > 0 && word.confidence < confidenceThreshold && !cutRange && !muteRange && !gainRange && !speedRange; const confidencePct = word.confidence > 0 ? Math.round(word.confidence * 100) : null; return ( handleWordMouseDown(globalIndex, e)} onMouseEnter={() => handleWordMouseEnter(globalIndex)} onMouseLeave={() => setHoveredWordIndex(null)} onDoubleClick={() => handleWordDoubleClick(globalIndex)} className={` relative px-[2px] py-[1px] rounded cursor-pointer transition-colors ${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${gainRange ? 'bg-amber-500/20 text-amber-100' : ''} ${speedRange ? 'bg-emerald-500/20 text-emerald-100' : ''} ${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''} ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''} ${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-1 ring-editor-accent/35' : ''} ${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-1 ring-editor-accent text-white' : ''} ${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''} ${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''} ${isLowConfidence ? 'border-b border-dashed border-orange-400/60' : ''} `} > {isEditing ? ( setEditText(e.target.value)} onBlur={commitEdit} className="w-24 px-1 py-0 text-xs bg-editor-bg border border-editor-accent rounded text-editor-text focus:outline-none" style={{ minWidth: `${Math.max(word.word.length * 8, 48)}px` }} /> ) : ( <>{word.word}{' '} )} {(cutRange || muteRange || gainRange || speedRange) && isHovered && ( )} ); })}

); }, [segments, selectedSet, matchSet, matchIndices, safeActiveMatchIdx, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode, editingWordIndex, editText, editInputRef, handleWordDoubleClick, commitEdit, setEditText], ); return (
{searchOpen && (
{ setSearchQuery(e.target.value); setActiveMatchIdx(0); }} placeholder="Search transcript" className="w-40 bg-transparent text-xs text-editor-text focus:outline-none" /> {matchIndices.length === 0 ? '0/0' : `${safeActiveMatchIdx + 1}/${matchIndices.length}`}
)}
{selectedWordIndices.length > 0 && (
)}
); }