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 && (
)}
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' }}
/>
);
}
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
));