1// Transcript of what is currently in the timeline. Click a word to seek; shift-click to select a
2// range of words, which can then be cut from the timeline.
4import { useEffect, useMemo, useRef } from 'react';
5import type { Segment, Take, Transcript } from '../../shared/types.ts';
6import { segmentStarts } from '../../shared/timeline.ts';
7import type { TimelinePlayer } from '../lib/player.ts';
8import { Icon } from './Icon.tsx';
10interface TWord {
11 text: string;
12 t0: number;
13 t1: number;
14 seg: number;
15}
17interface Props {
18 segments: Segment[];
19 takes: Take[];
20 transcripts: Record<string, Transcript>;
21 takeNumber: (takeId: string) => number;
22 player: TimelinePlayer;
23 marks: { in: number | null; out: number | null };
24 onMarks: (m: { in: number | null; out: number | null }) => void;
25 onCut: () => void;
26 onTranscribe: (takeId: string) => void;
27 hasKey: boolean;
28}
30export function TranscriptPanel({ segments, takes, transcripts, takeNumber, player, marks, onMarks, onCut, onTranscribe, hasKey }: Props) {
31 const words = useMemo(() => {
32 const out: TWord[] = [];
33 const starts = segmentStarts(segments);
34 segments.forEach((s, i) => {
35 const tr = transcripts[s.takeId];
36 if (!tr) return;
37 for (const w of tr.words) {
38 const mid = (w.start + w.end) / 2;
39 if (mid < s.start || mid >= s.end) continue;
40 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 });
41 }
42 });
43 return out;
44 }, [segments, transcripts]);
46 const containerRef = useRef<HTMLDivElement>(null);
47 const anchor = useRef<number | null>(null);
49 // Highlight the word under the playhead without re-rendering the whole list.
50 useEffect(() => {
51 let current = -1;
52 const update = () => {
53 const t = player.getSnapshot().time;
54 let lo = 0;
55 let hi = words.length - 1;
56 let idx = -1;
57 while (lo <= hi) {
58 const m = (lo + hi) >> 1;
59 if (words[m].t0 <= t) {
60 idx = m;
61 lo = m + 1;
62 } else hi = m - 1;
63 }
64 if (idx >= 0 && t > words[idx].t1 + 0.3) idx = -1;
65 if (idx === current) return;
66 const root = containerRef.current;
67 root?.querySelector(`[data-i="${current}"]`)?.classList.remove('current');
68 const el = root?.querySelector(`[data-i="${idx}"]`);
69 el?.classList.add('current');
70 if (el && player.getSnapshot().playing) el.scrollIntoView({ block: 'nearest' });
71 current = idx;
72 };
73 update();
74 return player.subscribe(update);
75 }, [player, words]);
77 const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
78 const inRange = (w: TWord) => hasRange && w.t0 >= marks.in! - 0.01 && w.t1 <= marks.out! + 0.01;
80 const onWordClick = (i: number, shift: boolean) => {
81 const w = words[i];
82 if (shift && anchor.current !== null) {
83 const a = Math.min(anchor.current, i);
84 const b = Math.max(anchor.current, i);
85 onMarks({ in: words[a].t0, out: words[b].t1 });
86 } else {
87 anchor.current = i;
88 onMarks({ in: null, out: null });
89 player.seek(w.t0);
90 }
91 };
93 const takeIds = [...new Set(segments.map((s) => s.takeId))];
94 const status = takeIds
95 .map((id) => takes.find((t) => t.id === id))
96 .filter((t): t is Take => !!t && t.transcript?.status !== 'done');
98 return (
99 <div className="transcript">
100 <div className="transcript-bar">
101 <span className="muted small">Click a word to jump there. Shift-click another word to select a range.</span>
102 <button className="btn small danger" disabled={!hasRange} onClick={onCut} title="Cut selection (X)">
103 <Icon name="scissors" /> Cut selection
104 </button>
105 </div>
106 {status.length > 0 && (
107 <div className="transcript-status">
108 {status.map((t) => (
109 <div key={t.id} className="row small">
110 <span>Take {takeNumber(t.id)}:</span>
111 {!t.audio ? (
112 <span className="muted">no audio</span>
113 ) : t.transcript?.status === 'pending' ? (
114 <span className="muted">transcribing…</span>
115 ) : (
116 <>
117 <span className="muted">{t.transcript?.status === 'failed' ? `failed (${t.transcript.error})` : 'not transcribed'}</span>
118 {hasKey ? (
119 <button className="btn small" onClick={() => onTranscribe(t.id)}>
120 Transcribe
121 </button>
122 ) : (
123 <a href="#/settings">Add a Deepgram key in Settings</a>
124 )}
125 </>
126 )}
127 </div>
128 ))}
129 </div>
130 )}
131 <div className="transcript-words" ref={containerRef}>
132 {words.length === 0 && status.length === 0 && <p className="muted">No words.</p>}
133 {words.map((w, i) => (
134 <span key={i}>
135 {i > 0 && words[i - 1].seg !== w.seg && <span className="seg-break" title="Cut / segment boundary" />}
136 <span data-i={i} className={`word${inRange(w) ? ' selected' : ''}`} onMouseDown={(e) => e.shiftKey && e.preventDefault()} onClick={(e) => onWordClick(i, e.shiftKey)}>
137 {w.text}
138 </span>{' '}
139 </span>
140 ))}
141 </div>
142 </div>
143 );
144}