···
+
vttSegments?: VTTSegment[];
+
function parseVTT(vttContent: string): VTTSegment[] {
+
const segments: VTTSegment[] = [];
+
const lines = vttContent.split("\n");
+
while (i < lines.length && !lines[i]?.includes("-->")) {
+
while (i < lines.length) {
+
if (line?.includes("-->")) {
+
const [startStr, endStr] = line.split("-->").map((s) => s.trim());
+
const start = parseVTTTimestamp(startStr || "");
+
const end = parseVTTTimestamp(endStr || "");
+
// Collect text lines until empty line
+
const textLines: string[] = [];
+
while (i < lines.length && lines[i]?.trim()) {
+
textLines.push(lines[i] || "");
+
text: textLines.join(" ").trim(),
+
function parseVTTTimestamp(timestamp: string): number {
+
const parts = timestamp.split(":");
+
if (parts.length === 3) {
+
const hours = Number.parseFloat(parts[0] || "0");
+
const minutes = Number.parseFloat(parts[1] || "0");
+
const seconds = Number.parseFloat(parts[2] || "0");
+
return hours * 3600 + minutes * 60 + seconds;
···
+
transition: background 0.1s;
+
background: color-mix(in srgb, var(--primary) 15%, transparent);
+
background: color-mix(in srgb, var(--accent) 30%, transparent);
···
const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
// Handle named "update" events from SSE stream
+
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);
+
// Load VTT for completed jobs
+
if (update.status === "completed") {
+
await this.loadVTTForJob(jobId);
+
this.setupWordHighlighting(jobId);
···
if ((job.status === "completed" || job.status === "failed") && job.transcript) {
this.displayedTranscripts.set(job.id, job.transcript);
+
// Fetch VTT for completed jobs
+
if (job.status === "completed") {
+
await this.loadVTTForJob(job.id);
+
await this.updateComplete;
+
this.setupWordHighlighting(job.id);
// Don't override serviceAvailable - it's set by checkHealth()
} else if (response.status === 404) {
···
+
private async loadVTTForJob(jobId: string) {
+
const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`);
+
const vttContent = await response.text();
+
const segments = parseVTT(vttContent);
+
// Update job with VTT segments
+
const job = this.jobs.find((j) => j.id === jobId);
+
job.vttSegments = segments;
+
job.audioUrl = `/api/transcriptions/${jobId}/audio`;
+
this.jobs = [...this.jobs];
+
console.warn(`Failed to load VTT for job ${jobId}:`, error);
+
private setupWordHighlighting(jobId: string) {
+
const job = this.jobs.find((j) => j.id === jobId);
+
if (!job?.audioUrl || !job.vttSegments) return;
+
// Wait for next frame to ensure DOM is updated
+
requestAnimationFrame(() => {
+
const audioElement = this.shadowRoot?.querySelector(
+
const transcriptDiv = this.shadowRoot?.querySelector(
+
`#transcript-${jobId}`,
+
if (!audioElement || !transcriptDiv) {
+
console.warn("Could not find audio or transcript elements");
+
// Track current segment
+
let currentSegmentElement: HTMLElement | null = null;
+
// Update highlighting on timeupdate
+
audioElement.addEventListener("timeupdate", () => {
+
const currentTime = audioElement.currentTime;
+
const segmentElements = transcriptDiv.querySelectorAll("[data-start]");
+
for (const el of segmentElements) {
+
const start = Number.parseFloat(
+
(el as HTMLElement).dataset.start || "0",
+
const end = Number.parseFloat((el as HTMLElement).dataset.end || "0");
+
if (currentTime >= start && currentTime <= end) {
+
if (currentSegmentElement !== el) {
+
currentSegmentElement?.classList.remove("current-segment");
+
(el as HTMLElement).classList.add("current-segment");
+
currentSegmentElement = el as HTMLElement;
+
// Auto-scroll to current segment
+
// Handle segment clicks
+
transcriptDiv.addEventListener("click", (e) => {
+
const target = e.target as HTMLElement;
+
if (target.dataset.start) {
+
const start = Number.parseFloat(target.dataset.start);
+
audioElement.currentTime = start;
private handleDragOver(e: DragEvent) {
···
private renderTranscript(job: TranscriptionJob) {
+
if (!job.vttSegments) {
+
const displayed = this.displayedTranscripts.get(job.id) || "";
+
const segments = job.vttSegments;
+
// Render segments as clickable spans
+
return html`${segments.map(
+
(segment, idx) => html`<span
+
data-start="${segment.start}"
+
data-end="${segment.end}"
+
>${segment.text}</span>${idx < segments.length - 1 ? " " : ""}`,
···
+
job.status === "completed" && job.audioUrl && job.vttSegments
+
<div class="audio-player">
+
<audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
+
<div class="job-transcript" id="transcript-${job.id}">
+
${this.renderTranscript(job)}
+
: this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
<div class="job-transcript">${this.renderTranscript(job)}</div>