馃 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}