From da12e5c60eea84a958a08eb864e0e2c1407f13c7 Mon Sep 17 00:00:00 2001 From: Kaden Napper Date: Tue, 21 Jul 2026 16:10:41 +1000 Subject: [PATCH] Initial release - Hudra 0.9.1 --- CHANGELOG.md | 16 + README.md | 32 ++ background.js | 339 ++++++++++++ controls.css | 51 ++ controls.html | 71 +++ controls.js | 135 +++++ manifest.json | 30 ++ presenter.js | 1405 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 2079 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 background.js create mode 100644 controls.css create mode 100644 controls.html create mode 100644 controls.js create mode 100644 manifest.json create mode 100644 presenter.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..43eabeb --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8d99b6 --- /dev/null +++ b/README.md @@ -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 diff --git a/background.js b/background.js new file mode 100644 index 0000000..f89bae8 --- /dev/null +++ b/background.js @@ -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(() => {}); + } +}); diff --git a/controls.css b/controls.css new file mode 100644 index 0000000..528da98 --- /dev/null +++ b/controls.css @@ -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; } } diff --git a/controls.html b/controls.html new file mode 100644 index 0000000..9fc955a --- /dev/null +++ b/controls.html @@ -0,0 +1,71 @@ + + + + + + Hudra + + + +
+
+
+
HUDRA
+

Loading lesson…

