1import { completeSnapshot, downloadSnapshot, textExport } from "./exports.mjs"; 2import { openWorkspace } from "./workspace.mjs"; 3import { openTranslation } from "./translation.mjs"; 4import { openAssistant } from "./assistant.mjs"; 5import { BrowserRecording } from "./recording.mjs"; 6import { openModelSettings } from "./settings.mjs"; 7import { openMicrophoneCheck } from "./microphone.mjs"; 8import { MIC_TEXTS, loadMicPreference } from "./mic.mjs"; 9const $ = (id) => document.getElementById(id); 10const publicToken = location.pathname.match( 11 /^\/v2\/public\/([A-Za-z0-9_-]{43})$/, 12)?.[1]; 13const state = { 14 me: null, 15 // How this server signs people in (GET /api/auth/config): the Community edition shows its 16 // email and password form; the hosted edition hands the browser to Oddproof. 17 auth: { identity: "local", login_url: null, account_url: null, portal_url: null }, 18 authLoaded: false, 19 cap: null, 20 selected: null, 21 items: [], 22 doc: null, 23 request: 0, 24 authGeneration: 0, 25 libraryRequest: 0, 26 libraryMore: false, 27 libraryQuery: "", 28}; 29// Server codes the person can act on. Any other code is shown with its underscores as spaces. 30const MESSAGES = { 31 insufficient_credit: "No credit left. Add credit at Oddproof, then try again.", 32 capture_budget_exceeded: 33 "Recording stopped at the paid boundary; the rest is saved on this device. Add credit at Oddproof, then use Recover uploads.", 34 seat_required: 35 "Your team seat is not active; ask your team's owner in the Oddproof portal.", 36 workspace_not_ready: 37 "Your workspace is not ready. Reload the app to settle it first.", 38 platform_recovering: 39 "Oddproof is restoring, content is briefly unavailable. Try again in a moment.", 40 access_revoked: "Your sign-in ended. Sign in again.", 41 ...MIC_TEXTS, 42 web_capture_unavailable: 43 "Recording in the browser is not enabled on this server.", 44 meeting_audio_not_selected: "Share a tab with audio to include the meeting.", 45 recording_account_changed: 46 "Your account changed. Reload the app before recording.", 47 storage_unavailable: 48 "This browser cannot store audio locally. Check private-mode and storage settings.", 49 request_failed: "Request could not be completed.", 50}; 51// Codes the sign-in callback appends as ?login_error= when an Oddproof sign-in does not finish. 52const LOGIN_ERRORS = { 53 access_denied: "Sign-in was cancelled.", 54 email_unverified: 55 "Verify the email address of your Oddproof account, then sign in again.", 56 login_expired: "That sign-in link has expired. Try again.", 57 login_binding_invalid: "Finish signing in in the browser where you started.", 58 identity_unavailable: "Oddproof could not be reached. Try again in a moment.", 59}; 60const PHASES = { 61 pending: "Audio saved · Transcription pending", 62 recording: "Recording", 63 complete: "Transcription complete", 64 cancelled: "Processing cancelled", 65 pending_credit: 66 "Waiting for credit · transcription starts once your workspace has credit at Oddproof", 67 needs_attention: "Transcription needs attention", 68 audio_expired: "Server audio expired · recover from your saved device copy", 69}; 70const describe = (error) => 71 MESSAGES[error.message] || 72 String(error.message || "request_failed").replaceAll("_", " "); 73const phaseLabel = (phase) => 74 ({ pending_credit: "waiting for credit", needs_attention: "needs attention" })[ 75 phase 76 ] || String(phase).replaceAll("_", " "); 77const node = (tag, text, cls) => { 78 const n = document.createElement(tag); 79 if (text !== undefined) n.textContent = text; 80 if (cls) n.className = cls; 81 return n; 82}; 83const link = (label, href, cls) => { 84 const a = node("a", label, cls); 85 a.href = href; 86 a.target = "_blank"; 87 a.rel = "noopener"; 88 return a; 89}; 90const portalUrl = () => state.me?.portal_url || state.auth.portal_url || null; 91function notify(text) { 92 $("notice").textContent = text; 93 $("notice").hidden = false; 94 clearTimeout(notify.timer); 95 notify.timer = setTimeout(() => ($("notice").hidden = true), 6000); 96} 97async function api(path, options = {}) { 98 const response = await fetch(path, { 99 credentials: "same-origin", 100 signal: AbortSignal.timeout(30000), 101 ...options, 102 headers: { 103 ...(options.body ? { "Content-Type": "application/json" } : {}), 104 ...options.headers, 105 }, 106 }); 107 const data = await response.json().catch(() => ({})); 108 if (!response.ok) { 109 // app/v2 answers {"detail": {"code", "retryable", ...}}; the shared sign-in and workspace 110 // layer answers a plain string such as "workspace_not_ready". 111 const detail = data.detail; 112 const error = new Error( 113 (typeof detail === "string" ? detail : detail?.code) || "request_failed", 114 ); 115 error.status = response.status; 116 error.detail = detail && typeof detail === "object" ? detail : {}; 117 throw error; 118 } 119 return data; 120} 121function modal(title, body) { 122 $("dialogTitle").textContent = title; 123 $("dialogBody").replaceChildren(body); 124 $("dialog").showModal(); 125} 126let recording = null; 127let assistant = null; 128let assistantKind = null; 129function closeAssistant() { 130 assistant?.close(); 131 assistant = null; 132 assistantKind = null; 133 $("assistantHost").hidden = true; 134 $("shell").classList.remove("with-assistant"); 135 $("library").inert = false;
136 document.querySelector("main").inert = false; 137} 138function showAssistant(kind) { 139 if (assistantKind === kind) { 140 closeAssistant(); 141 return; 142 } 143 closeAssistant(); 144 if (!state.selected || !state.me?.features?.[kind]) return; 145 $("assistantHost").hidden = false; 146 $("shell").classList.add("with-assistant"); 147 syncAssistantFocus(); 148 assistantKind = kind; 149 assistant = openAssistant({ 150 kind, 151 recordingId: state.selected, 152 host: $("assistantHost"), 153 api, 154 identity: { ...state.me }, 155 canEdit: !!state.doc?.can_edit && !state.me.read_only, 156 onError: notify, 157 }); 158 $("assistantHost").querySelector("button")?.focus(); 159} 160const focusedAssistant = matchMedia("(max-width: 1200px)"); 161function syncAssistantFocus() { 162 const focused = !$("assistantHost").hidden && focusedAssistant.matches; 163 $("library").inert = focused; 164 document.querySelector("main").inert = focused; 165} 166focusedAssistant.addEventListener("change", syncAssistantFocus); 167document.addEventListener("keydown", (event) => { 168 if ( 169 event.key === "Escape" && 170 !$("assistantHost").hidden && 171 !$("dialog").open 172 ) { 173 closeAssistant(); 174 $("openChat").hidden ? $("openActions").focus() : $("openChat").focus(); 175 } 176}); 177$("openChat").onclick = () => showAssistant("chat"); 178$("openActions").onclick = () => showAssistant("actions"); 179// The panel's own Close button removes it; restore the reader layout and focus. 180new MutationObserver(() => { 181 if (!$("assistantHost").children.length && !$("assistantHost").hidden) { 182 closeAssistant(); 183 $("openChat").hidden ? $("openActions").focus() : $("openChat").focus(); 184 } 185}).observe($("assistantHost"), { childList: true }); 186function banner(message, { portal = false, retry = null } = {}) { 187 $("captureBanner").hidden = !message; 188 $("captureMessage").textContent = message || ""; 189 const url = portalUrl(); 190 $("capturePortal").hidden = !portal || !url; 191 if (url) $("capturePortal").href = url; 192 $("captureRetry").hidden = !retry; 193 $("captureRetry").onclick = retry; 194} 195function captureState(phase, message) { 196 banner(message, { 197 portal: phase === "credit", 198 retry: phase === "credit" ? retryCredit : null, 199 }); 200 $("newCapture").textContent = recording?.active ? "Stop recording" : "Record"; 201 $("pauseCapture").hidden = !["recording", "paused"].includes(phase); 202 $("pauseCapture").textContent = phase === "paused" ? "Resume" : "Pause"; 203 $("newCapture").disabled = ["permission", "saving"].includes(phase); 204 $("recoverCapture").hidden = 205 !recording || !["pending", "attention", "credit"].includes(phase); 206} 207// A capture that did not start or finish: 402 insufficient_credit left the recording waiting 208// for credit at the platform (phase pending_credit) and "Try again" resumes that very recording; 209// everything else is a fact about this device or this sign-in. 210function captureFailed(error) { 211 if (error.status === 401 || error.message === "access_revoked") 212 return signIn(); 213 if (error.message === "insufficient_credit" && recording?.pendingCredit) 214 return captureState("credit", MESSAGES.insufficient_credit); 215 captureState("attention", describe(error)); 216} 217async function retryCredit() { 218 const pending = recording?.pendingCredit; 219 if (!pending || recording.active) return; 220 $("captureRetry").disabled = true; 221 try { 222 await recording.start({ 223 meeting: pending.meeting, 224 resume: pending.recordingId, 225 }); 226 } catch (error) { 227 captureFailed(error); 228 } finally { 229 $("captureRetry").disabled = false; 230 } 231} 232// A same-origin path to continue to after sign-in (the extension and iOS consent pages use it). 233function loginNext() { 234 const next = new URLSearchParams(location.search).get("next") || ""; 235 return next.startsWith("/") && !next.startsWith("//") ? next : ""; 236} 237async function loadAuth() { 238 if (state.authLoaded) return state.auth; 239 try { 240 state.auth = await api("/api/auth/config"); 241 state.authLoaded = true; 242 } catch { 243 /* keep the Community default until the server answers */ 244 } 245 return state.auth; 246}
247function renderSignIn() { 248 const hosted = state.auth.identity === "oddproof"; 249 $("oddproofSignIn").hidden = !hosted; 250 $("accountHint").hidden = !hosted || !state.auth.account_url; 251 if (state.auth.account_url) $("accountLink").href = state.auth.account_url; 252 $("loginForm").hidden = hosted; 253} 254async function signIn() { 255 closeAssistant(); 256 recording?.revoke(); 257 recording = null; 258 state.request++; 259 state.authGeneration++; 260 state.me = null; 261 $("dialog").close(); 262 $("dialogBody").replaceChildren(); 263 state.selected = null; 264 state.doc = null; 265 state.items = []; 266 $("transcript").replaceChildren(); 267 $("recordings").replaceChildren(); 268 $("shell").hidden = true; 269 $("gate").hidden = true; 270 $("login").hidden = false; 271 $("password").value = ""; 272 const code = new URLSearchParams(location.search).get("login_error"); 273 if (code) 274 $("loginError").textContent = 275 LOGIN_ERRORS[code] || `Sign-in did not complete (${code}). Try again.`; 276 const generation = state.authGeneration; 277 renderSignIn(); 278 await loadAuth(); 279 if (generation === state.authGeneration && !state.me) renderSignIn(); 280} 281$("oddproofSignIn").onclick = () => { 282 const next = loginNext() || "/"; 283 location.href = `${state.auth.login_url || "/auth/login"}?next=${encodeURIComponent(next)}`; 284}; 285async function logout() { 286 await api("/api/auth/logout", { method: "POST" }).catch(() => {}); 287 location.replace("/"); 288} 289function fail(error) { 290 if (error.status === 401 || error.message === "access_revoked") 291 return signIn(); 292 if (error.message === "workspace_not_ready") 293 return recording?.active 294 ? notify(MESSAGES.workspace_not_ready) 295 : gate(); 296 if (error.message === "platform_recovering") { 297 $("connection").textContent = "Oddproof is restoringâ¦"; 298 return notify(MESSAGES.platform_recovering); 299 } 300 notify(describe(error)); 301} 302function workspaceLabel(me) { 303 const base = 304 me.workspace_kind === "team" 305 ? `${me.workspace_name || "Team"} · ${me.role || "member"}` 306 : "Personal workspace"; 307 return me.read_only ? `${base} · Read only` : base; 308} 309async function init() { 310 const generation = ++state.authGeneration; 311 if (recording && !recording.active) { 312 recording.revoke(); 313 recording = null; 314 } 315 $("gate").hidden = true; 316 let me; 317 try { 318 me = await api("/api/v2/me"); 319 } catch (error) { 320 if (generation !== state.authGeneration) return; 321 if (error.status === 401 || error.message === "access_revoked") 322 return signIn(); 323 if (error.message === "workspace_not_ready") return gate(); 324 if (error.message === "platform_recovering") return recovering(); 325 $("shell").hidden = true; 326 $("login").hidden = false; 327 renderSignIn(); 328 $("loginError").textContent = describe(error); 329 return; 330 } 331 if (generation !== state.authGeneration) return; 332 state.me = me; 333 const next = loginNext(); 334 if (next && next !== location.pathname) { 335 location.href = next; 336 return; 337 } 338 $("login").hidden = true; 339 $("loginError").textContent = ""; 340 $("shell").hidden = false; 341 $("workspace").textContent = workspaceLabel(me); 342 let cap; 343 try { 344 cap = await api("/api/v2/capabilities"); 345 } catch (error) { 346 return fail(error); 347 } 348 if (generation !== state.authGeneration) return; 349 state.cap = cap; 350 $("captureStatus").textContent = cap.transcription 351 ? "Ready for your next recording." 352 : "Transcription is not enabled on this server yet."; 353 const canRecord = 354 cap.web_capture && 355 (cap.batch_transcription || cap.live_transcription) && 356 !me.read_only; 357 if (canRecord) { 358 recording = new BrowserRecording({ 359 api, 360 live: Boolean(cap.live_transcription), 361 identity: { ...me }, 362 onState: (phase, message) => { 363 if (generation === state.authGeneration) captureState(phase, message); 364 }, 365 onSaved: async (id) => { 366 if (generation !== state.authGeneration) return; 367 await library(); 368 await select(id); 369 }, 370 }); 371 $("newCapture").hidden = false; 372 $("recoverCapture").hidden = false; 373 banner(""); 374 } else { 375 $("newCapture").hidden = true; 376 $("pauseCapture").hidden = true; 377 $("recoverCapture").hidden = true; 378 const seat = me.read_only && me.workspace_kind === "team"; 379 banner( 380 me.read_only 381 ? seat 382 ? MESSAGES.seat_required 383 : "Your workspace is read-only." 384 : "", 385 { portal: seat }, 386 ); 387 } 388 await library(); 389} 390function showGate(title, paragraphs, actions) { 391 $("shell").hidden = true; 392 $("login").hidden = true; 393 $("gate").hidden = false;
394 $("gateTitle").textContent = title; 395 $("gateBody").replaceChildren(...paragraphs.map((text) => node("p", text))); 396 $("gateError").textContent = ""; 397 $("gateActions").replaceChildren(...actions); 398} 399function gateButton(label, action, cls) { 400 const button = node("button", label, cls); 401 button.type = "button"; 402 button.onclick = async () => { 403 button.disabled = true; 404 $("gateError").textContent = ""; 405 try { 406 await action(); 407 } catch (error) { 408 $("gateError").textContent = describe(error); 409 } finally { 410 button.disabled = false; 411 } 412 }; 413 return button; 414} 415// Every account works in exactly one workspace: its team, otherwise its personal one. A 416// membership the platform cannot settle (two teams, platform unreachable) blocks the app until 417// it is settled, and recording into a team a person just joined is an explicit step when they 418// already own recordings, so nothing lands in the wrong place. 419async function gate() { 420 let ws; 421 try { 422 ws = await api("/api/workspace"); 423 } catch (error) { 424 if (error.status === 401) return signIn(); 425 if (error.message === "platform_recovering") return recovering(); 426 ws = { state: "unavailable", note: describe(error) }; 427 } 428 const portal = portalUrl(); 429 if (ws.state === "switch_required" && ws.pending) { 430 const n = ws.pending.recordings ?? 0; 431 const them = n === 1 ? "it" : "them"; 432 showGate( 433 `Your account is now part of ${ws.pending.name || "a team"}`, 434 [ 435 `Minutes keeps one workspace per account. You own ${n} recording${n === 1 ? "" : "s"} in your personal workspace. Continue to move ${them} into the team, where current and future team admins can see ${them}.`, 436 "If you would rather keep them private, export them first, then come back; or ask the team owner to invite a different email address.", 437 ], 438 [ 439 gateButton( 440 "Move my recordings and continue", 441 async () => { 442 await api("/api/workspace/switch", { 443 method: "POST", 444 body: JSON.stringify({ confirmation: "MOVE RECORDINGS" }), 445 }); 446 await init(); 447 }, 448 "primary", 449 ), 450 gateButton("Sign out", logout), 451 ], 452 ); 453 return; 454 } 455 const reasons = { 456 ambiguous: [ 457 "Your account belongs to more than one Minutes team", 458 "Minutes keeps one workspace per account. Ask the owners of the other teams to remove you, then check again.", 459 ], 460 unavailable: [ 461 "Oddproof could not be reached", 462 "Minutes needs to read your account's teams before it can show your recordings. Check again in a moment.", 463 ], 464 }; 465 const [title, text] = reasons[ws.state] || [ 466 "Your workspace is not ready", 467 `State: ${ws.state}${ws.note ? ` (${ws.note})` : ""}. Check again in a moment.`, 468 ]; 469 const actions = [ 470 gateButton( 471 "Check again", 472 async () => { 473 await api("/api/workspace/refresh", { method: "POST" }); 474 await init(); 475 }, 476 "primary", 477 ), 478 ]; 479 if (portal) actions.push(link("Open Oddproof", portal, "portal-link")); 480 actions.push(gateButton("Sign out", logout)); 481 showGate(title, [text], actions); 482} 483function recovering() { 484 showGate( 485 "Oddproof is restoring", 486 [ 487 "Content is briefly unavailable while Oddproof restores. Your recordings are safe; try again in a moment.", 488 ], 489 [gateButton("Try again", init, "primary"), gateButton("Sign out", logout)], 490 ); 491} 492async function library(append = false) { 493 const generation = state.authGeneration; 494 const request = ++state.libraryRequest; 495 const query = $("search").value.trim(); 496 const cursor = append ? state.items.at(-1)?.id : null; 497 $("libraryMore").disabled = true; 498 const params = new URLSearchParams({ limit: "50", q: query }); 499 if (cursor) params.set("before", cursor); 500 try { 501 const items = await api("/api/v2/recordings?" + params); 502 if ( 503 generation !== state.authGeneration || 504 request !== state.libraryRequest || 505 query !== $("search").value.trim() 506 ) 507 return; 508 state.items = append 509 ? [ 510 ...state.items, 511 ...items.filter( 512 (item) => !state.items.some((old) => old.id === item.id), 513 ), 514 ] 515 : items; 516 state.libraryQuery = query; 517 state.libraryMore = items.length === 50; 518 renderLibrary(); 519 } catch (error) { 520 if (generation !== state.authGeneration || request !== state.libraryRequest) 521 return; 522 if (append && error.message === "library_changed") { 523 await library(); 524 notify("Your library changed. Its latest recordings are shown."); 525 } else fail(error); 526 } finally { 527 if (request === state.libraryRequest) $("libraryMore").disabled = false;
528 } 529} 530function renderLibrary() { 531 $("recordings").replaceChildren(); 532 for (const item of state.items) { 533 const b = node("button", undefined, "recording"); 534 b.setAttribute("aria-current", String(item.id === state.selected)); 535 b.append( 536 node("strong", item.title), 537 node( 538 "small", 539 new Date(item.created_at).toLocaleDateString(undefined, { 540 month: "short", 541 day: "numeric", 542 }) + 543 " · " + 544 phaseLabel(item.phase), 545 ), 546 ); 547 b.onclick = () => select(item.id); 548 $("recordings").append(b); 549 } 550 $("libraryMore").hidden = !state.libraryMore; 551 if (!state.items.length) 552 $("recordings").append( 553 node( 554 "p", 555 state.libraryQuery ? "No matching recordings." : "No recordings yet.", 556 "muted", 557 ), 558 ); 559} 560async function select(id) { 561 if (id !== state.selected) closeAssistant(); 562 const request = ++state.request; 563 state.selected = id; 564 $("library").classList.remove("open"); 565 renderLibrary(); 566 try { 567 const doc = await api("/api/v2/recordings/" + id); 568 if (request !== state.request) return; 569 state.doc = doc; 570 renderDoc(); 571 } catch (error) { 572 if (request === state.request) fail(error); 573 } 574} 575function time(seconds) { 576 return ( 577 Math.floor(seconds / 60) 578 .toString() 579 .padStart(2, "0") + 580 ":" + 581 Math.floor(seconds % 60) 582 .toString() 583 .padStart(2, "0") 584 ); 585} 586// The recording's state is never hidden: while the transcriber is still working the phase 587// line says how far it is (parts committed of parts recorded) and what that means for the 588// reader, refreshed every few seconds until the recording is complete. 589let progressTimer = null; 590// What the server's live state means for the reader, in its words: the code the server 591// recorded is the reason, never a guess. 592function progressReason(status, code) { 593 if (status === "pending_credit" || code === "insufficient_credit") 594 return "waiting for credit at Oddproof"; 595 if (status === "needs_recovery" || status === "blocked") return "held, Retry continues it"; 596 if (code === "rate_limited") return "waiting for a free live session at Oddproof, retrying"; 597 if (code === "provider_unavailable") return "the transcription provider is unavailable, retrying"; 598 if (code) return "recovering after an interrupted session, retrying"; 599 return "text appears as it arrives; Actions run when it is complete"; 600} 601function showProgress(doc) { 602 clearTimeout(progressTimer); 603 const recordingId = state.selected, 604 generation = state.authGeneration; 605 if (publicToken || !recordingId || doc.phase === "complete") return; 606 const tick = async () => {
607 let coverage; 608 try { 609 coverage = await api(`/api/v2/recordings/${recordingId}/coverage`); 610 } catch { 611 return; 612 } 613 if (recordingId !== state.selected || generation !== state.authGeneration) return; 614 const ranges = coverage.ranges || []; 615 const done = ranges.filter((r) => r.committed).length; 616 const live = coverage.live_checkpoint || {}; 617 if (coverage.phase === "complete") { 618 $("phase").textContent = PHASES.complete || "Transcribed"; 619 return; 620 } 621 const reason = progressReason(live.status, live.error_code); 622 $("phase").textContent = 623 `Transcribing · ${done} of ${ranges.length} parts · ${reason}`; 624 progressTimer = setTimeout(tick, 5000); 625 }; 626 tick(); 627} 628function renderDoc() { 629 const doc = state.doc; 630 $("empty").hidden = true; 631 $("detail").hidden = false; 632 $("title").textContent = doc.title; 633 $("phase").textContent = PHASES[doc.phase] || phaseLabel(doc.phase); 634 showProgress(doc); 635 const portal = portalUrl(); 636 $("phasePortal").hidden = 637 doc.phase !== "pending_credit" || !portal || !!publicToken; 638 if (portal) $("phasePortal").href = portal; 639 $("retryTranscription").hidden = 640 !doc.can_edit || 641 !["pending_credit", "needs_attention", "audio_expired"].includes(doc.phase); 642 $("breadcrumb").textContent = "Your recordings"; 643 $("openChat").hidden = 644 !state.me?.features?.chat || !doc.can_assistant_read || !!publicToken; 645 $("openActions").hidden = 646 !state.me?.features?.actions || !doc.can_assistant_read || !!publicToken; 647 $("translate").hidden = !doc.can_edit || !state.me?.features?.translation; 648 $("share").hidden = !doc.can_edit; 649 $("remove").hidden = !doc.can_edit; 650 $("transcript").replaceChildren(); 651 for (const segment of doc.segments) { 652 const passage = node("section", undefined, "passage"); 653 passage.dataset.id = segment.id; 654 const content = node("div"); 655 const speaker = node("button", segment.speaker, "speaker"); 656 speaker.disabled = !doc.can_edit || !segment.speaker_binding; 657 speaker.onclick = () => rename(segment); 658 content.append(speaker); 659 const columns = node("div", undefined, "passage-columns"); 660 columns.append(node("p", segment.text)); 661 if (segment.translations?.length) { 662 const translations = node("div", undefined, "translations"); 663 for (const translated of segment.translations) { 664 const translation = node("section"); 665 translation.append( 666 node("small", translated.language), 667 node("p", translated.text), 668 ); 669 translation.lang = translated.language; 670 translation.dir = "auto"; 671 translations.append(translation); 672 } 673 columns.classList.add("translated"); 674 columns.append(translations); 675 } 676 content.append(columns); 677 passage.append(node("span", time(segment.start_seconds), "time"), content); 678 $("transcript").append(passage); 679 } 680 if (doc.live?.hypothesis?.length) { 681 const interim = node("section", undefined, "live-hypothesis"); 682 interim.append( 683 node("small", "Live draft · may change"), 684 node("p", doc.live.hypothesis.map((t) => t.text).join("")), 685 ); 686 interim.dir = "auto"; 687 $("transcript").append(interim); 688 } 689 if (!doc.segments.length && !doc.live?.hypothesis?.length) 690 $("transcript").append( 691 node( 692 "p", 693 doc.phase === "complete" 694 ? "No speech was found in this recording." 695 : "Your transcript will appear here when processing completes.", 696 "muted", 697 ), 698 ); 699 $("wordCount").textContent = doc.segments.length + " passages"; 700 $("more").hidden = doc.next_offset === null; 701} 702function rename(segment) { 703 const form = node("form"); 704 const label = node("label", "Speaker name"); 705 const input = node("input"); 706 input.value = segment.speaker; 707 input.maxLength = 80; 708 input.required = true; 709 label.append(input); 710 const save = node("button", "Save name", "primary"); 711 form.append(label, save); 712 form.onsubmit = async (e) => { 713 e.preventDefault(); 714 save.disabled = true; 715 try { 716 await api("/api/v2/recordings/" + state.selected + "/speakers", { 717 method: "PUT", 718 body: JSON.stringify({ 719 binding: segment.speaker_binding, 720 name: input.value.trim(), 721 }), 722 }); 723 $("dialog").close(); 724 await select(state.selected); 725 } catch (error) { 726 fail(error); 727 save.disabled = false;
728 } 729 }; 730 modal("Rename speaker", form); 731 input.focus(); 732 input.select(); 733} 734$("loginForm").onsubmit = async (e) => { 735 e.preventDefault(); 736 const button = e.target.querySelector("button"); 737 button.disabled = true; 738 $("loginError").textContent = ""; 739 try { 740 await api("/api/auth/login", { 741 method: "POST", 742 body: JSON.stringify({ 743 email: $("email").value.trim(), 744 password: $("password").value, 745 }), 746 }); 747 $("password").value = ""; 748 const next = loginNext(); 749 if (next) { 750 location.href = next; 751 return; 752 } 753 await init(); 754 } catch (error) { 755 $("loginError").textContent = 756 error.status === 429 757 ? "Too many sign-in attempts. Please wait before trying again." 758 : error.message === "password_sign_in_disabled" 759 ? "This server signs people in through Oddproof." 760 : "Sign-in failed. Check your email and password."; 761 } finally { 762 button.disabled = false; 763 } 764}; 765$("newCapture").onclick = async () => { 766 if (recording?.active) { 767 try { 768 await recording.stop(); 769 } catch (error) { 770 fail(error); 771 } 772 return; 773 } 774 const choices = node("div"); 775 choices.append( 776 node( 777 "p", 778 recording?.live 779 ? "Record your microphone, or include meeting audio from a shared tab. Your transcript appears as you speak." 780 : "Record your microphone, or include meeting audio from a shared tab. Transcription starts after you stop.", 781 ), 782 ); 783 const mic = loadMicPreference(); 784 choices.append( 785 node( 786 "p", 787 mic.label ? `Input: ${mic.label}` : "Input: system default microphone", 788 "muted", 789 ), 790 ); 791 for (const [meeting, label] of [ 792 [false, "Microphone"], 793 [true, "Meeting + microphone"], 794 ]) { 795 const button = node("button", label); 796 button.onclick = async () => { 797 $("dialog").close(); 798 // The first recording in this browser, or one after a dead input: the microphone check 799 // runs first and recording starts only from a confirmed input. 800 if (!loadMicPreference().verified) { 801 const confirmed = await openMicrophoneCheck({ modal, dialog: $("dialog") }); 802 if (!confirmed?.verified) return; 803 } 804 $("newCapture").disabled = true; 805 try { 806 await recording.start({ meeting }); 807 } catch (error) { 808 captureFailed(error); 809 } finally { 810 $("newCapture").disabled = false; 811 } 812 }; 813 choices.append(button); 814 } 815 modal("New recording", choices); 816}; 817$("pauseCapture").onclick = () => 818 recording?.pause($("pauseCapture").textContent === "Pause"); 819$("recoverCapture").onclick = async () => { 820 $("recoverCapture").disabled = true; 821 try { 822 const count = await recording.recover(); 823 notify(count ? "Saved recordings synchronized." : "No pending uploads."); 824 await library(); 825 } catch (error) { 826 fail(error); 827 } finally { 828 $("recoverCapture").disabled = false; 829 } 830}; 831window.addEventListener("beforeunload", (event) => { 832 if (recording?.active) { 833 event.preventDefault(); 834 event.returnValue = ""; 835 } 836}); 837let searchTimer; 838$("search").oninput = () => { 839 ++state.libraryRequest; 840 clearTimeout(searchTimer); 841 searchTimer = setTimeout(() => library(), 250); 842}; 843$("libraryMore").onclick = () => library(true); 844$("openLibrary").onclick = () => { 845 $("library").classList.add("open"); 846 $("closeLibrary").focus(); 847}; 848$("closeLibrary").onclick = () => { 849 $("library").classList.remove("open"); 850 $("openLibrary").focus(); 851}; 852$("logout").onclick = logout; 853$("more").onclick = async () => { 854 const id = state.selected; 855 try { 856 const page = await api( 857 `/api/v2/recordings/${id}?offset=${state.doc.next_offset}&watermark=${state.doc.watermark}`, 858 ); 859 if (id !== state.selected) return; 860 state.doc = { 861 ...page, 862 segments: [...state.doc.segments, ...page.segments], 863 }; 864 renderDoc(); 865 } catch (error) { 866 if (error.message === "snapshot_changed") { 867 await select(id); 868 notify("The transcript changed. Refreshed to its latest version."); 869 } else fail(error); 870 } 871}; 872$("share").onclick = () => { 873 const body = node("div"); 874 body.append( 875 node( 876 "p", 877 "Anyone with this link can read the transcript and speaker names. Creating a new link revokes the previous one.", 878 ), 879 ); 880 const create = node("button", "Create a new link", "primary"); 881 const revoke = node("button", "Revoke current link"); 882 body.append(create, revoke); 883 create.onclick = async () => { 884 try { 885 const data = await api( 886 "/api/v2/recordings/" + state.selected + "/public-link", 887 { method: "POST" }, 888 ); 889 const field = node("input"); 890 field.readOnly = true; 891 field.value = location.origin + "/v2/public/" + data.token; 892 field.setAttribute("aria-label", "Public recording link"); 893 body.replaceChildren( 894 node("p", "Link created. Copy it to share this recording."), 895 field, 896 ); 897 field.select(); 898 } catch (error) { 899 fail(error); 900 } 901 };
902 revoke.onclick = async () => { 903 try { 904 await api("/api/v2/recordings/" + state.selected + "/public-link", { 905 method: "DELETE", 906 }); 907 $("dialog").close(); 908 notify("Public link revoked."); 909 } catch (error) { 910 fail(error); 911 } 912 }; 913 modal("Share recording", body); 914}; 915$("remove").onclick = () => { 916 const id = state.selected; 917 const body = node("div"); 918 body.append( 919 node( 920 "p", 921 "Delete this recording and its transcript? Public access stops immediately. Background cleanup removes stored content.", 922 ), 923 ); 924 const confirm = node("button", "Delete recording", "danger"); 925 body.append(confirm); 926 confirm.onclick = async () => { 927 confirm.disabled = true; 928 try { 929 await api("/api/v2/recordings/" + id, { method: "DELETE" }); 930 $("dialog").close(); 931 state.request++; 932 closeAssistant(); 933 state.selected = null; 934 state.doc = null; 935 $("detail").hidden = true; 936 $("empty").hidden = false; 937 await library(); 938 notify("Recording deleted."); 939 } catch (error) { 940 fail(error); 941 confirm.disabled = false; 942 } 943 }; 944 modal("Delete recording?", body); 945}; 946$("export").onclick = () => { 947 const recordingId = state.selected, 948 generation = state.authGeneration; 949 const body = node("div"); 950 body.append( 951 node( 952 "p", 953 "Export the complete finalized transcript, speaker names and available translations. Chat and Action results are separate.", 954 ), 955 ); 956 for (const [format, label] of [ 957 ["copy", "Copy to clipboard"], 958 ["txt", "Text file"], 959 ["json", "JSON file"], 960 ]) { 961 const button = node("button", label); 962 body.append(button); 963 button.onclick = async () => { 964 button.disabled = true; 965 try { 966 const base = publicToken 967 ? `/api/v2/public/${publicToken}` 968 : `/api/v2/recordings/${recordingId}`; 969 const snapshot = await completeSnapshot((offset, watermark) => 970 api( 971 base + (offset ? `?offset=${offset}&watermark=${watermark}` : ""), 972 ), 973 ); 974 if ( 975 !publicToken && 976 (generation !== state.authGeneration || 977 recordingId !== state.selected) 978 ) 979 return; 980 if (format === "copy") {
981 await navigator.clipboard.writeText(textExport(snapshot)); 982 notify("Complete transcript copied to the clipboard."); 983 } else { 984 downloadSnapshot(snapshot, format); 985 notify("Complete transcript exported."); 986 } 987 } catch (error) { 988 fail(error); 989 } finally { 990 button.disabled = false; 991 } 992 }; 993 } 994 modal("Export recording", body); 995}; 996$("retryTranscription").onclick = async () => { 997 const id = state.selected; 998 $("retryTranscription").disabled = true; 999 try { 1000 await api(`/api/v2/recordings/${id}/retry`, { 1001 method: "POST", 1002 body: JSON.stringify({ operation_id: crypto.randomUUID() }), 1003 }); 1004 await select(id); 1005 } catch (error) { 1006 fail(error); 1007 } finally { 1008 $("retryTranscription").disabled = false; 1009 } 1010}; 1011$("translate").onclick = async () => { 1012 const id = state.selected; 1013 const generation = state.authGeneration; 1014 try { 1015 await openTranslation({ 1016 api, 1017 recordingId: id, 1018 watermark: state.doc.watermark, 1019 identity: { ...state.me }, 1020 modal: (title, body) => { 1021 if (generation === state.authGeneration && id === state.selected) 1022 modal(title, body); 1023 }, 1024 onQueued: () => 1025 notify("Translation queued. Results will appear beside the original."), 1026 }); 1027 } catch (error) { 1028 fail(error); 1029 } 1030}; 1031$("workspaceSettings").onclick = async () => { 1032 try { 1033 await openWorkspace({ 1034 api, 1035 modal, 1036 identity: { ...state.me }, 1037 auth: state.auth, 1038 }); 1039 } catch (error) { 1040 fail(error); 1041 } 1042}; 1043$("microphone").onclick = () => { 1044 openMicrophoneCheck({ modal, dialog: $("dialog") }); 1045}; 1046$("settings").onclick = async () => { 1047 try { 1048 await openModelSettings({ api, modal, identity: { ...state.me } }); 1049 } catch (error) { 1050 fail(error); 1051 } 1052}; 1053document.addEventListener("keydown", (e) => { 1054 if (!$("library").classList.contains("open")) return; 1055 if (e.key === "Escape") $("closeLibrary").click(); 1056 if (e.key === "Tab") { 1057 const focus = [...$("library").querySelectorAll("button,input,a")].filter( 1058 (n) => n.getClientRects().length, 1059 ); 1060 if (e.shiftKey && document.activeElement === focus[0]) { 1061 e.preventDefault(); 1062 focus.at(-1).focus(); 1063 } else if (!e.shiftKey && document.activeElement === focus.at(-1)) { 1064 e.preventDefault(); 1065 focus[0].focus(); 1066 } 1067 } 1068}); 1069let readerPollRunning = false, 1070 readerPollAt = 0; 1071setInterval(async () => { 1072 if ( 1073 readerPollRunning || 1074 Date.now() - readerPollAt < (recording?.active ? 900 : 4900) 1075 ) 1076 return; 1077 if (document.hidden || !state.me || !state.selected || $("dialog").open) 1078 return; 1079 const id = state.selected, 1080 generation = state.authGeneration; 1081 readerPollRunning = true; 1082 readerPollAt = Date.now(); 1083 try { 1084 const doc = await api("/api/v2/recordings/" + id); 1085 if (id !== state.selected || generation !== state.authGeneration) return; 1086 $("connection").textContent = "Up to date"; 1087 if (doc.watermark !== state.doc?.watermark) { 1088 const anchor = [...document.querySelectorAll(".passage")].find( 1089 (n) => n.getBoundingClientRect().bottom > 0, 1090 ); 1091 const top = anchor?.getBoundingClientRect().top; 1092 state.doc = doc; 1093 renderDoc(); 1094 if (anchor) { 1095 const replacement = [...document.querySelectorAll(".passage")].find( 1096 (n) => n.dataset.id === anchor.dataset.id, 1097 ); 1098 if (replacement) 1099 scrollBy(0, replacement.getBoundingClientRect().top - top); 1100 } 1101 } 1102 } catch (error) { 1103 if (generation !== state.authGeneration || id !== state.selected) return; 1104 if (error.status === 401) signIn(); 1105 else 1106 $("connection").textContent = 1107 error.message === "platform_recovering" 1108 ? "Oddproof is restoringâ¦" 1109 : "Reconnectingâ¦"; 1110 } finally { 1111 readerPollRunning = false; 1112 } 1113}, 1000); 1114if (publicToken) { 1115 document.body.classList.add("public"); 1116 $("shell").hidden = false; 1117 $("library").hidden = true; 1118 $("openLibrary").hidden = true; 1119 $("breadcrumb").textContent = "Shared recording"; 1120 try { 1121 state.doc = await api("/api/v2/public/" + publicToken); 1122 renderDoc(); 1123 $("more").onclick = async () => {
1124 try { 1125 const page = await api( 1126 `/api/v2/public/${publicToken}?offset=${state.doc.next_offset}&watermark=${state.doc.watermark}`, 1127 ); 1128 state.doc = { 1129 ...page, 1130 segments: [...state.doc.segments, ...page.segments], 1131 }; 1132 renderDoc(); 1133 } catch (error) { 1134 notify("The shared recording changed. Reload to continue."); 1135 } 1136 }; 1137 setInterval(async () => { 1138 if (document.hidden) return; 1139 try { 1140 const doc = await api("/api/v2/public/" + publicToken); 1141 if (doc.watermark !== state.doc.watermark) { 1142 state.doc = doc; 1143 renderDoc(); 1144 } 1145 } catch { 1146 $("detail").hidden = true; 1147 $("empty").hidden = false; 1148 $("empty").replaceChildren( 1149 node("h1", "This link is no longer available."), 1150 ); 1151 } 1152 }, 5000); 1153 } catch { 1154 $("empty").replaceChildren(node("h1", "This link is not available.")); 1155 } 1156} else { 1157 await loadAuth(); 1158 await init(); 1159}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.