馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import { parseVTT } from "../lib/vtt-cleaner";
4
5interface TranscriptionJob {
6 id: string;
7 filename: string;
8 status: "uploading" | "processing" | "transcribing" | "completed" | "failed";
9 progress: number;
10 transcript?: string;
11 created_at: number;
12 audioUrl?: string;
13 vttSegments?: VTTSegment[];
14 vttContent?: string;
15}
16
17interface VTTSegment {
18 start: number;
19 end: number;
20 text: string;
21 index?: string;
22}
23
24
25
26function parseVTT(vttContent: string): VTTSegment[] {
27 const segments: VTTSegment[] = [];
28 const lines = vttContent.split("\n");
29
30 let i = 0;
31 // Skip WEBVTT header
32 while (i < lines.length && lines[i]?.trim() !== "WEBVTT") {
33 i++;
34 }
35 i++; // Skip WEBVTT
36
37 while (i < lines.length) {
38 let index: string | undefined;
39 // Check for cue ID (line before timestamp)
40 if (lines[i]?.trim() && !lines[i]?.includes("-->")) {
41 index = lines[i]?.trim();
42 i++;
43 }
44
45 if (i < lines.length && lines[i]?.includes("-->")) {
46 const [startStr, endStr] = lines[i].split("-->").map((s) => s.trim());
47 const start = parseVTTTimestamp(startStr || "");
48 const end = parseVTTTimestamp(endStr || "");
49
50 // Collect text lines until empty line
51 const textLines: string[] = [];
52 i++;
53 while (i < lines.length && lines[i]?.trim()) {
54 textLines.push(lines[i] || "");
55 i++;
56 }
57
58 segments.push({
59 start,
60 end,
61 text: textLines.join(" ").trim(),
62 index,
63 });
64 } else {
65 i++;
66 }
67 }
68
69 return segments;
70}
71
72function parseVTTTimestamp(timestamp: string): number {
73 const parts = timestamp.split(":");
74 if (parts.length === 3) {
75 const hours = Number.parseFloat(parts[0] || "0");
76 const minutes = Number.parseFloat(parts[1] || "0");
77 const seconds = Number.parseFloat(parts[2] || "0");
78 return hours * 3600 + minutes * 60 + seconds;
79 }
80 return 0;
81}
82
83class WordStreamer {
84 private queue: string[] = [];
85 private isProcessing = false;
86 private wordDelay: number;
87 private onWord: (word: string) => void;
88
89 constructor(wordDelay: number = 50, onWord: (word: string) => void) {
90 this.wordDelay = wordDelay;
91 this.onWord = onWord;
92 }
93
94 addChunk(text: string) {
95 // Split on whitespace and filter out empty strings
96 const words = text.split(/(\s+)/).filter((w) => w.length > 0);
97 this.queue.push(...words);
98
99 // Start processing if not already running
100 if (!this.isProcessing) {
101 this.processQueue();
102 }
103 }
104
105 private async processQueue() {
106 this.isProcessing = true;
107
108 while (this.queue.length > 0) {
109 const word = this.queue.shift()!;
110 this.onWord(word);
111 await new Promise((resolve) => setTimeout(resolve, this.wordDelay));
112 }
113
114 this.isProcessing = false;
115 }
116
117 showAll() {
118 // Drain entire queue immediately
119 while (this.queue.length > 0) {
120 const word = this.queue.shift()!;
121 this.onWord(word);
122 }
123 this.isProcessing = false;
124 }
125
126 clear() {
127 this.queue = [];
128 this.isProcessing = false;
129 }
130}
131
132@customElement("transcription-component")
133export class TranscriptionComponent extends LitElement {
134 @state() jobs: TranscriptionJob[] = [];
135 @state() isUploading = false;
136 @state() dragOver = false;
137 @state() serviceAvailable = true;
138 // Word streamers for each job
139 private wordStreamers = new Map<string, WordStreamer>();
140 // Displayed transcripts
141 private displayedTranscripts = new Map<string, string>();
142 // Track last full transcript to compare
143 private lastTranscripts = new Map<string, string>();
144
145 static override styles = css`
146 :host {
147 display: block;
148 }
149
150 .upload-area {
151 border: 2px dashed var(--secondary);
152 border-radius: 8px;
153 padding: 3rem 2rem;
154 text-align: center;
155 transition: all 0.2s;
156 cursor: pointer;
157 background: var(--background);
158 }
159
160 .upload-area:hover,
161 .upload-area.drag-over {
162 border-color: var(--primary);
163 background: color-mix(in srgb, var(--primary) 5%, transparent);
164 }
165
166 .upload-area.disabled {
167 border-color: var(--secondary);
168 opacity: 0.6;
169 cursor: not-allowed;
170 }
171
172 .upload-area.disabled:hover {
173 border-color: var(--secondary);
174 background: transparent;
175 }
176
177 .upload-icon {
178 font-size: 3rem;
179 color: var(--secondary);
180 margin-bottom: 1rem;
181 }
182
183 .upload-text {
184 color: var(--text);
185 font-size: 1.125rem;
186 font-weight: 500;
187 margin-bottom: 0.5rem;
188 }
189
190 .upload-hint {
191 color: var(--text);
192 opacity: 0.7;
193 font-size: 0.875rem;
194 }
195
196 .jobs-section {
197 margin-top: 2rem;
198 }
199
200 .jobs-title {
201 font-size: 1.25rem;
202 font-weight: 600;
203 color: var(--text);
204 margin-bottom: 1rem;
205 }
206
207 .job-card {
208 background: var(--background);
209 border: 1px solid var(--secondary);
210 border-radius: 8px;
211 padding: 1.5rem;
212 margin-bottom: 1rem;
213 }
214
215 .job-header {
216 display: flex;
217 align-items: center;
218 justify-content: space-between;
219 margin-bottom: 1rem;
220 }
221
222 .job-filename {
223 font-weight: 500;
224 color: var(--text);
225 }
226
227 .job-status {
228 padding: 0.25rem 0.75rem;
229 border-radius: 4px;
230 font-size: 0.75rem;
231 font-weight: 600;
232 text-transform: uppercase;
233 }
234
235 .status-uploading {
236 background: color-mix(in srgb, var(--primary) 10%, transparent);
237 color: var(--primary);
238 }
239
240 .status-processing {
241 background: color-mix(in srgb, var(--primary) 10%, transparent);
242 color: var(--primary);
243 }
244
245 .status-transcribing {
246 background: color-mix(in srgb, var(--accent) 10%, transparent);
247 color: var(--accent);
248 }
249
250 .status-completed {
251 background: color-mix(in srgb, var(--success) 10%, transparent);
252 color: var(--success);
253 }
254
255 .status-failed {
256 background: color-mix(in srgb, var(--text) 10%, transparent);
257 color: var(--text);
258 }
259
260 .progress-bar {
261 width: 100%;
262 height: 4px;
263 background: var(--secondary);
264 border-radius: 2px;
265 margin-bottom: 1rem;
266 overflow: hidden;
267 position: relative;
268 }
269
270 .progress-fill {
271 height: 100%;
272 background: var(--primary);
273 border-radius: 2px;
274 transition: width 0.3s;
275 }
276
277 .progress-fill.indeterminate {
278 width: 30%;
279 background: var(--primary);
280 animation: progress-slide 1.5s ease-in-out infinite;
281 }
282
283 @keyframes progress-slide {
284 0% {
285 transform: translateX(-100%);
286 }
287 100% {
288 transform: translateX(333%);
289 }
290 }
291
292 .job-transcript {
293 background: color-mix(in srgb, var(--primary) 5%, transparent);
294 border-radius: 6px;
295 padding: 1rem;
296 margin-top: 1rem;
297 font-family: monospace;
298 font-size: 0.875rem;
299 color: var(--text);
300 line-height: 1.6;
301 word-wrap: break-word;
302 }
303
304 .segment {
305 cursor: pointer;
306 transition: background 0.1s;
307 display: inline;
308 }
309
310 .segment:hover {
311 background: color-mix(in srgb, var(--primary) 15%, transparent);
312 border-radius: 2px;
313 }
314
315 .current-segment {
316 background: color-mix(in srgb, var(--accent) 30%, transparent);
317 border-radius: 2px;
318 }
319
320 .paragraph {
321 display: block;
322 margin: 0 0 1rem 0;
323 line-height: 1.6;
324 }
325
326 .audio-player {
327 margin-top: 1rem;
328 width: 100%;
329 }
330
331 .audio-player audio {
332 width: 100%;
333 height: 2.5rem;
334 }
335
336 .hidden {
337 display: none;
338 }
339
340 .file-input {
341 display: none;
342 }
343 `;
344
345 private eventSources: Map<string, EventSource> = new Map();
346 private handleAuthChange = async () => {
347 await this.checkHealth();
348 await this.loadJobs();
349 this.connectToJobStreams();
350 };
351
352 override async connectedCallback() {
353 super.connectedCallback();
354 await this.checkHealth();
355 await this.loadJobs();
356 this.connectToJobStreams();
357
358 // Listen for auth changes to reload jobs
359 window.addEventListener("auth-changed", this.handleAuthChange);
360 }
361
362 override disconnectedCallback() {
363 super.disconnectedCallback();
364 // Clean up all event sources and word streamers
365 for (const es of this.eventSources.values()) {
366 es.close();
367 }
368 this.eventSources.clear();
369
370 for (const streamer of this.wordStreamers.values()) {
371 streamer.clear();
372 }
373 this.wordStreamers.clear();
374 this.displayedTranscripts.clear();
375 this.lastTranscripts.clear();
376
377 window.removeEventListener("auth-changed", this.handleAuthChange);
378 }
379
380 private connectToJobStreams() {
381 // Connect to SSE streams for active jobs
382 for (const job of this.jobs) {
383 if (
384 job.status === "processing" ||
385 job.status === "transcribing" ||
386 job.status === "uploading"
387 ) {
388 this.connectToJobStream(job.id);
389 }
390 }
391 }
392
393 private connectToJobStream(jobId: string, retryCount = 0) {
394 if (this.eventSources.has(jobId)) {
395 return; // Already connected
396 }
397
398 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
399
400 // Handle named "update" events from SSE stream
401 eventSource.addEventListener("update", async (event) => {
402 const update = JSON.parse(event.data);
403
404 // Update the job in our list efficiently (mutate in place for Lit)
405 const job = this.jobs.find((j) => j.id === jobId);
406 if (job) {
407 // Update properties directly
408 if (update.status !== undefined) job.status = update.status;
409 if (update.progress !== undefined) job.progress = update.progress;
410 if (update.transcript !== undefined) {
411 job.transcript = update.transcript;
412
413 // Get or create word streamer for this job
414 if (!this.wordStreamers.has(jobId)) {
415 const streamer = new WordStreamer(50, (word) => {
416 const current = this.displayedTranscripts.get(jobId) || "";
417 this.displayedTranscripts.set(jobId, current + word);
418 this.requestUpdate();
419 });
420 this.wordStreamers.set(jobId, streamer);
421 }
422
423 const streamer = this.wordStreamers.get(jobId)!;
424 const lastTranscript = this.lastTranscripts.get(jobId) || "";
425 const newTranscript = update.transcript;
426
427 // Check if this is new content we haven't seen
428 if (newTranscript !== lastTranscript) {
429 // If new transcript starts with last transcript, it's cumulative - add diff
430 if (newTranscript.startsWith(lastTranscript)) {
431 const newPortion = newTranscript.slice(lastTranscript.length);
432 if (newPortion.trim()) {
433 streamer.addChunk(newPortion);
434 }
435 } else {
436 // Completely different segment, add space separator then new content
437 if (lastTranscript) {
438 streamer.addChunk(" ");
439 }
440 streamer.addChunk(newTranscript);
441 }
442 this.lastTranscripts.set(jobId, newTranscript);
443 }
444
445 // On completion, show everything immediately
446 if (update.status === "completed") {
447 streamer.showAll();
448 this.wordStreamers.delete(jobId);
449 this.lastTranscripts.delete(jobId);
450 }
451 }
452
453 // Trigger Lit re-render by creating new array reference
454 this.jobs = [...this.jobs];
455
456 // Close connection if job is complete or failed
457 if (update.status === "completed" || update.status === "failed") {
458 eventSource.close();
459 this.eventSources.delete(jobId);
460
461 // Clean up streamer
462 const streamer = this.wordStreamers.get(jobId);
463 if (streamer) {
464 streamer.clear();
465 this.wordStreamers.delete(jobId);
466 }
467 this.lastTranscripts.delete(jobId);
468
469 // Load VTT for completed jobs
470 if (update.status === "completed") {
471 await this.loadVTTForJob(jobId);
472 this.setupWordHighlighting(jobId);
473 }
474 }
475 }
476 });
477
478 eventSource.onerror = (error) => {
479 console.warn(`SSE connection error for job ${jobId}:`, error);
480 eventSource.close();
481 this.eventSources.delete(jobId);
482
483 // Check if the job still exists before retrying
484 const job = this.jobs.find((j) => j.id === jobId);
485 if (!job) {
486 console.log(`Job ${jobId} no longer exists, skipping retry`);
487 return;
488 }
489
490 // Don't retry if job is already in a terminal state
491 if (job.status === "completed" || job.status === "failed") {
492 console.log(`Job ${jobId} is ${job.status}, skipping retry`);
493 return;
494 }
495
496 // Retry connection up to 3 times with exponential backoff
497 if (retryCount < 3) {
498 const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s
499 console.log(
500 `Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`,
501 );
502 setTimeout(() => {
503 this.connectToJobStream(jobId, retryCount + 1);
504 }, backoff);
505 } else {
506 console.error(`Failed to connect to job ${jobId} after 3 attempts`);
507 }
508 };
509
510 this.eventSources.set(jobId, eventSource);
511 }
512
513 async checkHealth() {
514 try {
515 const response = await fetch("/api/transcriptions/health");
516 if (response.ok) {
517 const data = await response.json();
518 this.serviceAvailable = data.available;
519 } else {
520 this.serviceAvailable = false;
521 }
522 } catch {
523 this.serviceAvailable = false;
524 }
525 }
526
527 async loadJobs() {
528 try {
529 const response = await fetch("/api/transcriptions");
530 if (response.ok) {
531 const data = await response.json();
532 this.jobs = data.jobs;
533
534 // Initialize displayedTranscripts for completed/failed jobs
535 for (const job of this.jobs) {
536 if ((job.status === "completed" || job.status === "failed") && job.transcript) {
537 this.displayedTranscripts.set(job.id, job.transcript);
538 }
539
540 // Fetch VTT for completed jobs
541 if (job.status === "completed") {
542 await this.loadVTTForJob(job.id);
543 await this.updateComplete;
544 this.setupWordHighlighting(job.id);
545 }
546 }
547 // Don't override serviceAvailable - it's set by checkHealth()
548 } else if (response.status === 404) {
549 // Transcription service not available - show empty state
550 this.jobs = [];
551 } else {
552 console.error("Failed to load jobs:", response.status);
553 }
554 } catch (error) {
555 // Network error or service unavailable - don't break the page
556 console.warn("Transcription service unavailable:", error);
557 this.jobs = [];
558 }
559 }
560
561 private async loadVTTForJob(jobId: string) {
562 try {
563 const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`);
564 if (response.ok) {
565 const vttContent = await response.text();
566 const segments = parseVTT(vttContent);
567
568 // Update job with VTT content and segments
569 const job = this.jobs.find((j) => j.id === jobId);
570 if (job) {
571 job.vttContent = vttContent;
572 job.vttSegments = segments;
573 job.audioUrl = `/api/transcriptions/${jobId}/audio`;
574 this.jobs = [...this.jobs];
575 }
576 }
577 } catch (error) {
578 console.warn(`Failed to load VTT for job ${jobId}:`, error);
579 }
580 }
581
582 private setupWordHighlighting(jobId: string) {
583 const job = this.jobs.find((j) => j.id === jobId);
584 if (!job?.audioUrl || !job.vttSegments) return;
585
586 // Wait for next frame to ensure DOM is updated
587 requestAnimationFrame(() => {
588 const audioElement = this.shadowRoot?.querySelector(
589 `#audio-${jobId}`,
590 ) as HTMLAudioElement;
591 const transcriptDiv = this.shadowRoot?.querySelector(
592 `#transcript-${jobId}`,
593 ) as HTMLDivElement;
594
595 if (!audioElement || !transcriptDiv) {
596 console.warn("Could not find audio or transcript elements");
597 return;
598 }
599
600 // Track current segment
601 let currentSegmentElement: HTMLElement | null = null;
602
603 // Update highlighting on timeupdate
604 audioElement.addEventListener("timeupdate", () => {
605 const currentTime = audioElement.currentTime;
606 const segmentElements = transcriptDiv.querySelectorAll("[data-start]");
607
608 for (const el of segmentElements) {
609 const start = Number.parseFloat(
610 (el as HTMLElement).dataset.start || "0",
611 );
612 const end = Number.parseFloat((el as HTMLElement).dataset.end || "0");
613
614 if (currentTime >= start && currentTime <= end) {
615 if (currentSegmentElement !== el) {
616 currentSegmentElement?.classList.remove("current-segment");
617 (el as HTMLElement).classList.add("current-segment");
618 currentSegmentElement = el as HTMLElement;
619
620 // Auto-scroll to current segment
621 el.scrollIntoView({
622 behavior: "smooth",
623 block: "center",
624 });
625 }
626 break;
627 }
628 }
629 });
630
631 // Handle segment clicks
632 transcriptDiv.addEventListener("click", (e) => {
633 const target = e.target as HTMLElement;
634 if (target.dataset.start) {
635 const start = Number.parseFloat(target.dataset.start);
636 audioElement.currentTime = start;
637 audioElement.play();
638 }
639 });
640 });
641 }
642
643 private handleDragOver(e: DragEvent) {
644 e.preventDefault();
645 this.dragOver = true;
646 }
647
648 private handleDragLeave(e: DragEvent) {
649 e.preventDefault();
650 this.dragOver = false;
651 }
652
653 private async handleDrop(e: DragEvent) {
654 e.preventDefault();
655 this.dragOver = false;
656
657 const files = e.dataTransfer?.files;
658 const file = files?.[0];
659 if (file) {
660 await this.uploadFile(file);
661 }
662 }
663
664 private async handleFileSelect(e: Event) {
665 const input = e.target as HTMLInputElement;
666 const file = input.files?.[0];
667 if (file) {
668 await this.uploadFile(file);
669 }
670 }
671
672 private async uploadFile(file: File) {
673 const allowedTypes = [
674 "audio/mpeg", // MP3
675 "audio/wav", // WAV
676 "audio/x-wav", // WAV (alternative)
677 "audio/m4a", // M4A
678 "audio/x-m4a", // M4A (alternative)
679 "audio/mp4", // MP4 audio
680 "audio/aac", // AAC
681 "audio/ogg", // OGG
682 "audio/webm", // WebM audio
683 "audio/flac", // FLAC
684 ];
685
686 // Also check file extension for M4A files (sometimes MIME type isn't set correctly)
687 const isM4A = file.name.toLowerCase().endsWith(".m4a");
688 const isAllowedType =
689 allowedTypes.includes(file.type) || (isM4A && file.type === "");
690
691 if (!isAllowedType) {
692 alert(
693 "Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)",
694 );
695 return;
696 }
697
698 if (file.size > 100 * 1024 * 1024) {
699 // 100MB limit
700 alert("File size must be less than 100MB");
701 return;
702 }
703
704 this.isUploading = true;
705
706 try {
707 const formData = new FormData();
708 formData.append("audio", file);
709
710 const response = await fetch("/api/transcriptions", {
711 method: "POST",
712 body: formData,
713 });
714
715 if (!response.ok) {
716 const data = await response.json();
717 alert(
718 data.error ||
719 "Upload failed - transcription service may be unavailable",
720 );
721 } else {
722 const result = await response.json();
723 await this.loadJobs();
724 // Connect to SSE stream for this new job
725 this.connectToJobStream(result.id);
726 }
727 } catch {
728 alert("Upload failed - transcription service may be unavailable");
729 } finally {
730 this.isUploading = false;
731 }
732 }
733
734 private getStatusClass(status: string) {
735 return `status-${status}`;
736 }
737
738 private renderTranscript(job: TranscriptionJob) {
739 if (!job.vttContent) {
740 const displayed = this.displayedTranscripts.get(job.id) || "";
741 return displayed;
742 }
743
744 const segments = parseVTT(job.vttContent);
745 // Group segments by paragraph (extract paragraph number from ID like "Paragraph 1-1" -> "1")
746 const paragraphGroups = new Map<string, typeof segments>();
747 for (const segment of segments) {
748 const id = (segment.index || '').trim();
749 const match = id.match(/^Paragraph\s+(\d+)-/);
750 const paraNum = match ? match[1] : '0';
751 if (!paragraphGroups.has(paraNum)) {
752 paragraphGroups.set(paraNum, []);
753 }
754 paragraphGroups.get(paraNum)!.push(segment);
755 }
756
757 // Render each paragraph group
758 const paragraphs = Array.from(paragraphGroups.entries()).map(([paraNum, groupSegments]) => {
759 // Concatenate all text in the group
760 const fullText = groupSegments.map(s => s.text || '').join(' ');
761 // Split into sentences
762 const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean);
763 // Calculate word counts for timing
764 const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
765 const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0));
766
767 // Overall paragraph timing
768 const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0));
769 const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart));
770
771 let acc = 0;
772 const paraDuration = paraEnd - paraStart;
773
774 return html`<div class="paragraph">
775 ${sentences.map((sent, si) => {
776 const startOffset = (acc / totalWords) * paraDuration;
777 acc += wordCounts[si];
778 const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration;
779 const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart;
780 const spanStart = paraStart + startOffset;
781 const spanEnd = paraStart + endOffset;
782 return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`;
783 })}
784 </div>`;
785 });
786
787 return html`${paragraphs}`;
788 }
789
790
791
792 override render() {
793 return html`
794 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
795 @dragover=${this.serviceAvailable ? this.handleDragOver : null}
796 @dragleave=${this.serviceAvailable ? this.handleDragLeave : null}
797 @drop=${this.serviceAvailable ? this.handleDrop : null}
798 @click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}>
799 <div class="upload-icon">馃幍</div>
800 <div class="upload-text">
801 ${
802 !this.serviceAvailable
803 ? "Transcription service unavailable"
804 : this.isUploading
805 ? "Uploading..."
806 : "Drop audio file here or click to browse"
807 }
808 </div>
809 <div class="upload-hint">
810 ${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"}
811 </div>
812 <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} />
813 </div>
814
815 <div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}">
816 <h3 class="jobs-title">Your Transcriptions</h3>
817 ${this.jobs.map(
818 (job) => html`
819 <div class="job-card">
820 <div class="job-header">
821 <span class="job-filename">${job.filename}</span>
822 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span>
823 </div>
824
825 ${
826 job.status === "uploading" ||
827 job.status === "processing" ||
828 job.status === "transcribing"
829 ? html`
830 <div class="progress-bar">
831 <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div>
832 </div>
833 `
834 : ""
835 }
836
837 ${
838 job.status === "completed" && job.audioUrl && job.vttSegments
839 ? html`
840 <div class="audio-player">
841 <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
842 </div>
843 <div class="job-transcript" id="transcript-${job.id}">
844 ${this.renderTranscript(job)}
845 </div>
846 `
847 : this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
848 ? html`
849 <div class="job-transcript">${this.renderTranscript(job)}</div>
850 `
851 : ""
852 }
853 </div>
854 `,
855 )}
856 </div>
857 `;
858 }
859}