Initial release - Hudra 0.9.1

This commit is contained in:
git
2026-07-21 16:10:41 +10:00
commit da12e5c60e
8 changed files with 2079 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Hudra changelog
## 0.9.0
- Removed the startup fade and wordmark so presentations begin immediately.
- Added live audience zoom controls from 70% to 150%.
- Added audience scroll up/down controls with press-and-hold support.
- Added mouse-wheel audience scrolling and Shift + wheel zooming over the current preview.
- Added Reset view and live view status.
- Kept the product name simply **Hudra**.
- Retained dual-screen presenter view, slide previews, timing, clicker support, audience blanking and video controls.
## 0.9.1
- Added one narrowly scoped feature: slides containing exactly one image now fit that image to the full audience viewport using `object-fit: contain`.
- Landscape images fit to width and portrait images fit to height without cropping or distortion.
- All other slide types retain the original Hudra 0.9 behaviour.
+32
View File
@@ -0,0 +1,32 @@
Hudra
=====
Hudra transforms structured Canvas/QLearn pages into responsive presentations. All lesson processing remains local in the browser.
INSTALL
1. Extract this ZIP file.
2. Open edge://extensions, chrome://extensions or brave://extensions.
3. Turn on Developer mode.
4. Remove or disable the previous version.
5. Click Load unpacked and select the hudra-extension folder.
6. Pin Hudra to the toolbar.
START / EXIT
- Click the Hudra toolbar icon, or use Alt+P.
- Hudra now presents immediately with no startup splash.
PRESENTER VIEW
- Zoom audience view from 70% to 150% in 5% steps.
- Scroll audience content up or down; hold a scroll button for continuous movement.
- Mouse wheel over the current preview scrolls the audience.
- Shift + mouse wheel over the preview zooms the audience.
- Double-click the current preview or press Reset view to return to 100% and the top.
- Audience zoom and scroll status are shown live.
KEYS / CLICKERS
- Right / Down / Page Down / Space / Enter: next
- Left / Up / Page Up / Backspace: previous
- Home / End: first / last
- B: hide or restore audience
- Tab: play/pause controllable slide video
- Escape: end presentation
+339
View File
@@ -0,0 +1,339 @@
const sessions = new Map();
const tabToSession = new Map();
const windowToSession = new Map();
const previousWindowStates = new Map();
function makeSessionId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function getUsableDisplays() {
const displays = await chrome.system.display.getInfo();
const unique = [];
const seen = new Set();
for (const display of displays) {
// Mirrored displays should behave like a single display.
if (display.mirroringSourceId) continue;
const bounds = display.bounds || {};
const key = `${bounds.left},${bounds.top},${bounds.width},${bounds.height}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(display);
}
return unique;
}
function chooseSecondaryDisplay(displays) {
const primary = displays.find((display) => display.isPrimary) || displays[0];
const secondary = displays.find((display) => display.id !== primary?.id);
return { primary, secondary };
}
async function waitForTabComplete(tabId, timeoutMs = 20000) {
const existing = await chrome.tabs.get(tabId).catch(() => null);
if (existing?.status === "complete") return;
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
reject(new Error("Timed out waiting for the presentation page to load."));
}, timeoutMs);
function listener(updatedTabId, changeInfo) {
if (updatedTabId === tabId && changeInfo.status === "complete") {
clearTimeout(timeout);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
async function injectPresenter(tabId, config) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["presenter.js"]
});
await chrome.tabs.sendMessage(tabId, {
type: "QLEARN_PRESENTER_START",
config
});
}
async function startSingleScreen(tab) {
if (!tab.id || tab.windowId == null) return;
const sessionId = makeSessionId();
const browserWindow = await chrome.windows.get(tab.windowId);
previousWindowStates.set(tab.windowId, browserWindow.state || "maximized");
const session = {
id: sessionId,
mode: "single",
sourceTabId: tab.id,
audienceTabId: tab.id,
audienceWindowId: tab.windowId,
controlWindowId: null,
pageTitle: "Hudra",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
};
sessions.set(sessionId, session);
tabToSession.set(tab.id, sessionId);
windowToSession.set(tab.windowId, sessionId);
await injectPresenter(tab.id, { mode: "single", sessionId });
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
}
async function startDualScreen(tab, primary, secondary) {
if (!tab.id || !tab.url) return;
const sessionId = makeSessionId();
const sb = secondary.bounds;
const pb = primary.bounds;
const audienceWindow = await chrome.windows.create({
url: tab.url,
type: "popup",
focused: true,
left: sb.left,
top: sb.top,
width: Math.max(500, sb.width),
height: Math.max(400, sb.height)
});
const audienceTab = audienceWindow.tabs?.[0];
if (!audienceWindow.id || !audienceTab?.id) {
throw new Error("Could not create the audience presentation window.");
}
const controlUrl = chrome.runtime.getURL(`controls.html?session=${encodeURIComponent(sessionId)}`);
const controlWindow = await chrome.windows.create({
url: controlUrl,
type: "popup",
focused: false,
left: pb.left,
top: pb.top,
width: Math.max(700, pb.width),
height: Math.max(500, pb.height)
});
const session = {
id: sessionId,
mode: "dual",
sourceTabId: tab.id,
audienceTabId: audienceTab.id,
audienceWindowId: audienceWindow.id,
controlWindowId: controlWindow.id || null,
pageTitle: "Loading lesson…",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
};
sessions.set(sessionId, session);
tabToSession.set(audienceTab.id, sessionId);
windowToSession.set(audienceWindow.id, sessionId);
if (controlWindow.id != null) windowToSession.set(controlWindow.id, sessionId);
await waitForTabComplete(audienceTab.id);
await injectPresenter(audienceTab.id, { mode: "audience", sessionId });
// Position first, then fullscreen so the chosen monitor owns fullscreen.
await chrome.windows.update(audienceWindow.id, {
left: sb.left,
top: sb.top,
width: sb.width,
height: sb.height,
focused: true
});
await chrome.windows.update(audienceWindow.id, { state: "fullscreen" });
if (controlWindow.id != null) {
await chrome.windows.update(controlWindow.id, {
left: pb.left,
top: pb.top,
width: pb.width,
height: pb.height,
focused: true
}).catch(() => {});
await chrome.windows.update(controlWindow.id, { state: "fullscreen", focused: true }).catch(() => {});
}
}
async function endSession(sessionId, reason = "ended") {
const session = sessions.get(sessionId);
if (!session) return;
sessions.delete(sessionId);
if (session.audienceTabId != null) tabToSession.delete(session.audienceTabId);
if (session.audienceWindowId != null) windowToSession.delete(session.audienceWindowId);
if (session.controlWindowId != null) windowToSession.delete(session.controlWindowId);
if (session.mode === "single") {
await chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command: "exit",
reason
}).catch(() => {});
const previousState = previousWindowStates.get(session.audienceWindowId) || "maximized";
previousWindowStates.delete(session.audienceWindowId);
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
});
} else {
if (session.audienceWindowId != null) {
await chrome.windows.remove(session.audienceWindowId).catch(() => {});
}
if (session.controlWindowId != null) {
await chrome.windows.remove(session.controlWindowId).catch(() => {});
}
}
}
async function sendCommand(sessionId, command, value) {
const session = sessions.get(sessionId);
if (!session?.audienceTabId) return;
if (command === "exit") {
await endSession(sessionId, "user");
return;
}
await chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command,
value
}).catch(() => {});
}
async function toggleHudraForTab(tab) {
if (!tab?.id) return;
const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null;
if (windowSessionId) {
await endSession(windowSessionId, "toggle");
return;
}
const existingId = tabToSession.get(tab.id);
if (existingId) {
await endSession(existingId, "toggle");
return;
}
try {
const displays = await getUsableDisplays();
const { primary, secondary } = chooseSecondaryDisplay(displays);
if (primary && secondary) {
await startDualScreen(tab, primary, secondary);
} else {
await startSingleScreen(tab);
}
} catch (error) {
console.error("Hudra failed to start:", error);
try {
await startSingleScreen(tab);
} catch (fallbackError) {
console.error("Hudra fallback also failed:", fallbackError);
}
}
}
chrome.action.onClicked.addListener(toggleHudraForTab);
chrome.commands.onCommand.addListener(async (command) => {
if (command !== "toggle-hudra") return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) await toggleHudraForTab(tab);
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "QLEARN_PRESENTER_STATE") {
const session = sessions.get(message.sessionId);
if (!session) return;
session.current = Number(message.current) || 0;
session.count = Number(message.count) || 0;
session.pageTitle = message.pageTitle || session.pageTitle;
session.audienceHidden = Boolean(message.audienceHidden);
session.audienceZoom = Number(message.audienceZoom) || 100;
session.audienceScrollY = Number(message.audienceScrollY) || 0;
session.currentPreview = message.currentPreview || "";
session.nextPreview = message.nextPreview || "";
chrome.runtime.sendMessage({
type: "QLEARN_PRESENTER_STATE_UPDATE",
sessionId: session.id,
current: session.current,
count: session.count,
pageTitle: session.pageTitle,
mode: session.mode,
audienceHidden: session.audienceHidden,
audienceZoom: session.audienceZoom,
audienceScrollY: session.audienceScrollY,
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
}).catch(() => {});
return;
}
if (message?.type === "QLEARN_PRESENTER_ENDED") {
endSession(message.sessionId, "audience").catch(() => {});
return;
}
if (message?.type === "QLEARN_PRESENTER_CONTROL_COMMAND") {
sendCommand(message.sessionId, message.command, message.value).catch(() => {});
return;
}
if (message?.type === "QLEARN_PRESENTER_GET_SESSION") {
const session = sessions.get(message.sessionId);
sendResponse(session ? {
sessionId: session.id,
mode: session.mode,
current: session.current,
count: session.count,
pageTitle: session.pageTitle,
audienceHidden: session.audienceHidden,
audienceZoom: session.audienceZoom,
audienceScrollY: session.audienceScrollY,
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
} : null);
return true;
}
});
chrome.windows.onRemoved.addListener((windowId) => {
const sessionId = windowToSession.get(windowId);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session) return;
// Closing either dual-screen window ends the whole presentation.
if (session.mode === "dual") {
endSession(sessionId, "window-closed").catch(() => {});
}
});
+51
View File
@@ -0,0 +1,51 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--blue: #2376ff;
--purple: #943cff;
--panel: #141925;
--panel-2: #0d111a;
--line: rgba(255,255,255,.12);
--muted: #a9b2c4;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #090c13; color: #f5f7ff; }
body { background: radial-gradient(circle at 18% 8%, rgba(35,118,255,.17), transparent 32%), radial-gradient(circle at 82% 12%, rgba(148,60,255,.16), transparent 30%), #090c13; }
.presenter-shell { height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: 16px; padding: 18px 22px 20px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 62px; }
.brand { display: inline-block; background: linear-gradient(90deg,var(--blue),var(--purple)); -webkit-background-clip: text; background-clip: text; color: transparent; font-weight: 900; letter-spacing: .12em; font-size: 15px; }
h1 { margin: 4px 0 0; max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: clamp(20px,2vw,31px); line-height: 1.1; }
.top-meta { display: flex; align-items: center; gap: 18px; color: var(--muted); font-size: 14px; }
#current-time { min-width: 92px; color: #fff; font-size: clamp(24px,2.4vw,38px); font-weight: 750; text-align: right; font-variant-numeric: tabular-nums; }
.preview-grid { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(300px, .85fr); gap: 18px; }
.preview-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); box-shadow: 0 20px 55px rgba(0,0,0,.28); }
.current-card { border-color: rgba(35,118,255,.48); box-shadow: 0 0 0 1px rgba(35,118,255,.12), 0 20px 55px rgba(0,0,0,.28); }
.next-card { border-color: rgba(148,60,255,.42); }
.preview-label { position: absolute; top: 10px; left: 12px; z-index: 3; padding: 5px 9px; border-radius: 999px; background: rgba(5,8,14,.78); color: #fff; font-size: 11px; font-weight: 800; letter-spacing: .14em; }
.preview-card iframe { width: 100%; height: 100%; border: 0; background: white; pointer-events: none; }
.control-deck { display: grid; grid-template-columns: minmax(160px,.65fr) minmax(390px,1.15fr) minmax(430px,1.25fr) minmax(300px,.85fr); align-items: center; gap: 18px; min-height: 92px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 18px; background: rgba(20,25,37,.92); box-shadow: 0 18px 45px rgba(0,0,0,.26); backdrop-filter: blur(14px); }
.deck-left { display: flex; align-items: center; gap: 22px; }
.slide-counter { font-size: clamp(17px,1.4vw,23px); font-weight: 800; white-space: nowrap; }
.time-block { display: grid; gap: 2px; }
.time-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
#elapsed-time { font-size: 25px; font-weight: 750; font-variant-numeric: tabular-nums; }
.transport, .utility-controls { display: flex; align-items: center; justify-content: center; gap: 9px; }
.utility-controls { justify-content: flex-end; }
button { min-height: 44px; border: 1px solid rgba(255,255,255,.15); border-radius: 11px; padding: 10px 14px; background: #202738; color: #f7f9ff; cursor: pointer; font: inherit; font-weight: 700; white-space: nowrap; }
button:hover:not(:disabled) { transform: translateY(-1px); background: #293247; }
button:disabled { cursor: default; opacity: .35; }
button.primary { border-color: transparent; background: linear-gradient(90deg,var(--blue),var(--purple)); }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.danger { border-color: rgba(255,87,106,.45); color: #ffadb7; background: rgba(130,25,45,.25); }
@media (max-width: 1150px) {
.control-deck { grid-template-columns: 1fr 1.5fr; }
.utility-controls { grid-column: 1 / -1; justify-content: center; }
}
.view-controls { display:flex; align-items:center; justify-content:center; gap:9px; flex-wrap:wrap; }
.control-group { display:flex; align-items:center; gap:6px; padding:5px 7px; border:1px solid var(--line); border-radius:11px; background:rgba(255,255,255,.035); }
.control-label { color:var(--muted); font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.09em; margin-right:2px; }
.control-group button { min-width:38px; padding:8px 10px; }
#zoom-value { min-width:52px; text-align:center; font-weight:800; font-variant-numeric:tabular-nums; }
.view-status { width:100%; color:var(--muted); text-align:center; font-size:12px; }
@media (max-width: 1500px) { .control-deck { grid-template-columns: 1fr 1.4fr 1.5fr; } .utility-controls { grid-column:1/-1; justify-content:center; } }
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hudra</title>
<link rel="stylesheet" href="controls.css">
</head>
<body>
<main class="presenter-shell">
<header class="topbar">
<div>
<div class="brand">HUDRA</div>
<h1 id="page-title">Loading lesson…</h1>
</div>
<div class="top-meta">
<span id="screen-status">Audience screen active</span>
<span id="current-time">--:--</span>
</div>
</header>
<section class="preview-grid">
<article class="preview-card current-card">
<div class="preview-label">NOW</div>
<iframe id="current-preview" title="Current slide preview"></iframe>
</article>
<article class="preview-card next-card">
<div class="preview-label">NEXT</div>
<iframe id="next-preview" title="Next slide preview"></iframe>
</article>
</section>
<footer class="control-deck">
<div class="deck-left">
<div id="slide-counter" class="slide-counter">Preparing slides…</div>
<div class="time-block"><span class="time-label">Elapsed</span><span id="elapsed-time">00:00</span></div>
</div>
<div class="transport">
<button id="first" type="button" title="First slide">|←</button>
<button id="previous" type="button">← Previous</button>
<button id="next" type="button" class="primary">Next →</button>
<button id="last" type="button" title="Last slide">→|</button>
</div>
<div class="view-controls" aria-label="Audience view controls">
<div class="control-group">
<span class="control-label">Zoom</span>
<button id="zoom-out" type="button" title="Zoom audience out"></button>
<output id="zoom-value">100%</output>
<button id="zoom-in" type="button" title="Zoom audience in">+</button>
</div>
<div class="control-group">
<span class="control-label">Scroll</span>
<button id="scroll-up" type="button" title="Scroll audience up"></button>
<button id="scroll-down" type="button" title="Scroll audience down"></button>
</div>
<button id="reset-view" type="button">Reset view</button>
<span id="view-status" class="view-status">Fit · top</span>
</div>
<div class="utility-controls">
<button id="video" type="button">Play / Pause video</button>
<button id="audience" type="button">Hide audience</button>
<button id="exit" type="button" class="danger">End</button>
</div>
</footer>
</main>
<script src="controls.js"></script>
</body>
</html>
Vendored
+135
View File
@@ -0,0 +1,135 @@
const params = new URLSearchParams(location.search);
const sessionId = params.get("session");
const pageTitle = document.getElementById("page-title");
const slideCounter = document.getElementById("slide-counter");
const previousButton = document.getElementById("previous");
const nextButton = document.getElementById("next");
const firstButton = document.getElementById("first");
const lastButton = document.getElementById("last");
const videoButton = document.getElementById("video");
const audienceButton = document.getElementById("audience");
const exitButton = document.getElementById("exit");
const currentPreview = document.getElementById("current-preview");
const nextPreview = document.getElementById("next-preview");
const currentTime = document.getElementById("current-time");
const elapsedTime = document.getElementById("elapsed-time");
const screenStatus = document.getElementById("screen-status");
const zoomOutButton = document.getElementById("zoom-out");
const zoomInButton = document.getElementById("zoom-in");
const zoomValue = document.getElementById("zoom-value");
const scrollUpButton = document.getElementById("scroll-up");
const scrollDownButton = document.getElementById("scroll-down");
const resetViewButton = document.getElementById("reset-view");
const viewStatus = document.getElementById("view-status");
let current = 0;
let count = 0;
let audienceHidden = false;
let audienceZoom = 100;
let audienceScrollY = 0;
const startedAt = Date.now();
function send(command, value) {
if (!sessionId) return;
chrome.runtime.sendMessage({
type: "QLEARN_PRESENTER_CONTROL_COMMAND",
sessionId, command, value
}).catch(() => {});
}
function setPreview(frame, srcdoc, emptyLabel) {
if (srcdoc) {
frame.srcdoc = srcdoc;
frame.style.opacity = "1";
} else {
frame.srcdoc = `<!doctype html><html><body style="margin:0;display:grid;place-items:center;height:100vh;background:#111722;color:#9ca8ba;font:600 28px system-ui">${emptyLabel}</body></html>`;
frame.style.opacity = ".72";
}
}
function render(state) {
if (!state) return;
current = Number(state.current) || 0;
count = Number(state.count) || 0;
pageTitle.textContent = state.pageTitle || "Hudra";
audienceHidden = Boolean(state.audienceHidden);
audienceZoom = Number(state.audienceZoom) || 100;
audienceScrollY = Number(state.audienceScrollY) || 0;
zoomValue.textContent = `${audienceZoom}%`;
const scrollLabel = Math.abs(audienceScrollY) < 4 ? "top" : `${Math.round(audienceScrollY)} px`;
viewStatus.textContent = `${audienceZoom === 100 ? "Fit" : `Scaled ${audienceZoom}%`} · ${scrollLabel}`;
audienceButton.textContent = audienceHidden ? "Restore audience" : "Hide audience";
screenStatus.textContent = audienceHidden ? "Audience screen hidden" : "Audience screen active";
slideCounter.textContent = count > 0 ? `Slide ${current + 1} of ${count}` : "Preparing slides…";
previousButton.disabled = current <= 0 || count <= 0;
firstButton.disabled = current <= 0 || count <= 0;
nextButton.disabled = count <= 0 || current >= count - 1;
lastButton.disabled = count <= 0 || current >= count - 1;
setPreview(currentPreview, state.currentPreview, "Current slide");
setPreview(nextPreview, state.nextPreview, current >= count - 1 ? "End of presentation" : "Next slide");
}
function updateTimes() {
const now = new Date();
currentTime.textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
const minutes = String(Math.floor(elapsed / 60)).padStart(2, "0");
const seconds = String(elapsed % 60).padStart(2, "0");
elapsedTime.textContent = `${minutes}:${seconds}`;
}
setInterval(updateTimes, 1000);
updateTimes();
previousButton.addEventListener("click", () => send("previous"));
nextButton.addEventListener("click", () => send("next"));
firstButton.addEventListener("click", () => send("first"));
lastButton.addEventListener("click", () => send("last"));
videoButton.addEventListener("click", () => send("toggleVideo"));
audienceButton.addEventListener("click", () => send("toggleAudienceHidden"));
exitButton.addEventListener("click", () => send("exit"));
zoomOutButton.addEventListener("click", () => send("zoomOut"));
zoomInButton.addEventListener("click", () => send("zoomIn"));
resetViewButton.addEventListener("click", () => send("resetView"));
function addHoldScroll(button, command) {
let timer = null;
const stop = () => { if (timer) clearInterval(timer); timer = null; };
button.addEventListener("pointerdown", (event) => { event.preventDefault(); send(command); timer = setInterval(() => send(command), 140); });
["pointerup", "pointercancel", "pointerleave"].forEach((name) => button.addEventListener(name, stop));
}
addHoldScroll(scrollUpButton, "scrollUp");
addHoldScroll(scrollDownButton, "scrollDown");
currentPreview.addEventListener("wheel", (event) => {
event.preventDefault();
if (event.shiftKey) send(event.deltaY < 0 ? "zoomIn" : "zoomOut");
else send("scrollBy", event.deltaY);
}, { passive: false });
currentPreview.addEventListener("dblclick", () => send("resetView"));
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === "QLEARN_PRESENTER_STATE_UPDATE" && message.sessionId === sessionId) render(message);
});
function requestSession(attempt = 0) {
chrome.runtime.sendMessage({ type: "QLEARN_PRESENTER_GET_SESSION", sessionId })
.then((state) => {
if (state) render(state);
else if (attempt < 20) setTimeout(() => requestSession(attempt + 1), 250);
})
.catch(() => {
if (attempt < 20) setTimeout(() => requestSession(attempt + 1), 250);
});
}
requestSession();
window.addEventListener("keydown", (event) => {
if (event.key.toLowerCase() === "b") { event.preventDefault(); if (!event.repeat) send("toggleAudienceHidden"); return; }
if (event.key === "Tab") { event.preventDefault(); if (!event.repeat) send("toggleVideo"); return; }
if (["ArrowRight", "ArrowDown", "PageDown", " ", "Enter"].includes(event.key)) { event.preventDefault(); send("next"); return; }
if (["ArrowLeft", "ArrowUp", "PageUp", "Backspace"].includes(event.key)) { event.preventDefault(); send("previous"); return; }
if (event.key === "Home") { event.preventDefault(); send("first"); return; }
if (event.key === "End") { event.preventDefault(); send("last"); return; }
if (event.key === "Escape") { event.preventDefault(); send("exit"); }
});
+30
View File
@@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "Hudra",
"version": "0.9.1",
"description": "Hudra turns Canvas/QLearn pages into responsive presentations with automatic pagination, dual-screen presenter view, audience controls, clicker support and local-only processing.",
"permissions": [
"activeTab",
"scripting",
"tabs",
"system.display"
],
"host_permissions": [
"https://*.instructure.com/*",
"https://*.qlearn.eq.edu.au/*"
],
"action": {
"default_title": "Start or exit Hudra"
},
"background": {
"service_worker": "background.js"
},
"commands": {
"toggle-hudra": {
"suggested_key": {
"default": "Alt+P"
},
"description": "Start or exit Hudra"
}
}
}
+1405
View File
File diff suppressed because it is too large Load Diff