Initial release - Hudra 0.9.1
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
@@ -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; } }
|
||||
@@ -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
@@ -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"); }
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user