// 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.key_points.map((p) => `- ${esc(p)}
`).join("")}
` : ""}
${s.action_items && s.action_items.length ? `
Action items
${s.action_items.map((a) => `- ${esc(a)}
`).join("")}
` : ""}
` : ""}
Transcript
${(t && t.segments && t.segments.length)
? t.segments.map((g) => `
${fmtTime(g.start)}
${esc(g.text)}
`).join("")
: `
${esc(t && t.text || "")}
`}
`;
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();
})();