commit da12e5c60eea84a958a08eb864e0e2c1407f13c7 Author: Kaden Napper Date: Tue Jul 21 16:10:41 2026 +1000 Initial release - Hudra 0.9.1 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}