import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { List } from 'react-window'; import DubSegmentRow from './DubSegmentRow'; import { Table, Select } from '../ui'; import './DubSegmentTable.css'; const BASE_ROW_HEIGHT = 26; const ROW_HEIGHT_WITH_ORIG = 40; const COLUMNS = [ { key: 'time', label: 'Time', width: 46 }, { key: 'spkr', label: 'Spkr', width: 40 }, { key: 'text', label: 'Text', flex: 1 }, { key: 'lang', label: 'Lang', width: 38 }, { key: 'voice', label: 'Voice', width: 56 }, { key: 'vol', label: 'Vol', width: 36, title: 'Volume (0–200%)' }, { key: 'act', label: '', width: 38 }, ]; export default function DubSegmentTable({ segments, profiles, speakerClones, dubStep, dubProgress, previewLoadingId, selectedIds, onSelect, onSelectAll, onClearSelection, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, }) { const disabled = dubStep === 'generating' || dubStep === 'stopping'; const [query, setQuery] = useState(''); const [speakerFilter, setSpeakerFilter] = useState(''); // react-window v2 needs a concrete height prop — CSS 100 % doesn't cut it. // Measure the body container and pass its height explicitly so the list // renders every row that fits, not just a default-sized window. const bodyRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(0); useLayoutEffect(() => { if (!bodyRef.current) return; const measure = () => { const h = bodyRef.current?.clientHeight || 0; setBodyHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(bodyRef.current); return () => ro.disconnect(); }, []); const speakers = useMemo(() => { const s = new Set(segments.map(x => x.speaker_id).filter(Boolean)); return Array.from(s).sort(); }, [segments]); const filtered = useMemo(() => { if (!query && !speakerFilter) return segments; const q = query.trim().toLowerCase(); return segments.filter(s => { if (speakerFilter && s.speaker_id !== speakerFilter) return false; if (!q) return true; return (s.text && s.text.toLowerCase().includes(q)) || (s.text_original && s.text_original.toLowerCase().includes(q)); }); }, [segments, query, speakerFilter]); const rowHeight = useCallback((index) => { const s = filtered[index]; if (!s) return BASE_ROW_HEIGHT; return (s.text_original && s.text_original !== s.text) ? ROW_HEIGHT_WITH_ORIG : BASE_ROW_HEIGHT; }, [filtered]); const rowProps = useMemo(() => ({ filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId, selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, segments, }), [filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId, selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, segments]); const Row = useCallback(({ index, style, filtered: fl, profiles: profs, speakerClones: clones, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, onSeek: seek, segments: segs }) => { const seg = fl[index]; if (!seg) return null; const absoluteIndex = segs.indexOf(seg); const isActive = (step === 'generating' || step === 'stopping') && prog.current === absoluteIndex + 1; const isDone = (step === 'generating' || step === 'stopping') && prog.current > absoluteIndex + 1; const canMerge = index < fl.length - 1; return ( ); }, []); const allFilteredSelected = filtered.length > 0 && filtered.every(s => selectedIds && selectedIds.has(s.id)); const selCount = selectedIds?.size ?? 0; const meta = ( <> {filtered.length}/{segments.length} {selCount > 0 && · {selCount} sel} ); return ( {speakers.length > 1 && ( )} e.target.checked ? onSelectAll(filtered) : onClearSelection()} title="Select all filtered" /> } />
{bodyHeight > 0 && ( )}
); }