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