const toggle = document.querySelector("#readerToggle");
const readerBar = document.querySelector(".reader-bar");
const modeStatus = document.querySelector("#modeStatus");
const currentStatus = document.querySelector("#currentStatus");
const runtimeStatus = document.querySelector("#runtimeStatus");
const runtimeStatusList = document.querySelector("#runtimeStatusList");
const modelStatus = document.querySelector("#modelStatus");
const readinessStatus = document.querySelector("#readinessStatus");
const imageStatus = document.querySelector("#imageStatus");
const voiceStatus = document.querySelector("#voiceStatus");
const latencyStatus = document.querySelector("#latencyStatus");
const liveNarration = document.querySelector("#liveNarration");
const readerQueueStatus = document.querySelector("#readerQueueStatus");
const readerQueueList = document.querySelector("#readerQueueList");
const audio = document.querySelector("#speechAudio");
const voiceControl = document.querySelector("#voiceControl");
const speedControl = document.querySelector("#speedControl");
const speedValue = document.querySelector("#speedValue");
const autoAdvanceControl = document.querySelector("#autoAdvanceControl");
const transcriptLog = document.querySelector("#transcriptLog");
const copyTranscriptButton = document.querySelector("#copyTranscriptButton");
const clearTranscriptButton = document.querySelector("#clearTranscriptButton");
const demoScriptStatus = document.querySelector("#demoScriptStatus");
const demoScriptList = document.querySelector("#demoScriptList");
const demoApiCheckList = document.querySelector("#demoApiCheckList");
const awardEvidenceList = document.querySelector("#awardEvidenceList");
const submissionReadinessStatus = document.querySelector("#submissionReadinessStatus");
const submissionReadinessList = document.querySelector("#submissionReadinessList");
const copyEvidenceButton = document.querySelector("#copyEvidenceButton");
const budgetStatus = document.querySelector("#budgetStatus");
const modelBudgetList = document.querySelector("#modelBudgetList");
const runtimeSetupStatus = document.querySelector("#runtimeSetupStatus");
const runtimeSetupList = document.querySelector("#runtimeSetupList");
const imageReceiptStatus = document.querySelector("#imageReceiptStatus");
const imageReceiptList = document.querySelector("#imageReceiptList");
const controls = {
prev: document.querySelector("#prevButton"),
play: document.querySelector("#playButton"),
next: document.querySelector("#nextButton"),
heading: document.querySelector("#headingButton"),
image: document.querySelector("#imageButton"),
summary: document.querySelector("#summaryButton"),
repeat: document.querySelector("#repeatButton"),
stop: document.querySelector("#stopButton"),
};
const nodes = [...document.querySelectorAll(".speakable")].map((element, index) => ({
element,
index,
type: element.dataset.readerType || "paragraph",
imageId: element.dataset.imageId || null,
get text() {
return element.innerText.replace(/\s+/g, " ").trim();
},
}));
nodes.forEach((node) => {
if (!node.element.id) {
node.element.id = `reader-node-${node.index + 1}`;
}
node.element.dataset.readerPosition = String(node.index + 1);
});
readerBar.setAttribute("aria-controls", nodes.map((node) => node.element.id).join(" "));
let enabled = false;
let currentIndex = -1;
let playing = false;
let requestSerial = 0;
let transcriptEntries = [];
let imageDescriptions = new Map();
let lastSpokenText = "";
let activeAutoAdvance = false;
let activeSpeechSerial = 0;
function updateSpeedValue() {
speedValue.textContent = `${Number(speedControl.value).toFixed(2)}x`;
}
function formatElapsed(ms) {
if (!Number.isFinite(ms)) {
return "n/a";
}
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
}
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}[char]));
}
function renderTranscript() {
transcriptLog.innerHTML = transcriptEntries
.map((entry) => `
${escapeHtml(entry.type)} (${escapeHtml(formatElapsed(entry.elapsedMs))})
${escapeHtml(entry.runtime)}
${escapeHtml(entry.position)}
${escapeHtml(entry.text)}
`)
.join("");
}
function addTranscriptEntry(entry) {
transcriptEntries = [entry, ...transcriptEntries].slice(0, 12);
renderTranscript();
}
async function copyTextToClipboard(text, successMessage, emptyMessage, unavailableMessage) {
if (!text) {
liveNarration.textContent = emptyMessage;
return false;
}
if (!navigator.clipboard) {
liveNarration.textContent = unavailableMessage;
return false;
}
await navigator.clipboard.writeText(text);
liveNarration.textContent = successMessage;
return true;
}
function readerTypeLabel(type) {
return {
heading: "Heading",
image: "Image",
paragraph: "Paragraph",
quote: "Quote",
section: "Section",
}[type] || roleLabel(type);
}
function readerItemStatus(node) {
return `${readerTypeLabel(node.type)}, item ${node.index + 1} of ${nodes.length}`;
}
function readerQueueLabel(node) {
return `${node.index + 1}. ${readerTypeLabel(node.type)}`;
}
function renderReaderQueue() {
readerQueueStatus.textContent = `${nodes.length} items`;
readerQueueList.innerHTML = nodes
.map((node) => `
${escapeHtml(readerQueueLabel(node))}
${node.index === currentIndex ? "current" : "queued"}
${escapeHtml(node.text.slice(0, 120))}
`)
.join("");
}
function initialReaderIndex() {
if (!nodes.length) {
return -1;
}
const focusedNode = nodes.find((node) => node.element.contains(document.activeElement));
if (focusedNode) {
return focusedNode.index;
}
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const visibleNodes = nodes
.map((node) => {
const rect = node.element.getBoundingClientRect();
const inView = rect.bottom > 0 && rect.top < viewportHeight;
return {
node,
score: inView ? Math.abs(rect.top - viewportHeight * 0.25) : Number.POSITIVE_INFINITY,
};
})
.filter((item) => Number.isFinite(item.score));
return visibleNodes.reduce(
(best, item) => (item.score < best.score ? item : best),
{ node: nodes[0], score: Number.POSITIVE_INFINITY },
).node.index;
}
function isControlTarget(target) {
return target instanceof Element
&& Boolean(target.closest("input, select, textarea, button, a, [contenteditable='true']"));
}
function shouldHandleReaderShortcut(event) {
if (event.key === "Escape") {
return true;
}
return !isControlTarget(event.target);
}
function currentSection() {
const activeNode = nodes[currentIndex]?.element;
return activeNode?.closest("section") || document.querySelector("#article article");
}
function currentSectionPayload() {
const section = currentSection();
const title = section.querySelector("h1, h2, h3")?.innerText.trim() || "Article introduction";
const text = [...section.querySelectorAll(".speakable")]
.map((element) => element.innerText.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join(" ");
return { title, text };
}
async function loadManifest() {
try {
const manifest = await postJson("/api/article-manifest");
const modelCount = Object.keys(manifest.models || {}).length;
modelStatus.textContent = `${modelCount} tiny models, ${manifest.bonus_targets.join(", ")}`;
const settings = manifest.reader_settings;
if (settings?.voices?.length) {
voiceControl.innerHTML = settings.voices
.map((voice) => ``)
.join("");
voiceControl.value = settings.default_voice;
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Ready";
}
if (settings?.speed) {
speedControl.min = settings.speed.min;
speedControl.max = settings.speed.max;
speedControl.step = settings.speed.step;
speedControl.value = settings.default_speed;
updateSpeedValue();
}
autoAdvanceControl.checked = Boolean(settings?.default_auto_advance);
} catch {
modelStatus.textContent = "Manifest unavailable";
}
}
async function loadAwardEvidence() {
try {
const payload = await postJson("/api/award-evidence");
awardEvidenceList.innerHTML = payload.items
.map((item) => `
${escapeHtml(item.label)}
${escapeHtml(item.status)}
${escapeHtml(item.evidence)}
`)
.join("");
} catch {
awardEvidenceList.innerHTML = `
Checklist
offline
Award evidence is unavailable.
`;
}
}
async function loadDemoScript() {
try {
const payload = await postJson("/api/demo-script");
demoScriptStatus.textContent = `${payload.estimated_minutes} min`;
demoScriptList.innerHTML = payload.actions
.map((item) => `
${escapeHtml(item.label)}
${escapeHtml(`Step ${item.step}`)}
${escapeHtml(item.action)}
${escapeHtml(item.evidence)}
`)
.join("");
demoApiCheckList.innerHTML = payload.api_checks
.map((item) => `
${escapeHtml(item.method)}
${escapeHtml(item.path)}
${escapeHtml(item.expect)}${item.sample_body ? " Sample body included." : ""}
${escapeHtml(item.curl)}
${escapeHtml(item.powershell)}
`)
.join("");
} catch {
demoScriptStatus.textContent = "Unavailable";
demoScriptList.innerHTML = `
Judge runbook
offline
The structured demo script is unavailable.
`;
demoApiCheckList.innerHTML = "";
}
}
async function loadSubmissionReadiness() {
try {
const payload = await postJson("/api/submission-readiness");
submissionReadinessStatus.textContent = payload.all_passed
? `${payload.passed_checks}/${payload.total_checks} pass`
: "Review needed";
submissionReadinessList.innerHTML = payload.checks
.map((item) => `
${escapeHtml(item.label)}
${escapeHtml(item.status)}
${escapeHtml(item.evidence)}
`)
.join("");
} catch {
submissionReadinessStatus.textContent = "Unavailable";
submissionReadinessList.innerHTML = `
Submission readiness
offline
Submission readiness evidence is unavailable.
`;
}
}
function roleLabel(role) {
return role
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
async function loadModelBudget() {
try {
const payload = await postJson("/api/model-budget");
budgetStatus.textContent = payload.all_models_within_limit
? `All <= ${payload.limit_billion}B`
: "Review needed";
modelBudgetList.innerHTML = payload.models
.map((model) => `
${escapeHtml(roleLabel(model.role))}
${escapeHtml(model.params)}
${model.within_limit ? "Tiny Titan pass" : "Review parameter budget"}
${escapeHtml(model.id)}
`)
.join("");
} catch {
budgetStatus.textContent = "Unavailable";
modelBudgetList.innerHTML = `
Model budget
offline
Tiny Titan evidence is unavailable.
`;
}
}
async function loadRuntimeSetup() {
try {
const payload = await postJson("/api/runtime-setup");
runtimeSetupStatus.textContent = `${payload.steps.length} paths`;
runtimeSetupList.innerHTML = payload.steps
.map((step) => `
${escapeHtml(step.label)}
${escapeHtml(step.runtime)}
${escapeHtml(step.command)}
${escapeHtml(step.fallback)}
`)
.join("");
} catch {
runtimeSetupStatus.textContent = "Unavailable";
runtimeSetupList.innerHTML = `
Runtime plan
offline
Setup evidence is unavailable.
`;
}
}
async function loadRuntimeStatus() {
try {
const payload = await postJson("/api/runtime-status");
const brain = payload.reader_brain;
const speech = payload.speech;
const brainLabel = brain.available ? "llama.cpp online" : "llama.cpp fallback";
const speechLabel = speech.available ? "Kokoro online" : "voice fallback";
readinessStatus.textContent = `${brainLabel}, ${speechLabel}`;
const runtimeItems = [
["Reader brain", payload.reader_brain],
["Vision", payload.vision],
["Speech", payload.speech],
["Image generation", payload.image_generation],
];
runtimeStatusList.innerHTML = runtimeItems
.map(([label, item]) => `
${escapeHtml(label)}
${escapeHtml(item.status)}
${escapeHtml(item.model)}${item.fallback ? ` | ${escapeHtml(item.fallback)}` : ""}
`)
.join("");
} catch {
readinessStatus.textContent = "Fallback ready";
runtimeStatusList.innerHTML = `
Runtime status
offline
Live runtime status is unavailable; deterministic fallbacks remain documented.
`;
}
}
async function loadImageDescriptions() {
imageStatus.textContent = "Describing";
try {
const payload = await postJson("/api/image-descriptions");
imageDescriptions = new Map(
payload.descriptions.map((description) => [description.id, description]),
);
for (const node of nodes.filter((item) => item.type === "image")) {
const description = imageDescriptions.get(node.imageId);
const image = node.element.querySelector("img");
if (description?.alt_text && image) {
image.alt = description.alt_text;
node.element.dataset.altReady = "true";
}
}
imageStatus.textContent = `${imageDescriptions.size} ready`;
imageReceiptStatus.textContent = `${payload.descriptions.length} receipts`;
imageReceiptList.innerHTML = payload.descriptions
.map((description) => `
${escapeHtml(description.id)}
${escapeHtml(description.generation_status)}
${escapeHtml(description.generation_model)} | seed ${escapeHtml(String(description.seed))}
${escapeHtml(description.prompt)}
`)
.join("");
} catch {
imageStatus.textContent = "Fallback on demand";
imageReceiptStatus.textContent = "Unavailable";
imageReceiptList.innerHTML = `
Image receipts
offline
Generated image provenance is unavailable.
`;
}
}
function setEnabled(nextValue) {
enabled = nextValue;
toggle.setAttribute("aria-pressed", String(enabled));
toggle.lastChild.textContent = enabled ? " Screen reader on" : " Screen reader off";
readerBar.hidden = !enabled;
modeStatus.textContent = enabled ? "Reader on" : "Reader off";
if (enabled) {
if (currentIndex < 0) {
setActive(initialReaderIndex());
}
announceIntro();
} else {
stopAudio();
setActive(-1);
liveNarration.textContent = "Turn on screen reader mode to begin.";
}
}
function setActive(index) {
nodes.forEach((node) => {
node.element.classList.remove("reader-active");
node.element.removeAttribute("aria-current");
node.element.removeAttribute("tabindex");
});
currentIndex = index;
const node = nodes[currentIndex];
if (!node) {
currentStatus.textContent = "No item selected";
renderReaderQueue();
return;
}
node.element.classList.add("reader-active");
node.element.setAttribute("aria-current", "true");
node.element.setAttribute("tabindex", "-1");
node.element.focus({ preventScroll: true });
node.element.scrollIntoView({ block: "center", behavior: "smooth" });
currentStatus.textContent = readerItemStatus(node);
renderReaderQueue();
}
function haltPlayback({ clearAutoAdvance = true } = {}) {
audio.pause();
audio.removeAttribute("src");
window.speechSynthesis?.cancel();
if (clearAutoAdvance) {
activeAutoAdvance = false;
}
activeSpeechSerial = 0;
playing = false;
controls.play.textContent = "Play";
}
function stopAudio() {
haltPlayback();
}
function maybeAutoAdvance(serial) {
if (!enabled || !autoAdvanceControl.checked || !activeAutoAdvance || serial !== requestSerial) {
return;
}
const nextIndex = currentIndex + 1;
if (nextIndex < nodes.length) {
window.setTimeout(() => narrate(nextIndex), 250);
} else {
liveNarration.textContent = "End of article.";
activeAutoAdvance = false;
}
}
function browserSpeak(text, serial) {
if (!("speechSynthesis" in window)) {
return false;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = Number(speedControl.value);
utterance.onend = () => {
playing = false;
controls.play.textContent = "Play";
maybeAutoAdvance(serial);
};
window.speechSynthesis.speak(utterance);
playing = true;
controls.play.textContent = "Pause";
return true;
}
async function speakNarration(text, serial, autoAdvance = false) {
lastSpokenText = text;
activeAutoAdvance = autoAdvance;
activeSpeechSerial = serial;
const speech = await postJson("/api/speak", {
text,
voice: voiceControl.value || "af_heart",
speed: Number(speedControl.value),
});
if (serial !== requestSerial) return;
if (speech.runtime === "fallback") {
const spoke = browserSpeak(text, serial);
voiceStatus.textContent = spoke ? "Browser fallback" : "Transcript only";
if (!spoke) {
liveNarration.textContent = `${text} Audio fallback is unavailable. Transcript is visible.`;
}
return speech;
}
if (speech.audio_url) {
audio.src = speech.audio_url;
await audio.play().catch(() => {
const spoke = browserSpeak(text, serial);
voiceStatus.textContent = spoke ? "Browser fallback" : "Audio ready";
liveNarration.textContent = spoke ? text : `${text} Audio is ready. Press Play to hear it.`;
});
playing = !audio.paused || window.speechSynthesis?.speaking;
controls.play.textContent = playing ? "Pause" : "Play";
if (!window.speechSynthesis?.speaking) {
voiceStatus.textContent = speech.runtime;
}
}
return speech;
}
async function postJson(url, payload) {
const options = payload === undefined ? {} : {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
};
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function narrate(index) {
if (!enabled || !nodes[index]) return;
const serial = ++requestSerial;
haltPlayback({ clearAutoAdvance: false });
setActive(index);
const node = nodes[index];
runtimeStatus.textContent = "Thinking";
controls.play.disabled = true;
try {
let sourceText = node.text;
if (node.type === "image") {
let description = imageDescriptions.get(node.imageId);
if (!description) {
description = await postJson("/api/describe-image", {
image_id: node.imageId,
caption: node.text,
});
}
sourceText = description.alt_text;
}
const result = await postJson("/api/reader-brain", {
node_type: node.type,
text: sourceText,
position: `item ${index + 1} of ${nodes.length}`,
mode: "narrate",
});
if (serial !== requestSerial) return;
runtimeStatus.textContent = result.runtime;
liveNarration.textContent = result.narration;
const speech = await speakNarration(result.narration, serial, true);
const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0);
latencyStatus.textContent = formatElapsed(elapsedMs);
addTranscriptEntry({
type: node.type,
position: readerItemStatus(node),
runtime: `${result.runtime}/${speech?.runtime || "voice"}`,
text: result.narration,
elapsedMs,
});
} catch (error) {
runtimeStatus.textContent = "Error";
liveNarration.textContent = `Narration failed: ${error.message}`;
} finally {
controls.play.disabled = false;
}
}
async function summarizeCurrentSection() {
if (!enabled) return;
const serial = ++requestSerial;
haltPlayback({ clearAutoAdvance: false });
const section = currentSectionPayload();
runtimeStatus.textContent = "Summarizing";
controls.summary.disabled = true;
try {
const result = await postJson("/api/reader-brain", {
node_type: "section",
text: section.text,
position: section.title,
mode: "summarize",
});
if (serial !== requestSerial) return;
runtimeStatus.textContent = result.runtime;
liveNarration.textContent = result.narration;
const speech = await speakNarration(result.narration, serial, false);
const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0);
latencyStatus.textContent = formatElapsed(elapsedMs);
addTranscriptEntry({
type: "summary",
position: section.title,
runtime: `${result.runtime}/${speech?.runtime || "voice"}`,
text: result.narration,
elapsedMs,
});
} catch (error) {
runtimeStatus.textContent = "Error";
liveNarration.textContent = `Summary failed: ${error.message}`;
} finally {
controls.summary.disabled = false;
}
}
function announceIntro() {
const headings = nodes.filter((node) => node.type === "heading").length;
const images = nodes.filter((node) => node.type === "image").length;
const currentItem = nodes[currentIndex] ? ` Current item: ${readerItemStatus(nodes[currentIndex]).toLowerCase()}.` : "";
liveNarration.textContent =
`Screen reader mode on. Article contains ${headings} headings, ${images} images, and ${nodes.length} readable items.${currentItem} Press N for next, H for heading, I for image, S for section summary, or Space to begin.`;
runtimeStatus.textContent = "Ready";
}
function nextByType(type) {
const start = Math.max(currentIndex + 1, 0);
const found = nodes.find((node) => node.index >= start && node.type === type);
if (found) return narrate(found.index);
const wrapped = nodes.find((node) => node.type === type);
if (wrapped) return narrate(wrapped.index);
liveNarration.textContent = `No ${readerTypeLabel(type).toLowerCase()} items in this article.`;
}
function repeatCurrentItem() {
if (currentIndex >= 0) {
narrate(currentIndex);
} else {
liveNarration.textContent = "No reader item selected.";
}
}
toggle.addEventListener("click", () => setEnabled(!enabled));
controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1, nodes.length - 1)));
controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
controls.heading.addEventListener("click", () => nextByType("heading"));
controls.image.addEventListener("click", () => nextByType("image"));
controls.summary.addEventListener("click", () => summarizeCurrentSection());
controls.repeat.addEventListener("click", () => repeatCurrentItem());
controls.stop.addEventListener("click", () => {
stopAudio();
liveNarration.textContent = "Reading stopped.";
});
nodes.forEach((node) => {
node.element.addEventListener("click", (event) => {
if (!enabled || isControlTarget(event.target)) return;
narrate(node.index);
});
});
readerQueueList.addEventListener("click", (event) => {
const button = event.target.closest(".reader-queue-play");
if (!button) return;
setEnabled(true);
narrate(Number(button.dataset.readerIndex));
});
voiceControl.addEventListener("change", () => {
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Custom voice";
});
speedControl.addEventListener("input", () => {
updateSpeedValue();
});
copyTranscriptButton.addEventListener("click", async () => {
const text = transcriptEntries
.slice()
.reverse()
.map((entry) => `[${entry.type} / ${entry.position} / ${entry.runtime}] ${entry.text}`)
.join("\n");
await copyTextToClipboard(
text,
"Transcript copied.",
"Transcript is empty.",
"Transcript is visible, but clipboard access is unavailable.",
);
});
clearTranscriptButton.addEventListener("click", () => {
transcriptEntries = [];
renderTranscript();
liveNarration.textContent = "Transcript cleared.";
});
copyEvidenceButton.addEventListener("click", async () => {
try {
const payload = await postJson("/api/evidence-bundle");
const text = JSON.stringify(payload, null, 2);
await copyTextToClipboard(
text,
"Evidence bundle copied.",
"Evidence bundle is empty.",
"Evidence bundle loaded, but clipboard access is unavailable.",
);
} catch (error) {
liveNarration.textContent = `Evidence bundle failed: ${error.message}`;
}
});
demoApiCheckList.addEventListener("click", async (event) => {
const button = event.target.closest(".demo-command-copy");
if (!button) return;
const command = button.dataset.command || "";
await copyTextToClipboard(
command,
"Demo command copied.",
"Demo command is empty.",
"Command is visible, but clipboard access is unavailable.",
);
});
runtimeSetupList.addEventListener("click", async (event) => {
const button = event.target.closest(".runtime-command-copy");
if (!button) return;
const command = button.dataset.command || "";
await copyTextToClipboard(
command,
"Runtime setup command copied.",
"Runtime setup command is empty.",
"Runtime setup command is visible, but clipboard access is unavailable.",
);
});
controls.play.addEventListener("click", () => {
if (!enabled) return;
if (currentIndex < 0) {
narrate(0);
return;
}
if (playing) {
audio.pause();
window.speechSynthesis?.pause();
playing = false;
controls.play.textContent = "Play";
} else if (window.speechSynthesis?.paused) {
window.speechSynthesis.resume();
playing = true;
controls.play.textContent = "Pause";
} else if (audio.src) {
audio.play();
playing = true;
controls.play.textContent = "Pause";
} else if (lastSpokenText) {
activeAutoAdvance = false;
browserSpeak(lastSpokenText, requestSerial);
} else {
narrate(currentIndex);
}
});
loadManifest();
loadDemoScript();
loadAwardEvidence();
loadSubmissionReadiness();
loadModelBudget();
loadRuntimeSetup();
loadRuntimeStatus();
loadImageDescriptions();
updateSpeedValue();
renderReaderQueue();
audio.addEventListener("ended", () => {
playing = false;
controls.play.textContent = "Play";
maybeAutoAdvance(activeSpeechSerial);
});
document.addEventListener("keydown", (event) => {
if (!enabled) return;
if (!shouldHandleReaderShortcut(event)) return;
const key = event.key.toLowerCase();
if ([" ", "n", "p", "h", "i", "s", "r", "escape"].includes(key)) {
event.preventDefault();
}
if (key === " ") controls.play.click();
if (key === "n") controls.next.click();
if (key === "p") controls.prev.click();
if (key === "h") controls.heading.click();
if (key === "i") controls.image.click();
if (key === "s") controls.summary.click();
if (key === "r") controls.repeat.click();
if (key === "escape") controls.stop.click();
});