···
13
+
class WordStreamer {
14
+
private queue: string[] = [];
15
+
private isProcessing = false;
16
+
private wordDelay: number;
17
+
private onWord: (word: string) => void;
19
+
constructor(wordDelay: number = 50, onWord: (word: string) => void) {
20
+
this.wordDelay = wordDelay;
21
+
this.onWord = onWord;
24
+
addChunk(text: string) {
25
+
// Split on whitespace and filter out empty strings
26
+
const words = text.split(/(\s+)/).filter((w) => w.length > 0);
27
+
this.queue.push(...words);
29
+
// Start processing if not already running
30
+
if (!this.isProcessing) {
31
+
this.processQueue();
35
+
private async processQueue() {
36
+
this.isProcessing = true;
38
+
while (this.queue.length > 0) {
39
+
const word = this.queue.shift()!;
41
+
await new Promise((resolve) => setTimeout(resolve, this.wordDelay));
44
+
this.isProcessing = false;
48
+
// Drain entire queue immediately
49
+
while (this.queue.length > 0) {
50
+
const word = this.queue.shift()!;
53
+
this.isProcessing = false;
58
+
this.isProcessing = false;
@customElement("transcription-component")
export class TranscriptionComponent extends LitElement {
@state() jobs: TranscriptionJob[] = [];
@state() isUploading = false;
@state() dragOver = false;
@state() serviceAvailable = true;
68
+
// Word streamers for each job
69
+
private wordStreamers = new Map<string, WordStreamer>();
70
+
// Displayed transcripts
71
+
private displayedTranscripts = new Map<string, string>();
72
+
// Track last full transcript to compare
73
+
private lastTranscripts = new Map<string, string>();
static override styles = css`
···
172
-
white-space: pre-wrap;
231
+
word-wrap: break-word;
···
override disconnectedCallback() {
super.disconnectedCallback();
206
-
// Clean up all event sources
262
+
// Clean up all event sources and word streamers
for (const es of this.eventSources.values()) {
this.eventSources.clear();
268
+
for (const streamer of this.wordStreamers.values()) {
271
+
this.wordStreamers.clear();
272
+
this.displayedTranscripts.clear();
273
+
this.lastTranscripts.clear();
window.removeEventListener("auth-changed", this.handleAuthChange);
···
// Update properties directly
if (update.status !== undefined) job.status = update.status;
if (update.progress !== undefined) job.progress = update.progress;
244
-
if (update.transcript !== undefined) job.transcript = update.transcript;
308
+
if (update.transcript !== undefined) {
309
+
job.transcript = update.transcript;
311
+
// Get or create word streamer for this job
312
+
if (!this.wordStreamers.has(jobId)) {
313
+
const streamer = new WordStreamer(50, (word) => {
314
+
const current = this.displayedTranscripts.get(jobId) || "";
315
+
this.displayedTranscripts.set(jobId, current + word);
316
+
this.requestUpdate();
318
+
this.wordStreamers.set(jobId, streamer);
321
+
const streamer = this.wordStreamers.get(jobId)!;
322
+
const lastTranscript = this.lastTranscripts.get(jobId) || "";
323
+
const newTranscript = update.transcript;
325
+
// Check if this is new content we haven't seen
326
+
if (newTranscript !== lastTranscript) {
327
+
// If new transcript starts with last transcript, it's cumulative - add diff
328
+
if (newTranscript.startsWith(lastTranscript)) {
329
+
const newPortion = newTranscript.slice(lastTranscript.length);
330
+
if (newPortion.trim()) {
331
+
streamer.addChunk(newPortion);
334
+
// Completely different segment, add space separator then new content
335
+
if (lastTranscript) {
336
+
streamer.addChunk(" ");
338
+
streamer.addChunk(newTranscript);
340
+
this.lastTranscripts.set(jobId, newTranscript);
343
+
// On completion, show everything immediately
344
+
if (update.status === "completed") {
345
+
streamer.showAll();
346
+
this.wordStreamers.delete(jobId);
347
+
this.lastTranscripts.delete(jobId);
// Trigger Lit re-render by creating new array reference
this.jobs = [...this.jobs];
···
if (update.status === "completed" || update.status === "failed") {
this.eventSources.delete(jobId);
359
+
// Clean up streamer
360
+
const streamer = this.wordStreamers.get(jobId);
363
+
this.wordStreamers.delete(jobId);
365
+
this.lastTranscripts.delete(jobId);
···
return `status-${status}`;
533
+
private renderTranscript(job: TranscriptionJob) {
534
+
const displayed = this.displayedTranscripts.get(job.id) || "";
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
···
584
+
this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
468
-
<div class="job-transcript">${job.transcript}</div>
586
+
<div class="job-transcript">${this.renderTranscript(job)}</div>