+
+
+ Audience screen active + --:-- +
+
+ +
+
+
NOW
+ +
+
+
NEXT
+ +
+
+ +
+
+
Preparing slides…
+
Elapsed00:00
+
+ +
+ + + + +
+ +
+
+ Zoom + + 100% + +
+
+ Scroll + + +
+ + Fit · top +
+ +
+ + + +
+
+
+ + + diff --git a/controls.js b/controls.js new file mode 100644 index 0000000..91c5760 --- /dev/null +++ b/controls.js @@ -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 = `${emptyLabel}`; + 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"); } +}); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b05c2ef --- /dev/null +++ b/manifest.json @@ -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" + } + } +} diff --git a/presenter.js b/presenter.js new file mode 100644 index 0000000..5cec8f1 --- /dev/null +++ b/presenter.js @@ -0,0 +1,1405 @@ +(() => { + if (window.__qlearnPresenterBootstrapInstalled) return; + window.__qlearnPresenterBootstrapInstalled = true; + + function startPresenter(config = {}) { + if (window.__qlearnPresenter) { + window.__qlearnPresenter.exit(false); + } + + const presenterMode = config.mode || "single"; + const sessionId = config.sessionId || "standalone"; + + const contentRoot = + document.querySelector( + "#content .user_content, " + + ".show-content .user_content, " + + ".wiki-page-content .user_content, " + + ".user_content" + ) || + document.querySelector( + "[role='main'] .page-content, " + + "[role='main'] .content" + ) || + document.querySelector("#content") || + document.body; + + const pageTitleElement = + contentRoot.querySelector("h1") || + document.querySelector( + "#content h1, .page-title, .wiki-page-title, h1.page-title" + ); + + let pageTitle = pageTitleElement?.textContent?.trim() || ""; + + if (!pageTitle) { + pageTitle = document.title.replace(/\s*:\s*[^:]+$/, "").trim(); + } + + if (!pageTitle) { + pageTitle = "Lesson"; + } + + function isHidden(element) { + if (!element || element.hidden) return true; + + const computedStyle = window.getComputedStyle(element); + return ( + computedStyle.display === "none" || + computedStyle.visibility === "hidden" + ); + } + + const visibleSlideHeadings = [ + ...contentRoot.querySelectorAll("h2, h3") + ].filter((heading) => { + const hasText = heading.textContent.trim().length > 0; + const hasMedia = Boolean( + heading.querySelector("img, video, iframe, svg, canvas, picture") + ); + + return (hasText || hasMedia) && !isHidden(heading); + }); + + const slideDefinitions = []; + + if (visibleSlideHeadings.length) { + visibleSlideHeadings.forEach((heading, index) => { + const nextHeading = visibleSlideHeadings[index + 1] || null; + const range = document.createRange(); + + range.setStartBefore(heading); + + if (nextHeading) { + range.setEndBefore(nextHeading); + } else { + range.setEnd(contentRoot, contentRoot.childNodes.length); + } + + slideDefinitions.push({ + index, + headingLevel: heading.tagName, + fragment: range.cloneContents() + }); + + if (typeof range.detach === "function") { + range.detach(); + } + }); + } else { + const range = document.createRange(); + range.selectNodeContents(contentRoot); + + slideDefinitions.push({ + index: 0, + headingLevel: null, + fragment: range.cloneContents() + }); + + if (typeof range.detach === "function") { + range.detach(); + } + } + + if (!slideDefinitions.length) { + alert("No presentable page content was found."); + return; + } + + const presentationLayer = document.createElement("div"); + presentationLayer.id = "qlearn-presenter-layer"; + document.body.appendChild(presentationLayer); + + const pageHeader = document.createElement("div"); + pageHeader.id = "qlearn-presenter-page-title"; + pageHeader.textContent = pageTitle; + presentationLayer.appendChild(pageHeader); + + const presenterCredit = document.createElement("div"); + presenterCredit.id = "qlearn-presenter-credit"; + presenterCredit.textContent = "Hudra"; + presentationLayer.appendChild(presenterCredit); + + const gameCredit = document.createElement("div"); + gameCredit.id = "qlearn-presenter-game"; + gameCredit.textContent = "The Game"; + presentationLayer.appendChild(gameCredit); + + const slidesContainer = document.createElement("div"); + slidesContainer.id = "qlearn-presenter-slides"; + presentationLayer.appendChild(slidesContainer); + + const toast = document.createElement("div"); + toast.id = "qlearn-presenter-toast"; + presentationLayer.appendChild(toast); + + const audienceHiddenOverlay = document.createElement("div"); + audienceHiddenOverlay.id = "qlearn-presenter-audience-hidden"; + audienceHiddenOverlay.innerHTML = `
Audience Hidden
`; + presentationLayer.appendChild(audienceHiddenOverlay); + + let slideWrappers = slideDefinitions.map((slide, index) => { + const wrapper = document.createElement("div"); + wrapper.className = "qlearn-presenter-slide"; + wrapper.dataset.slideIndex = String(index); + + if (slide.headingLevel) { + wrapper.dataset.headingLevel = slide.headingLevel.toLowerCase(); + } + + wrapper.appendChild(slide.fragment.cloneNode(true)); + slidesContainer.appendChild(wrapper); + return wrapper; + }); + + slideWrappers.forEach((wrapper) => { + wrapper + .querySelectorAll( + "script, style, noscript, [hidden], [aria-hidden='true']" + ) + .forEach((element) => element.remove()); + + wrapper.querySelectorAll("h1").forEach((element) => element.remove()); + }); + + function isImageOnlySlide(wrapper) { + const images = [...wrapper.querySelectorAll("img")].filter((image) => { + const style = window.getComputedStyle(image); + return style.display !== "none" && style.visibility !== "hidden"; + }); + + if (images.length !== 1) return false; + + if (wrapper.querySelector("video, iframe, canvas, svg, table, pre, code, ul, ol")) { + return false; + } + + const walker = document.createTreeWalker( + wrapper, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + return node.textContent.trim() + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + } + ); + + return !walker.nextNode(); + } + + slideWrappers.forEach((wrapper) => { + wrapper.classList.toggle("qlp-image-only", isImageOnlySlide(wrapper)); + }); + + const style = document.createElement("style"); + style.id = "qlearn-presenter-style"; + style.textContent = ` + body.qlearn-presenting { + overflow: hidden !important; + background: #111 !important; + } + + body.qlearn-presenting > *:not(#qlearn-presenter-layer):not(#qlearn-presenter-controls) { + visibility: hidden !important; + } + + #qlearn-presenter-layer { + visibility: visible !important; + position: fixed !important; + inset: 0 !important; + z-index: 2147483646 !important; + overflow: hidden !important; + background: white !important; + --qlp-slide-pad-top: 11vh; + --qlp-slide-pad-side: 7vw; + --qlp-slide-pad-bottom: 13vh; + --qlp-body-size: clamp(21px, 2.1vw, 33px); + --qlp-h2-size: clamp(44px, 5.8vw, 78px); + --qlp-h3-size: clamp(38px, 4.8vw, 66px); + --qlp-media-height: 58vh; + } + + #qlearn-presenter-layer.qlp-aspect-16-10 { + --qlp-slide-pad-top: 10vh; + --qlp-slide-pad-side: 6.5vw; + --qlp-slide-pad-bottom: 11vh; + --qlp-body-size: clamp(20px, 1.95vw, 31px); + --qlp-h2-size: clamp(42px, 5.2vw, 72px); + --qlp-h3-size: clamp(36px, 4.3vw, 60px); + --qlp-media-height: 62vh; + } + + #qlearn-presenter-layer, + #qlearn-presenter-layer * { + visibility: visible !important; + } + + #qlearn-presenter-page-title { + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + z-index: 10 !important; + box-sizing: border-box !important; + min-height: 48px !important; + padding: 12px 7vw 10px !important; + overflow: hidden !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.12) !important; + background: rgba(255, 255, 255, 0.97) !important; + color: #3a3a3a !important; + font-family: Arial, sans-serif !important; + font-size: clamp(16px, 1.4vw, 22px) !important; + font-weight: 600 !important; + line-height: 1.2 !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } + + #qlearn-presenter-credit { + position: absolute !important; + right: 18px !important; + bottom: 12px !important; + z-index: 20 !important; + color: rgba(0, 0, 0, 0.52) !important; + font-family: Arial, sans-serif !important; + font-size: 11px !important; + font-weight: 400 !important; + line-height: 1 !important; + pointer-events: none !important; + white-space: nowrap !important; + } + + #qlearn-presenter-game { + position: absolute !important; + left: 18px !important; + bottom: 12px !important; + z-index: 95 !important; + color: rgba(255, 255, 255, 0.72) !important; + mix-blend-mode: difference !important; + font-family: Arial, sans-serif !important; + font-size: 11px !important; + font-weight: 400 !important; + line-height: 1 !important; + pointer-events: none !important; + white-space: nowrap !important; + opacity: 1 !important; + transition: opacity 700ms ease !important; + } + + #qlearn-presenter-game.qlearn-presenter-game-hidden { + opacity: 0 !important; + } + + #qlearn-presenter-slides { + position: absolute !important; + inset: 0 !important; + overflow: hidden !important; + } + + .qlearn-presenter-slide { + display: block !important; + position: absolute !important; + inset: 0 !important; + z-index: 0 !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + overflow-y: auto !important; + padding: var(--qlp-slide-pad-top) var(--qlp-slide-pad-side) var(--qlp-slide-pad-bottom) !important; + background: white !important; + color: #222 !important; + font-family: Arial, sans-serif !important; + font-size: var(--qlp-body-size) !important; + line-height: 1.4 !important; + opacity: 0 !important; + visibility: hidden !important; + transform: translateY(8px) !important; + pointer-events: none !important; + transition: + opacity 220ms ease, + transform 220ms ease, + visibility 0s linear 220ms !important; + } + + .qlearn-presenter-slide.qlearn-slide-active { + z-index: 1 !important; + opacity: 1 !important; + visibility: visible !important; + transform: translateY(0) !important; + pointer-events: auto !important; + transition: + opacity 220ms ease, + transform 220ms ease, + visibility 0s linear 0s !important; + } + + @media (prefers-reduced-motion: reduce) { + .qlearn-presenter-slide { + transform: none !important; + transition: none !important; + } + } + + .qlearn-presenter-slide > div, + .qlearn-presenter-slide section, + .qlearn-presenter-slide article { + max-width: none !important; + } + + .qlearn-presenter-slide h2 { + margin: 0 0 0.7em !important; + padding: 0 !important; + color: #222 !important; + font-family: Arial, sans-serif !important; + font-size: var(--qlp-h2-size) !important; + font-weight: 700 !important; + line-height: 1.06 !important; + } + + .qlearn-presenter-slide h3 { + margin: 0 0 0.7em !important; + padding: 0 !important; + color: #222 !important; + font-family: Arial, sans-serif !important; + font-size: var(--qlp-h3-size) !important; + font-weight: 700 !important; + line-height: 1.1 !important; + } + + .qlearn-presenter-slide h4 { + margin: 1em 0 0.45em !important; + color: #222 !important; + font-family: Arial, sans-serif !important; + font-size: clamp(27px, 3vw, 42px) !important; + font-weight: 700 !important; + line-height: 1.15 !important; + } + + .qlearn-presenter-slide h5, + .qlearn-presenter-slide h6 { + margin: 0.9em 0 0.4em !important; + color: #222 !important; + font-family: Arial, sans-serif !important; + font-size: 1.05em !important; + font-weight: 700 !important; + line-height: 1.2 !important; + } + + .qlearn-presenter-slide h2:has(img), + .qlearn-presenter-slide h3:has(img) { + margin: 0 0 0.7em !important; + padding: 0 !important; + line-height: 1 !important; + } + + .qlearn-presenter-slide h2 img, + .qlearn-presenter-slide h3 img { + display: block !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + max-height: 28vh !important; + object-fit: contain !important; + } + + .qlearn-presenter-slide p { + margin-top: 0 !important; + margin-bottom: 0.8em !important; + } + + .qlearn-presenter-slide ul, + .qlearn-presenter-slide ol { + margin-top: 0.4em !important; + margin-bottom: 0.8em !important; + padding-left: 1.5em !important; + } + + .qlearn-presenter-slide li { + margin-bottom: 0.3em !important; + } + + .qlearn-presenter-slide a { + color: #0068a8 !important; + text-decoration: underline !important; + } + + .qlearn-presenter-slide img { + max-width: 100% !important; + max-height: calc(var(--qlp-media-height) * var(--qlp-media-scale, 1)) !important; + width: auto !important; + height: auto !important; + object-fit: contain !important; + } + + .qlearn-presenter-slide video { + display: block !important; + max-width: 100% !important; + max-height: calc(var(--qlp-media-height) * var(--qlp-media-scale, 1)) !important; + width: auto !important; + height: auto !important; + object-fit: contain !important; + } + + .qlearn-presenter-slide iframe { + display: block !important; + width: min(90vw, 1200px) !important; + height: min(65vh, 720px) !important; + max-width: 100% !important; + border: 0 !important; + } + + .qlearn-presenter-slide table { + width: 100% !important; + max-width: 100% !important; + height: auto !important; + border-collapse: collapse !important; + table-layout: auto !important; + } + + .qlearn-presenter-slide tbody, + .qlearn-presenter-slide tr, + .qlearn-presenter-slide td, + .qlearn-presenter-slide th { + height: auto !important; + max-width: 100% !important; + } + + .qlearn-presenter-slide td, + .qlearn-presenter-slide th { + box-sizing: border-box !important; + padding: 0.35em !important; + vertical-align: top !important; + } + + .qlearn-presenter-slide [style*="width"] { + max-width: 100% !important; + } + + .qlearn-presenter-slide-content { + width: 100% !important; + box-sizing: border-box !important; + zoom: var(--qlp-content-scale, 1); + } + + .qlearn-presenter-slide.qlp-tight { + padding-top: 8vh !important; + padding-bottom: 9vh !important; + } + + .qlearn-presenter-slide.qlp-very-tight { + padding-top: 7vh !important; + padding-right: 5vw !important; + padding-bottom: 8vh !important; + padding-left: 5vw !important; + } + + .qlearn-presenter-slide .qlp-continuation-title::after { + content: " · continued"; + font-size: 0.42em; + font-weight: 500; + opacity: 0.62; + white-space: nowrap; + } + + .qlearn-presenter-slide .qlp-compact-block { + font-size: 0.82em !important; + } + + /* A slide containing exactly one image uses the full audience viewport. */ + .qlearn-presenter-slide.qlp-image-only { + padding: 0 !important; + overflow: hidden !important; + background: #fff !important; + } + + .qlearn-presenter-slide.qlp-image-only .qlearn-presenter-slide-content { + width: 100vw !important; + height: 100vh !important; + zoom: 1 !important; + } + + .qlearn-presenter-slide.qlp-image-only img { + position: absolute !important; + inset: 0 !important; + display: block !important; + width: 100vw !important; + height: 100vh !important; + max-width: none !important; + max-height: none !important; + margin: auto !important; + object-fit: contain !important; + } + + #qlearn-presenter-layer.qlp-showing-image-only #qlearn-presenter-page-title { + display: none !important; + } + + #qlearn-presenter-audience-hidden { + position: absolute !important; + inset: 0 !important; + z-index: 80 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + background: #000 !important; + color: rgba(255, 255, 255, 0.92) !important; + font: 600 clamp(24px, 3vw, 44px)/1.2 Arial, sans-serif !important; + letter-spacing: 0.02em !important; + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; + transition: opacity 180ms ease, visibility 0s linear 180ms !important; + } + + #qlearn-presenter-audience-hidden.qlearn-presenter-audience-hidden-visible { + opacity: 1 !important; + visibility: visible !important; + transition: opacity 180ms ease, visibility 0s linear 0s !important; + } + + #qlearn-presenter-toast { + position: absolute !important; + left: 50% !important; + bottom: 78px !important; + transform: translateX(-50%) translateY(10px) !important; + z-index: 30 !important; + padding: 9px 14px !important; + border-radius: 999px !important; + background: rgba(0, 0, 0, 0.78) !important; + color: white !important; + font: 14px Arial, sans-serif !important; + opacity: 0 !important; + transition: opacity 140ms ease, transform 140ms ease !important; + pointer-events: none !important; + white-space: nowrap !important; + } + + #qlearn-presenter-toast.qlearn-presenter-toast-visible { + opacity: 1 !important; + transform: translateX(-50%) translateY(0) !important; + } + + body.qlearn-presenter-audience #qlearn-presenter-controls { + display: none !important; + } + + #qlearn-presenter-controls { + visibility: visible !important; + position: fixed !important; + left: 50% !important; + bottom: 20px !important; + transform: translateX(-50%) !important; + z-index: 2147483647 !important; + display: flex !important; + align-items: center !important; + gap: 12px !important; + padding: 10px 16px !important; + border-radius: 999px !important; + background: rgba(0, 0, 0, 0.8) !important; + color: white !important; + font: 16px Arial, sans-serif !important; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.3) !important; + } + + #qlearn-presenter-controls, + #qlearn-presenter-controls * { + visibility: visible !important; + } + + #qlearn-presenter-controls button { + border: 0 !important; + border-radius: 7px !important; + padding: 8px 13px !important; + background: white !important; + color: #222 !important; + cursor: pointer !important; + font: inherit !important; + } + + #qlearn-presenter-controls button:hover:not(:disabled) { + background: #e8e8e8 !important; + } + + #qlearn-presenter-controls button:disabled { + cursor: default !important; + opacity: 0.4 !important; + } + + #qlearn-presenter-controls [data-role="counter"] { + min-width: 110px !important; + text-align: center !important; + white-space: nowrap !important; + } + `; + + document.head.appendChild(style); + + function applyAspectMode() { + const ratio = window.innerWidth / Math.max(window.innerHeight, 1); + // Only two supported layouts: 16:9 (1.778) and 16:10 (1.6). + const isSixteenTen = Math.abs(ratio - 1.6) < Math.abs(ratio - 16 / 9); + presentationLayer.classList.toggle("qlp-aspect-16-10", isSixteenTen); + presentationLayer.classList.toggle("qlp-aspect-16-9", !isSixteenTen); + presentationLayer.dataset.aspect = isSixteenTen ? "16:10" : "16:9"; + } + + function wrapSlideContent(wrapper) { + let content = wrapper.querySelector(":scope > .qlearn-presenter-slide-content"); + if (content) return content; + content = document.createElement("div"); + content.className = "qlearn-presenter-slide-content"; + while (wrapper.firstChild) content.appendChild(wrapper.firstChild); + wrapper.appendChild(content); + return content; + } + + function slideOverflows(wrapper) { + const content = wrapSlideContent(wrapper); + return ( + content.getBoundingClientRect().bottom > + wrapper.getBoundingClientRect().bottom - 4 || + wrapper.scrollHeight > wrapper.clientHeight + 4 + ); + } + + function resetFit(wrapper) { + wrapper.classList.remove("qlp-tight", "qlp-very-tight"); + wrapper.style.setProperty("--qlp-content-scale", "1"); + wrapper.style.setProperty("--qlp-media-scale", "1"); + } + + function fitSlide(wrapper, allowSmall = true) { + resetFit(wrapper); + if (wrapper.classList.contains("qlp-image-only")) return true; + if (!slideOverflows(wrapper)) return true; + + wrapper.style.setProperty("--qlp-media-scale", "0.86"); + if (!slideOverflows(wrapper)) return true; + + wrapper.classList.add("qlp-tight"); + if (!slideOverflows(wrapper)) return true; + + wrapper.classList.add("qlp-very-tight"); + if (!slideOverflows(wrapper)) return true; + + const scales = allowSmall + ? [0.96, 0.92, 0.88, 0.84, 0.80] + : [0.96, 0.92, 0.88]; + for (const scale of scales) { + wrapper.style.setProperty("--qlp-content-scale", String(scale)); + if (!slideOverflows(wrapper)) return true; + } + return false; + } + + function makeContinuationHeading(sourceHeading) { + const heading = sourceHeading.cloneNode(true); + heading.classList.add("qlp-continuation-title"); + return heading; + } + + function splitParagraph(paragraph) { + const text = paragraph.textContent.trim(); + const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text]; + if (sentences.length < 2) return [paragraph.cloneNode(true)]; + const midpoint = Math.ceil(sentences.length / 2); + return [sentences.slice(0, midpoint), sentences.slice(midpoint)] + .filter((part) => part.length) + .map((part) => { + const copy = paragraph.cloneNode(false); + copy.textContent = part.join(" ").trim(); + return copy; + }); + } + + function splitList(list) { + const items = [...list.children].filter((item) => item.tagName === "LI"); + if (items.length < 2) return [list.cloneNode(true)]; + const midpoint = Math.ceil(items.length / 2); + return [items.slice(0, midpoint), items.slice(midpoint)].map((group) => { + const copy = list.cloneNode(false); + group.forEach((item) => copy.appendChild(item.cloneNode(true))); + return copy; + }); + } + + function divideOversizedBlock(block) { + if (block.matches("ul, ol")) return splitList(block); + if (block.matches("p") && block.textContent.trim().length > 220) { + return splitParagraph(block); + } + return [block.cloneNode(true)]; + } + + function createGeneratedSlide(template, heading, continued) { + const wrapper = document.createElement("div"); + wrapper.className = "qlearn-presenter-slide"; + wrapper.dataset.headingLevel = template.dataset.headingLevel || "h2"; + const content = document.createElement("div"); + content.className = "qlearn-presenter-slide-content"; + if (heading) { + content.appendChild(continued ? makeContinuationHeading(heading) : heading.cloneNode(true)); + } + wrapper.appendChild(content); + return wrapper; + } + + function paginateWrapper(original) { + const originalContent = wrapSlideContent(original); + const blocks = [...originalContent.children]; + if (!blocks.length || fitSlide(original, false)) return [original]; + + const first = blocks[0]?.matches("h2, h3") ? blocks.shift() : null; + const generated = []; + let currentWrapper = createGeneratedSlide(original, first, false); + slidesContainer.appendChild(currentWrapper); + generated.push(currentWrapper); + + function currentContent() { + return currentWrapper.querySelector(".qlearn-presenter-slide-content"); + } + + function newContinuation() { + currentWrapper = createGeneratedSlide(original, first, true); + slidesContainer.appendChild(currentWrapper); + generated.push(currentWrapper); + } + + for (const sourceBlock of blocks) { + const pieces = divideOversizedBlock(sourceBlock); + for (const piece of pieces) { + currentContent().appendChild(piece); + resetFit(currentWrapper); + if (!slideOverflows(currentWrapper)) continue; + + piece.remove(); + const hasBody = [...currentContent().children].some((el) => !el.matches("h2, h3")); + if (hasBody) newContinuation(); + currentContent().appendChild(piece); + + if (!fitSlide(currentWrapper, false)) { + if (piece.matches("table, pre, iframe, video, img, div")) { + piece.classList.add("qlp-compact-block"); + } + fitSlide(currentWrapper, true); + } + } + } + + original.remove(); + return generated; + } + + function paginateAndFitSlides() { + applyAspectMode(); + const originals = [...slideWrappers]; + const rebuilt = []; + originals.forEach((wrapper) => rebuilt.push(...paginateWrapper(wrapper))); + slideWrappers = rebuilt; + slideWrappers.forEach((wrapper, index) => { + wrapper.dataset.slideIndex = String(index); + fitSlide(wrapper, true); + }); + } + + let resizeTimer = null; + function refitAfterResize() { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + applyAspectMode(); + slideWrappers.forEach((wrapper) => fitSlide(wrapper, true)); + if (slideWrappers[current]) slideWrappers[current].scrollTop = 0; + }, 160); + } + + applyAspectMode(); + paginateAndFitSlides(); + window.addEventListener("resize", refitAfterResize); + + const controls = document.createElement("div"); + controls.id = "qlearn-presenter-controls"; + controls.innerHTML = ` + + + + + `; + + document.body.appendChild(controls); + + const previousButton = controls.querySelector('[data-action="prev"]'); + const nextButton = controls.querySelector('[data-action="next"]'); + const counter = controls.querySelector('[data-role="counter"]'); + + let current = 0; + let toastTimer = null; + let audienceHidden = false; + let audienceZoom = 100; + let audienceScrollY = 0; + + function applyAudienceView() { + const slide = slideWrappers[current]; + if (!slide) return; + slide.style.zoom = `${audienceZoom}%`; + slide.scrollTop = audienceScrollY; + requestAnimationFrame(() => { audienceScrollY = slide.scrollTop; }); + } + + function setAudienceZoom(value, announce = true) { + audienceZoom = Math.max(70, Math.min(150, Math.round(Number(value) / 5) * 5)); + applyAudienceView(); + if (announce) showToast(`Audience zoom ${audienceZoom}%`); + sendPresenterState(); + } + + function adjustAudienceZoom(delta) { setAudienceZoom(audienceZoom + Number(delta || 0)); } + + function scrollAudience(delta) { + const slide = slideWrappers[current]; + if (!slide) return; + slide.scrollBy({ top: Number(delta || 0), behavior: "smooth" }); + setTimeout(() => { audienceScrollY = slide.scrollTop; sendPresenterState(); }, 180); + } + + function resetAudienceView(announce = true) { + audienceZoom = 100; + audienceScrollY = 0; + applyAudienceView(); + if (announce) showToast("Audience view reset"); + sendPresenterState(); + } + + function setAudienceHidden(hidden, announce = true) { + audienceHidden = Boolean(hidden); + audienceHiddenOverlay.classList.toggle( + "qlearn-presenter-audience-hidden-visible", + audienceHidden + ); + + if (announce) { + showToast(audienceHidden ? "Audience hidden" : "Audience restored"); + } + + sendPresenterState(); + } + + function toggleAudienceHidden() { + if (videoModeActive) { + showToast("Pause the video before hiding the audience screen"); + return; + } + setAudienceHidden(!audienceHidden); + } + + function showToast(message) { + toast.textContent = message; + toast.classList.add("qlearn-presenter-toast-visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.classList.remove("qlearn-presenter-toast-visible"); + }, 1200); + } + + function isVisibleMedia(element) { + if (!element) return false; + const mediaStyle = window.getComputedStyle(element); + return mediaStyle.display !== "none" && mediaStyle.visibility !== "hidden"; + } + + function findPlayableVideo(slide) { + if (!slide) return null; + + const directVideos = [...slide.querySelectorAll("video")]; + const directVideo = directVideos.find(isVisibleMedia); + if (directVideo) { + return { video: directVideo, frame: null }; + } + + const videoFrames = [ + ...slide.querySelectorAll( + 'iframe[data-media-type="video"], iframe[title*="video" i], iframe[src*="media_attachments_iframe"]' + ) + ].filter(isVisibleMedia); + + for (const frame of videoFrames) { + try { + const frameDocument = frame.contentDocument || frame.contentWindow?.document; + const frameVideo = frameDocument?.querySelector("video"); + if (frameVideo) return { video: frameVideo, frame }; + } catch (error) { + // Cross-origin players cannot be controlled directly. + } + } + + return null; + } + + let activeVideo = null; + let activeVideoFrame = null; + let activeFullscreenDocument = null; + let videoModeActive = false; + let videoFullscreenActive = false; + let deliberatelyLeavingVideoFullscreen = false; + + function getFullscreenElement(doc) { + return doc?.fullscreenElement || doc?.webkitFullscreenElement || null; + } + + async function requestElementFullscreen(element) { + if (!element) return false; + try { + if (element.requestFullscreen) { + await element.requestFullscreen(); + return true; + } + if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + return true; + } + } catch (error) { + console.warn("Hudra fullscreen request failed:", error); + } + return false; + } + + async function exitDocumentFullscreen(doc) { + if (!doc || !getFullscreenElement(doc)) return; + try { + if (doc.exitFullscreen) { + await doc.exitFullscreen(); + } else if (doc.webkitExitFullscreen) { + doc.webkitExitFullscreen(); + } + } catch (error) { + console.warn("Hudra could not exit video fullscreen:", error); + } + } + + function removeVideoListeners() { + if (activeVideo) { + activeVideo.removeEventListener("ended", onActiveVideoEnded); + } + if (activeFullscreenDocument && activeFullscreenDocument !== document) { + activeFullscreenDocument.removeEventListener("fullscreenchange", onVideoFullscreenChange); + activeFullscreenDocument.removeEventListener("webkitfullscreenchange", onVideoFullscreenChange); + } + document.removeEventListener("fullscreenchange", onVideoFullscreenChange); + document.removeEventListener("webkitfullscreenchange", onVideoFullscreenChange); + } + + function clearVideoState() { + removeVideoListeners(); + activeVideo = null; + activeVideoFrame = null; + activeFullscreenDocument = null; + videoModeActive = false; + videoFullscreenActive = false; + deliberatelyLeavingVideoFullscreen = false; + } + + async function stopActiveVideo({ pause = true, toastMessage = "⏸ Video paused" } = {}) { + if (!videoModeActive) return false; + + const video = activeVideo; + const fullscreenDoc = activeFullscreenDocument; + + deliberatelyLeavingVideoFullscreen = true; + if (pause && video && !video.paused) video.pause(); + + await exitDocumentFullscreen(fullscreenDoc); + if (fullscreenDoc !== document) { + await exitDocumentFullscreen(document); + } + + clearVideoState(); + if (toastMessage) showToast(toastMessage); + return true; + } + + async function onActiveVideoEnded() { + await stopActiveVideo({ + pause: false, + toastMessage: "↩ Video finished · returning to slide" + }); + } + + function onVideoFullscreenChange() { + if (!videoModeActive || deliberatelyLeavingVideoFullscreen) return; + + const ownerFullscreen = getFullscreenElement(activeFullscreenDocument); + const parentFullscreen = getFullscreenElement(document); + + if (videoFullscreenActive && !ownerFullscreen && !parentFullscreen) { + if (activeVideo && !activeVideo.paused) activeVideo.pause(); + clearVideoState(); + showToast("⏸ Video paused · returned to slide"); + } + } + + async function startCurrentSlideVideo() { + const slide = slideWrappers[current]; + const playable = findPlayableVideo(slide); + + if (!playable) { + showToast("No controllable video on this slide"); + return false; + } + + const { video, frame } = playable; + activeVideo = video; + activeVideoFrame = frame; + activeFullscreenDocument = video.ownerDocument || document; + videoModeActive = true; + deliberatelyLeavingVideoFullscreen = false; + + video.addEventListener("ended", onActiveVideoEnded); + document.addEventListener("fullscreenchange", onVideoFullscreenChange); + document.addEventListener("webkitfullscreenchange", onVideoFullscreenChange); + if (activeFullscreenDocument !== document) { + activeFullscreenDocument.addEventListener("fullscreenchange", onVideoFullscreenChange); + activeFullscreenDocument.addEventListener("webkitfullscreenchange", onVideoFullscreenChange); + } + + try { + if (video.ended) video.currentTime = 0; + + // Prefer the actual video. If Canvas blocks that, fullscreen its iframe. + videoFullscreenActive = await requestElementFullscreen(video); + if (!videoFullscreenActive && frame) { + activeFullscreenDocument = document; + videoFullscreenActive = await requestElementFullscreen(frame); + } + + await video.play(); + showToast( + videoFullscreenActive + ? "▶ Video playing fullscreen" + : "▶ Video playing · fullscreen unavailable" + ); + return true; + } catch (error) { + console.warn("Hudra could not control this video:", error); + await stopActiveVideo({ pause: true, toastMessage: null }); + showToast("Video player could not be controlled"); + return false; + } + } + + async function toggleCurrentSlideVideo() { + if (videoModeActive) { + return stopActiveVideo({ + pause: true, + toastMessage: "⏸ Video paused · returning to slide" + }); + } + return startCurrentSlideVideo(); + } + + function makePreviewDocument(index) { + const slide = slideWrappers[index]; + if (!slide) return ""; + + const clone = slide.cloneNode(true); + clone.classList.add("qlearn-slide-active"); + clone.style.position = "relative"; + clone.style.inset = "auto"; + clone.style.width = "100%"; + clone.style.height = "100%"; + clone.style.opacity = "1"; + clone.style.visibility = "visible"; + clone.style.transform = "none"; + clone.style.overflow = "hidden"; + if (index === current) { + clone.style.zoom = `${audienceZoom}%`; + clone.dataset.hudraScrollY = String(audienceScrollY); + } + + clone.querySelectorAll("video").forEach((video) => { + const replacement = document.createElement("div"); + replacement.className = "hudra-preview-media"; + replacement.textContent = "Video"; + if (video.getAttribute("poster")) { + replacement.style.backgroundImage = `url('${video.getAttribute("poster").replace(/'/g, "%27")}')`; + } + video.replaceWith(replacement); + }); + + clone.querySelectorAll("iframe").forEach((frame) => { + const replacement = document.createElement("div"); + replacement.className = "hudra-preview-media"; + replacement.textContent = frame.title || "Embedded content"; + frame.replaceWith(replacement); + }); + + return `${clone.outerHTML}