// SPDX-License-Identifier: GPL-3.0-only - Nightjar web UI logic (vanilla JS) (function () { const $ = (s) => document.querySelector(s); const listEl = $("#list"), detailEl = $("#detail"); const recBtn = $("#recBtn"), recTimer = $("#recTimer"), uploadEl = $("#upload"); let selected = null, poller = null; let mediaRecorder = null, chunks = [], recStart = 0, recInterval = null; const PROC = ["queued", "transcribing", "summarising"]; const fmtTime = (s) => { s = Math.floor(s || 0); const m = Math.floor(s / 60); return `${m}:${String(s % 60).padStart(2, "0")}`; }; const when = (t) => new Date((t || 0) * 1000).toLocaleString(); const esc = (x) => (x || "").replace(/&/g, "&").replace(//g, ">"); async function j(url, opts) { const r = await fetch(url, opts); if (!r.ok && r.status !== 204) throw new Error(await r.text()); return r.status === 204 ? null : r.json(); } function badge(status) { if (status === "done") return 'done'; if (status === "error") return 'error'; return `${status}`; } async function refreshList() { const items = await j("/api/recordings"); listEl.innerHTML = items.map((r) => `
  • ${esc(r.title)}
    ${badge(r.status)} ${when(r.created_at)}
  • `).join("") || '
  • No recordings yet.
  • '; listEl.querySelectorAll(".item").forEach((li) => li.onclick = () => open(li.dataset.id)); // keep polling if anything is still processing if (items.some((r) => PROC.includes(r.status)) && !poller) poller = setInterval(tick, 2500); if (!items.some((r) => PROC.includes(r.status)) && poller) { clearInterval(poller); poller = null; } } async function tick() { await refreshList(); if (selected) await open(selected, true); } async function open(id, quiet) { selected = id; if (!quiet) listEl.querySelectorAll(".item").forEach((li) => li.classList.toggle("sel", li.dataset.id === id)); const r = await j("/api/recordings/" + id); const s = r.summary, t = r.transcript; let mid; if (PROC.includes(r.status)) mid = `
     ${r.status}… this can take a few seconds.
    `; else if (r.status === "error") mid = `

    Processing failed

    ${esc(r.error)}

    Check the server's AI provider config (see .env).

    `; else mid = ` ${s ? `

    Summary

    ${esc(s.overview)}

    ${s.key_points && s.key_points.length ? `

    Key points

    ` : ""} ${s.action_items && s.action_items.length ? `

    Action items

    ` : ""}
    ` : ""}

    Transcript

    ${(t && t.segments && t.segments.length) ? t.segments.map((g) => `
    ${fmtTime(g.start)}
    ${esc(g.text)}
    `).join("") : `
    ${esc(t && t.text || "")}
    `}
    Export: ${["txt", "md", "srt", "vtt", "json"].map((f) => `${f.toUpperCase()}`).join("")}
    `; detailEl.innerHTML = `

    ${esc(r.title)}

    ${badge(r.status)} ${when(r.created_at)}
    ${mid}`; $("#delBtn").onclick = async () => { if (confirm("Delete this recording?")) { await j("/api/recordings/" + id, { method: "DELETE" }); selected = null; detailEl.innerHTML = ""; refreshList(); } }; } async function upload(blob, name) { const fd = new FormData(); fd.append("file", blob, name); const r = await j("/api/recordings?title=" + encodeURIComponent(name), { method: "POST", body: fd }); await refreshList(); open(r.id); if (!poller) poller = setInterval(tick, 2500); } uploadEl.onchange = () => { const f = uploadEl.files[0]; if (f) upload(f, f.name); uploadEl.value = ""; }; recBtn.onclick = async () => { if (mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); return; } let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { alert("Microphone access denied or unavailable."); return; } chunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data); mediaRecorder.onstop = () => { stream.getTracks().forEach((t) => t.stop()); clearInterval(recInterval); recTimer.hidden = true; recBtn.classList.remove("recording"); recBtn.textContent = "● Record"; const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" }); const name = "Recording " + new Date().toLocaleString(); upload(blob, name.replace(/[/:]/g, "-") + ".webm"); }; mediaRecorder.start(); recStart = Date.now(); recTimer.hidden = false; recInterval = setInterval(() => recTimer.textContent = fmtTime((Date.now() - recStart) / 1000) + " · recording…", 250); recBtn.classList.add("recording"); recBtn.textContent = "■ Stop"; }; async function boot() { try { const h = await j("/health"); $("#providers").textContent = `stt: ${h.transcriber} · llm: ${h.summariser}`; } catch (e) {} refreshList(); } boot(); })();