···
12
+
vttSegments?: VTTSegment[];
15
+
interface VTTSegment {
23
+
function parseVTT(vttContent: string): VTTSegment[] {
24
+
const segments: VTTSegment[] = [];
25
+
const lines = vttContent.split("\n");
28
+
// Skip WEBVTT header
29
+
while (i < lines.length && !lines[i]?.includes("-->")) {
33
+
while (i < lines.length) {
34
+
const line = lines[i];
35
+
if (line?.includes("-->")) {
36
+
const [startStr, endStr] = line.split("-->").map((s) => s.trim());
37
+
const start = parseVTTTimestamp(startStr || "");
38
+
const end = parseVTTTimestamp(endStr || "");
40
+
// Collect text lines until empty line
41
+
const textLines: string[] = [];
43
+
while (i < lines.length && lines[i]?.trim()) {
44
+
textLines.push(lines[i] || "");
51
+
text: textLines.join(" ").trim(),
60
+
function 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;
···
294
+
transition: background 0.1s;
299
+
background: color-mix(in srgb, var(--primary) 15%, transparent);
300
+
border-radius: 2px;
304
+
background: color-mix(in srgb, var(--accent) 30%, transparent);
305
+
border-radius: 2px;
313
+
.audio-player audio {
···
const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
// Handle named "update" events from SSE stream
299
-
eventSource.addEventListener("update", (event) => {
383
+
eventSource.addEventListener("update", async (event) => {
const update = JSON.parse(event.data);
// Update the job in our list efficiently (mutate in place for Lit)
···
this.wordStreamers.delete(jobId);
this.lastTranscripts.delete(jobId);
451
+
// Load VTT for completed jobs
452
+
if (update.status === "completed") {
453
+
await this.loadVTTForJob(jobId);
454
+
this.setupWordHighlighting(jobId);
···
if ((job.status === "completed" || job.status === "failed") && job.transcript) {
this.displayedTranscripts.set(job.id, job.transcript);
522
+
// Fetch VTT for completed jobs
523
+
if (job.status === "completed") {
524
+
await this.loadVTTForJob(job.id);
525
+
await this.updateComplete;
526
+
this.setupWordHighlighting(job.id);
// Don't override serviceAvailable - it's set by checkHealth()
} else if (response.status === 404) {
···
543
+
private async loadVTTForJob(jobId: string) {
545
+
const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`);
547
+
const vttContent = await response.text();
548
+
const segments = parseVTT(vttContent);
550
+
// Update job with VTT segments
551
+
const job = this.jobs.find((j) => j.id === jobId);
553
+
job.vttSegments = segments;
554
+
job.audioUrl = `/api/transcriptions/${jobId}/audio`;
555
+
this.jobs = [...this.jobs];
559
+
console.warn(`Failed to load VTT for job ${jobId}:`, error);
563
+
private setupWordHighlighting(jobId: string) {
564
+
const job = this.jobs.find((j) => j.id === jobId);
565
+
if (!job?.audioUrl || !job.vttSegments) return;
567
+
// Wait for next frame to ensure DOM is updated
568
+
requestAnimationFrame(() => {
569
+
const audioElement = this.shadowRoot?.querySelector(
571
+
) as HTMLAudioElement;
572
+
const transcriptDiv = this.shadowRoot?.querySelector(
573
+
`#transcript-${jobId}`,
574
+
) as HTMLDivElement;
576
+
if (!audioElement || !transcriptDiv) {
577
+
console.warn("Could not find audio or transcript elements");
581
+
// Track current segment
582
+
let currentSegmentElement: HTMLElement | null = null;
584
+
// Update highlighting on timeupdate
585
+
audioElement.addEventListener("timeupdate", () => {
586
+
const currentTime = audioElement.currentTime;
587
+
const segmentElements = transcriptDiv.querySelectorAll("[data-start]");
589
+
for (const el of segmentElements) {
590
+
const start = Number.parseFloat(
591
+
(el as HTMLElement).dataset.start || "0",
593
+
const end = Number.parseFloat((el as HTMLElement).dataset.end || "0");
595
+
if (currentTime >= start && currentTime <= end) {
596
+
if (currentSegmentElement !== el) {
597
+
currentSegmentElement?.classList.remove("current-segment");
598
+
(el as HTMLElement).classList.add("current-segment");
599
+
currentSegmentElement = el as HTMLElement;
601
+
// Auto-scroll to current segment
602
+
el.scrollIntoView({
603
+
behavior: "smooth",
612
+
// Handle segment clicks
613
+
transcriptDiv.addEventListener("click", (e) => {
614
+
const target = e.target as HTMLElement;
615
+
if (target.dataset.start) {
616
+
const start = Number.parseFloat(target.dataset.start);
617
+
audioElement.currentTime = start;
618
+
audioElement.play();
private handleDragOver(e: DragEvent) {
···
private renderTranscript(job: TranscriptionJob) {
541
-
const displayed = this.displayedTranscripts.get(job.id) || "";
719
+
if (!job.vttSegments) {
720
+
const displayed = this.displayedTranscripts.get(job.id) || "";
724
+
const segments = job.vttSegments;
725
+
// Render segments as clickable spans
726
+
return html`${segments.map(
727
+
(segment, idx) => html`<span
729
+
data-start="${segment.start}"
730
+
data-end="${segment.end}"
731
+
>${segment.text}</span>${idx < segments.length - 1 ? " " : ""}`,
···
591
-
this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
783
+
job.status === "completed" && job.audioUrl && job.vttSegments
785
+
<div class="audio-player">
786
+
<audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
788
+
<div class="job-transcript" id="transcript-${job.id}">
789
+
${this.renderTranscript(job)}
792
+
: this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
<div class="job-transcript">${this.renderTranscript(job)}</div>