import React, { memo, useRef } from 'react'; import { CheckCircle, AlertCircle, Circle, Trash2, Loader, Headphones, Scissors, Merge, MoreHorizontal, Sparkles, } from 'lucide-react'; import { formatTime } from '../utils/format'; import { LANG_CODES } from '../utils/languages'; import { PRESETS } from '../utils/constants'; import { Menu, Button, Badge } from '../ui'; import './DubSegmentRow.css'; const CHAR_BUDGET_RATIO = 1.3; const SENTENCE_END = /[.!?。!?]/; function rowClass(isActive, isDone, selected) { return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}`; } // Best split point for the Scissors menu when the user hasn't placed a cursor — // prefer the sentence boundary nearest the middle, then a whitespace boundary, // then the literal midpoint as a last resort. function bestSplitPoint(text) { const mid = Math.floor(text.length / 2); let best = -1, bestDist = Infinity; for (let i = 0; i < text.length; i++) { if (SENTENCE_END.test(text[i])) { const d = Math.abs(i + 1 - mid); if (d < bestDist) { best = i + 1; bestDist = d; } } } if (best > 0 && best < text.length) return best; for (let r = 0; r < text.length; r++) { for (const i of [mid - r, mid + r]) { if (i > 0 && i < text.length && /\s/.test(text[i])) return i; } } return mid; } // Accept "m:ss[.s]" or raw seconds; return null on garbage so the field can revert. function parseTime(s) { const m = /^\s*(\d+):([0-5]?\d(?:\.\d+)?)\s*$/.exec(s); if (m) return parseInt(m[1], 10) * 60 + parseFloat(m[2]); const n = parseFloat(s); return Number.isFinite(n) ? n : null; } function DubSegmentRow({ seg, idx, style, disabled, isActive, isDone, previewLoading, selected, profiles, speakerClones, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge, onDirect, onSeek, }) { const textInputRef = useRef(null); // Remember where the caret was inside the text field even after it loses // focus — Radix's menu trigger steals focus, so by the time the Scissors // item fires, selectionStart on the input would otherwise read as null. const lastCursorRef = useRef(null); const speakerOptions = speakerClones ? Object.keys(speakerClones) : []; const speakerListId = `seg-speakers-${seg.id}`; const syncColor = seg.sync_ratio === undefined ? null : (seg.sync_ratio >= 0.95 && seg.sync_ratio <= 1.05) ? '#b8bb26' : seg.sync_ratio > 1.25 ? '#fb4934' : '#fabd2f'; const SyncIcon = seg.sync_ratio === undefined ? null : (seg.sync_ratio >= 0.95 && seg.sync_ratio <= 1.05) ? CheckCircle : seg.sync_ratio > 1.25 ? AlertCircle : Circle; const overBudget = seg.text_original && seg.text.length > Math.ceil(seg.text_original.length * CHAR_BUDGET_RATIO); const handleTextKeyDown = (e) => { // Ctrl/Cmd+D → split at cursor, Ctrl/Cmd+M → merge with next if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); const pos = e.target.selectionStart ?? seg.text.length; onSplit(seg.id, pos); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'm' || e.key === 'M')) { e.preventDefault(); if (canMerge) onMerge(seg.id); } }; const captureCursor = (e) => { const pos = e.target.selectionStart; if (pos != null) lastCursorRef.current = pos; }; // Row click → seek the player to this segment. We don't fire when the click // originates in an interactive element (input, button, the actions cluster) // so per-field editing keeps working. const handleRowClick = (e) => { if (!onSeek) return; if (e.target.closest('input, button, select, textarea, label, [data-noseek]')) return; onSeek(seg.start); }; return (
onSelect(seg.id, idx, e.nativeEvent.shiftKey)} onClick={(e) => onSelect(seg.id, idx, e.shiftKey)} disabled={disabled} style={{ accentColor: '#d3869b' }} className="seg-check" title="Select segment (shift+click for range)" /> e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') { e.target.value = formatTime(seg.start); e.target.blur(); } }} onBlur={(e) => { const v = parseTime(e.target.value); if (v == null || v < 0 || v >= seg.end) { e.target.value = formatTime(seg.start); return; } if (Math.abs(v - seg.start) > 1e-3) { onEditField(seg.id, 'start', +v.toFixed(3)); } else { e.target.value = formatTime(seg.start); } }} /> {formatTime(seg.end)} {seg.speed && seg.speed !== 1.0 && ( 1 ? '#d3869b' : '#8ec07c' }}> {seg.speed.toFixed(2)}x )} {SyncIcon && ( Sync: {Math.round(seg.sync_ratio * 100)}% )} {seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && ( 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }} title={`Speech-rate fit: ${seg.rate_ratio.toFixed(2)}× relative to slot${seg.rate_error ? ` (${seg.rate_error})` : ''}`} > 📖 {seg.rate_ratio.toFixed(2)}× )} onEditField(seg.id, 'speaker_id', e.target.value)} onClick={(e) => e.stopPropagation()} disabled={disabled} list={speakerOptions.length ? speakerListId : undefined} placeholder={speakerOptions.length ? 'Pick…' : ''} title={speakerOptions.length ? 'Speaker — pick from detected, or type a custom name' : 'Speaker — type a name (no diarization clones detected)'} /> {speakerOptions.length > 0 && ( {speakerOptions.map(spk => )} onEditField(seg.id, 'text', e.target.value)} onKeyDown={handleTextKeyDown} onKeyUp={captureCursor} onSelect={captureCursor} onClick={(e) => { e.stopPropagation(); captureCursor(e); }} disabled={disabled} title={seg.translate_error ? `Translation error: ${seg.translate_error}` : overBudget ? `Text is ${Math.round((seg.text.length / seg.text_original.length) * 100)}% of original — consider higher speed or shorter phrasing` : 'Ctrl+D to split at cursor · Ctrl+M to merge with next'} style={ overBudget ? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' } : seg.translate_error ? { borderColor: 'rgba(251,73,52,0.5)' } : undefined } /> {seg.text_original && seg.text_original !== seg.text && ( orig {seg.text_original} {overBudget && ( {Math.round((seg.text.length / seg.text_original.length) * 100)}% )} )} onEditField(seg.id, 'gain', Number(e.target.value) / 100)} className="seg-gain-slider" style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }} />
onDirect?.(seg), }, 'separator', { id: 'split', label: 'Split at cursor', icon: Scissors, shortcut: '⌘D', onSelect: () => { // Prefer the live cursor if the text input still has focus, // otherwise fall back to the last remembered position, then // to a sentence-boundary heuristic. Splitting at the literal // midpoint (the old behaviour) felt random to users. let pos = textInputRef.current?.selectionStart; if (pos == null || pos <= 0 || pos >= seg.text.length) { pos = lastCursorRef.current; } if (pos == null || pos <= 0 || pos >= seg.text.length) { pos = bestSplitPoint(seg.text); } onSplit(seg.id, pos); }, }, { id: 'merge', label: 'Merge with next', icon: Merge, shortcut: '⌘M', disabled: !canMerge, onSelect: () => onMerge(seg.id), }, ]} >
); } export default memo(DubSegmentRow, (prev, next) => ( prev.seg === next.seg && prev.disabled === next.disabled && prev.isActive === next.isActive && prev.isDone === next.isDone && prev.previewLoading === next.previewLoading && prev.onDirect === next.onDirect && prev.onSeek === next.onSeek && prev.selected === next.selected && prev.canMerge === next.canMerge && prev.profiles === next.profiles && prev.speakerClones === next.speakerClones && prev.idx === next.idx ));