// Transcript of what is currently in the timeline. Click a word to seek; shift-click to select a // range of words, which can then be cut from the timeline. import { useEffect, useMemo, useRef } from 'react'; import type { Segment, Take, Transcript } from '../../shared/types.ts'; import { segmentStarts } from '../../shared/timeline.ts'; import type { TimelinePlayer } from '../lib/player.ts'; import { Icon } from './Icon.tsx'; interface TWord { text: string; t0: number; t1: number; seg: number; } interface Props { segments: Segment[]; takes: Take[]; transcripts: Record; takeNumber: (takeId: string) => number; player: TimelinePlayer; marks: { in: number | null; out: number | null }; onMarks: (m: { in: number | null; out: number | null }) => void; onCut: () => void; onTranscribe: (takeId: string) => void; hasKey: boolean; } export function TranscriptPanel({ segments, takes, transcripts, takeNumber, player, marks, onMarks, onCut, onTranscribe, hasKey }: Props) { const words = useMemo(() => { const out: TWord[] = []; const starts = segmentStarts(segments); segments.forEach((s, i) => { const tr = transcripts[s.takeId]; if (!tr) return; for (const w of tr.words) { const mid = (w.start + w.end) / 2; if (mid < s.start || mid >= s.end) continue; out.push({ text: w.text, t0: starts[i] + Math.max(0, w.start - s.start), t1: starts[i] + Math.min(s.end, w.end) - s.start, seg: i }); } }); return out; }, [segments, transcripts]); const containerRef = useRef(null); const anchor = useRef(null); // Highlight the word under the playhead without re-rendering the whole list. useEffect(() => { let current = -1; const update = () => { const t = player.getSnapshot().time; let lo = 0; let hi = words.length - 1; let idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (words[m].t0 <= t) { idx = m; lo = m + 1; } else hi = m - 1; } if (idx >= 0 && t > words[idx].t1 + 0.3) idx = -1; if (idx === current) return; const root = containerRef.current; root?.querySelector(`[data-i="${current}"]`)?.classList.remove('current'); const el = root?.querySelector(`[data-i="${idx}"]`); el?.classList.add('current'); if (el && player.getSnapshot().playing) el.scrollIntoView({ block: 'nearest' }); current = idx; }; update(); return player.subscribe(update); }, [player, words]); const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in; const inRange = (w: TWord) => hasRange && w.t0 >= marks.in! - 0.01 && w.t1 <= marks.out! + 0.01; const onWordClick = (i: number, shift: boolean) => { const w = words[i]; if (shift && anchor.current !== null) { const a = Math.min(anchor.current, i); const b = Math.max(anchor.current, i); onMarks({ in: words[a].t0, out: words[b].t1 }); } else { anchor.current = i; onMarks({ in: null, out: null }); player.seek(w.t0); } }; const takeIds = [...new Set(segments.map((s) => s.takeId))]; const status = takeIds .map((id) => takes.find((t) => t.id === id)) .filter((t): t is Take => !!t && t.transcript?.status !== 'done'); return (
Click a word to jump there. Shift-click another word to select a range.
{status.length > 0 && (
{status.map((t) => (
Take {takeNumber(t.id)}: {!t.audio ? ( no audio ) : t.transcript?.status === 'pending' ? ( transcribing… ) : ( <> {t.transcript?.status === 'failed' ? `failed (${t.transcript.error})` : 'not transcribed'} {hasKey ? ( ) : ( Add a Deepgram key in Settings )} )}
))}
)}
{words.length === 0 && status.length === 0 &&

No words.

} {words.map((w, i) => ( {i > 0 && words[i - 1].seg !== w.seg && } e.shiftKey && e.preventDefault()} onClick={(e) => onWordClick(i, e.shiftKey)}> {w.text} {' '} ))}
); }