馃 distributed transcription service
thistle.dunkirk.sh
1import { LitElement, html, css } from "lit";
2import { customElement, property } from "lit/decorators.js";
3
4interface VTTSegment {
5 start: number;
6 end: number;
7 text: string;
8 index?: string;
9}
10
11function parseVTT(vttContent: string): VTTSegment[] {
12 const segments: VTTSegment[] = [];
13 const lines = vttContent.split("\n");
14
15 let i = 0;
16 // Skip WEBVTT header if present
17 while (i < lines.length && (lines[i] || "").trim() !== "WEBVTT") {
18 i++;
19 }
20 if (i < lines.length) i++; // advance past header if found
21
22 while (i < lines.length) {
23 let index: string | undefined;
24 let line = lines[i] || "";
25
26 // Check for cue ID (line before timestamp)
27 if (line.trim() && !line.includes("-->")) {
28 index = line.trim();
29 i++;
30 line = lines[i] || "";
31 }
32
33 if (line.includes("-->")) {
34 const parts = line.split("-->").map((s) => s.trim());
35 const start = parseVTTTimestamp(parts[0] ?? "");
36 const end = parseVTTTimestamp(parts[1] ?? "");
37
38 // Collect text lines until empty line
39 const textLines: string[] = [];
40 i++;
41 while (i < lines.length && (lines[i] || "").trim()) {
42 textLines.push(lines[i] || "");
43 i++;
44 }
45
46 segments.push({
47 start,
48 end,
49 text: textLines.join(" ").trim(),
50 index,
51 });
52 } else {
53 i++;
54 }
55 }
56
57 return segments;
58}
59
60function parseVTTTimestamp(timestamp?: string): number {
61 const parts = (timestamp || "").split(":");
62 if (parts.length === 3) {
63 const hours = Number.parseFloat(parts[0] || "0");
64 const minutes = Number.parseFloat(parts[1] || "0");
65 const seconds = Number.parseFloat(parts[2] || "0");
66 return hours * 3600 + minutes * 60 + seconds;
67 }
68 return 0;
69}
70
71@customElement("vtt-viewer")
72export class VTTViewer extends LitElement {
73 @property({ type: String }) vttContent = "";
74 @property({ type: String }) audioId = "";
75
76 static override styles = css`
77 .transcript {
78 background: color-mix(in srgb, var(--primary) 5%, transparent);
79 border-radius: 6px;
80 padding: 1rem;
81 font-family: monospace;
82 font-size: 0.875rem;
83 color: var(--text);
84 line-height: 1.6;
85 word-wrap: break-word;
86 }
87
88 .segment {
89 cursor: pointer;
90 transition: background 0.1s;
91 display: inline;
92 }
93
94 .segment:hover {
95 background: color-mix(in srgb, var(--primary) 15%, transparent);
96 border-radius: 2px;
97 }
98
99 .current-segment {
100 background: color-mix(in srgb, var(--accent) 30%, transparent);
101 border-radius: 2px;
102 }
103
104 .paragraph {
105 display: block;
106 margin: 0 0 1rem 0;
107 line-height: 1.6;
108 }
109 `;
110
111 private audioElement: HTMLAudioElement | null = null;
112 private boundTimeUpdate: ((this: HTMLAudioElement, ev: Event) => any) | null = null;
113 private boundTranscriptClick: ((e: Event) => any) | null = null;
114
115 private _viewerId = `vtt-${Math.random().toString(36).slice(2,9)}`;
116
117 private findAudioElementById(id: string): HTMLAudioElement | null {
118 let root: any = this.getRootNode();
119 let depth = 0;
120 while (root && depth < 10) {
121 if (root instanceof ShadowRoot) {
122 const el = root.querySelector(`#${id}`) as HTMLAudioElement | null;
123 if (el) return el;
124 root = (root as ShadowRoot).host?.getRootNode?.();
125 } else if (root instanceof Document) {
126 const byId = root.getElementById(id) as HTMLAudioElement | null;
127 if (byId) return byId;
128 break;
129 } else {
130 break;
131 }
132 depth++;
133 }
134 return null;
135 }
136
137 private setupHighlighting() {
138 // Detach previous listeners if any
139 this.detachHighlighting();
140
141 const audioElement = this.findAudioElementById(this.audioId);
142 const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
143 if (!audioElement || !transcriptDiv) return;
144
145 // Clear any lingering highlights from prior instances
146 transcriptDiv.querySelectorAll('.current-segment').forEach((el) => (el as HTMLElement).classList.remove('current-segment'));
147
148 this.audioElement = audioElement;
149 let currentSegmentElement: HTMLElement | null = null;
150
151 this.boundTimeUpdate = () => {
152 const currentTime = this.audioElement?.currentTime ?? 0;
153 const segmentElements = transcriptDiv.querySelectorAll('[data-start]');
154 let found = false;
155
156 for (const el of Array.from(segmentElements)) {
157 const start = Number.parseFloat((el as HTMLElement).dataset.start || '0');
158 const end = Number.parseFloat((el as HTMLElement).dataset.end || '0');
159
160 if (currentTime >= start && currentTime <= end) {
161 found = true;
162 if (currentSegmentElement !== el) {
163 currentSegmentElement?.classList.remove('current-segment');
164 (el as HTMLElement).classList.add('current-segment');
165 currentSegmentElement = el as HTMLElement;
166 (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
167 }
168 break;
169 }
170 }
171
172 // If no segment matched, clear any existing highlight
173 if (!found && currentSegmentElement) {
174 currentSegmentElement.classList.remove('current-segment');
175 currentSegmentElement = null;
176 }
177 };
178
179 audioElement.addEventListener('timeupdate', this.boundTimeUpdate as EventListener);
180
181 this.boundTranscriptClick = (e: Event) => {
182 const target = e.target as HTMLElement;
183 if (target.dataset.start) {
184 this.audioElement!.currentTime = Number.parseFloat(target.dataset.start);
185 this.audioElement!.play();
186 }
187 };
188
189 transcriptDiv.addEventListener('click', this.boundTranscriptClick);
190 }
191
192 private detachHighlighting() {
193 try {
194 const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
195 if (this.audioElement) {
196 // Pause playback to avoid audio continuing after the viewer is removed
197 try {
198 this.audioElement.pause();
199 } catch (e) {
200 // ignore
201 }
202 if (this.boundTimeUpdate) {
203 this.audioElement.removeEventListener('timeupdate', this.boundTimeUpdate);
204 }
205 }
206 if (transcriptDiv && this.boundTranscriptClick) {
207 transcriptDiv.removeEventListener('click', this.boundTranscriptClick);
208 }
209 } finally {
210 this.audioElement = null;
211 this.boundTimeUpdate = null;
212 this.boundTranscriptClick = null;
213 }
214 }
215
216 override disconnectedCallback() {
217 this.detachHighlighting();
218 super.disconnectedCallback && super.disconnectedCallback();
219 }
220
221 override updated(changed: Map<string, any>) {
222 super.updated(changed);
223 if (changed.has('vttContent') || changed.has('audioId')) {
224 this.setupHighlighting();
225 }
226 }
227
228 private renderFromVTT() {
229 if (!this.vttContent) return html``;
230 const segments = parseVTT(this.vttContent);
231 const paragraphGroups = new Map<string, VTTSegment[]>();
232
233 for (const segment of segments) {
234 const id = (segment.index || "").trim();
235 const match = id.match(/^Paragraph\s+(\d+)-/);
236 const paraNum = match ? match[1] : '0';
237 if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []);
238 paragraphGroups.get(paraNum)!.push(segment);
239 }
240
241 const paragraphs = Array.from(paragraphGroups.entries()).map(([_, groupSegments]) => {
242 const fullText = groupSegments.map(s => s.text || '').join(' ');
243 const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean);
244 const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
245 const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0));
246 const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0));
247 const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart));
248 let acc = 0;
249 const paraDuration = paraEnd - paraStart;
250
251 return html`<div class="paragraph">${sentences.map((sent, si) => {
252 const startOffset = (acc / totalWords) * paraDuration;
253 acc += wordCounts[si];
254 const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration;
255 const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart;
256 const spanStart = paraStart + startOffset;
257 const spanEnd = paraStart + endOffset;
258 return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`;
259 })}</div>`;
260 });
261
262 return html`${paragraphs}`;
263 }
264
265 override render() {
266 return html`<div class="transcript">${this.renderFromVTT()}</div>`;
267 }
268}