Files
hudra/presenter.js
2026-07-26 16:02:28 +10:00

1371 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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 detectedPage = window.__hudraPageDetector?.resolve();
if (!detectedPage?.supported) {
window.__hudraPageDetector?.showNotice(
"Hudra couldnt start because this page does not contain a supported lesson."
);
return;
}
const { contentRoot, pageTitleElement } = detectedPage;
let pageTitle = pageTitleElement?.textContent?.trim() || "";
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();
}
}
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 = `<div>Audience Hidden</div>`;
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 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);
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 = `
<button type="button" data-action="prev">← Previous</button>
<span data-role="counter"></span>
<button type="button" data-action="next">Next →</button>
<button type="button" data-action="exit">Exit</button>
`;
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 `<!doctype html><html><head><meta charset="utf-8"><base href="${location.href.replace(/"/g, "&quot;")}"><style>
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff;}
body{font-family:Arial,sans-serif;}
.qlearn-presenter-slide{box-sizing:border-box;width:100%;height:100%;padding:7vh 6vw 9vh;background:white;color:#222;font-size:clamp(14px,2vw,30px);line-height:1.38;overflow:hidden;}
h2{margin:0 0 .65em;font-size:clamp(25px,5vw,66px);line-height:1.06;}
h3{margin:0 0 .65em;font-size:clamp(22px,4vw,52px);line-height:1.1;}
h4{font-size:clamp(18px,3vw,38px);}
p{margin-top:0;margin-bottom:.75em;} ul,ol{margin-top:.3em;margin-bottom:.7em;padding-left:1.4em;}
img,video{max-width:100%;max-height:56vh;width:auto;height:auto;object-fit:contain;}
.qlearn-presenter-slide.qlp-image-only{padding:0;position:relative;}
.qlearn-presenter-slide.qlp-image-only .qlearn-presenter-slide-content{width:100%;height:100%;}
.qlearn-presenter-slide.qlp-image-only img{position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none;object-fit:contain;}
iframe{max-width:100%;height:54vh;border:0;} .hudra-preview-media{display:grid;place-items:center;min-height:34vh;border-radius:14px;background:#111722 center/cover no-repeat;color:#d7ddeb;font:700 clamp(16px,2vw,28px) system-ui;} table{width:100%;border-collapse:collapse;font-size:.78em;} td,th{padding:.3em;} pre{overflow:hidden;font-size:.7em;}
.qlp-continuation-title::after{content:" · continued";font-size:.42em;font-weight:500;opacity:.62;}
</style></head><body>${clone.outerHTML}<script>const s=document.querySelector('[data-hudra-scroll-y]');if(s)s.scrollTop=Number(s.dataset.hudraScrollY)||0;<\/script></body></html>`;
}
function sendPresenterState() {
chrome.runtime.sendMessage({
type: "QLEARN_PRESENTER_STATE",
sessionId,
current,
count: slideWrappers.length,
pageTitle,
audienceHidden,
audienceZoom,
audienceScrollY,
currentPreview: makePreviewDocument(current),
nextPreview: makePreviewDocument(current + 1)
}).catch(() => {});
}
function showSlide(index) {
if (videoModeActive) {
stopActiveVideo({ pause: true, toastMessage: null }).catch(() => {});
}
current = Math.max(0, Math.min(index, slideWrappers.length - 1));
slideWrappers.forEach((wrapper, wrapperIndex) => {
wrapper.classList.toggle(
"qlearn-slide-active",
wrapperIndex === current
);
});
presentationLayer.classList.toggle(
"qlp-showing-image-only",
slideWrappers[current]?.classList.contains("qlp-image-only")
);
audienceZoom = 100;
audienceScrollY = 0;
slideWrappers.forEach((wrapper) => { wrapper.style.zoom = "100%"; wrapper.scrollTop = 0; });
applyAudienceView();
counter.textContent = `Slide ${current + 1} of ${slideWrappers.length}`;
previousButton.disabled = current === 0;
nextButton.disabled = current === slideWrappers.length - 1;
sendPresenterState();
}
function exit(notifyBackground = true) {
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", refitAfterResize);
clearTimeout(resizeTimer);
clearTimeout(toastTimer);
clearTimeout(gameTimer);
if (activeVideo && !activeVideo.paused) activeVideo.pause();
if (videoModeActive) {
deliberatelyLeavingVideoFullscreen = true;
exitDocumentFullscreen(activeFullscreenDocument).catch(() => {});
clearVideoState();
}
presentationLayer.remove();
controls.remove();
style.remove();
document.body.classList.remove("qlearn-presenting", "qlearn-presenter-audience");
delete window.__qlearnPresenter;
if (notifyBackground) {
chrome.runtime.sendMessage({
type: "QLEARN_PRESENTER_ENDED",
sessionId
}).catch(() => {});
}
}
function onKey(event) {
const activeElement = document.activeElement;
const isTyping =
activeElement &&
["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName);
if (isTyping) return;
if (event.key.toLowerCase() === "b") {
event.preventDefault();
event.stopPropagation();
if (!event.repeat) toggleAudienceHidden();
return;
}
if (event.key === "Tab") {
event.preventDefault();
event.stopPropagation();
if (!event.repeat) toggleCurrentSlideVideo();
return;
}
if (["ArrowRight", "PageDown", " ", "Enter"].includes(event.key)) {
event.preventDefault();
if (videoModeActive) {
if (!event.repeat) showToast("Pause the video before changing slides");
return;
}
if (current < slideWrappers.length - 1) showSlide(current + 1);
return;
}
if (["ArrowLeft", "PageUp", "Backspace"].includes(event.key)) {
event.preventDefault();
if (videoModeActive) {
if (!event.repeat) showToast("Pause the video before changing slides");
return;
}
if (current > 0) showSlide(current - 1);
return;
}
if (event.key === "Home") {
event.preventDefault();
showSlide(0);
return;
}
if (event.key === "End") {
event.preventDefault();
showSlide(slideWrappers.length - 1);
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (videoModeActive) {
stopActiveVideo({
pause: true,
toastMessage: "⏸ Video paused · returned to slide"
}).catch(() => {});
} else {
exit();
}
}
}
controls.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "next" && current < slideWrappers.length - 1) {
if (videoModeActive) {
showToast("Pause the video before changing slides");
} else {
showSlide(current + 1);
}
return;
}
if (action === "prev" && current > 0) {
if (videoModeActive) {
showToast("Pause the video before changing slides");
} else {
showSlide(current - 1);
}
return;
}
if (action === "exit") exit();
});
document.body.classList.add("qlearn-presenting");
if (presenterMode === "audience") {
document.body.classList.add("qlearn-presenter-audience");
}
document.addEventListener("keydown", onKey);
const gameTimer = setTimeout(() => {
gameCredit.classList.add("qlearn-presenter-game-hidden");
setTimeout(() => gameCredit.remove(), 800);
}, 5000);
window.__qlearnPresenter = {
exit,
next() {
if (videoModeActive) {
showToast("Pause the video before changing slides");
} else if (current < slideWrappers.length - 1) {
showSlide(current + 1);
}
},
previous() {
if (videoModeActive) {
showToast("Pause the video before changing slides");
} else if (current > 0) {
showSlide(current - 1);
}
},
first() {
if (videoModeActive) showToast("Pause the video before changing slides");
else showSlide(0);
},
last() {
if (videoModeActive) showToast("Pause the video before changing slides");
else showSlide(slideWrappers.length - 1);
},
toggleVideo() {
return toggleCurrentSlideVideo();
},
toggleAudienceHidden() {
toggleAudienceHidden();
},
zoomIn() { adjustAudienceZoom(5); },
zoomOut() { adjustAudienceZoom(-5); },
setZoom(value) { setAudienceZoom(value); },
scrollUp() { scrollAudience(-140); },
scrollDown() { scrollAudience(140); },
scrollBy(value) { scrollAudience(value); },
resetView() { resetAudienceView(); },
goTo(index) {
const slideNumber = Number(index);
if (Number.isFinite(slideNumber)) showSlide(slideNumber);
},
getCurrentSlide() {
return current + 1;
},
getSlideCount() {
return slideWrappers.length;
},
reportState() {
sendPresenterState();
}
};
showSlide(0);
}
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === "QLEARN_PRESENTER_START") {
startPresenter(message.config || {});
return;
}
if (message?.type === "QLEARN_PRESENTER_COMMAND" && window.__qlearnPresenter) {
const command = message.command;
if (command === "next") window.__qlearnPresenter.next();
if (command === "previous") window.__qlearnPresenter.previous();
if (command === "first") window.__qlearnPresenter.first();
if (command === "last") window.__qlearnPresenter.last();
if (command === "toggleVideo") window.__qlearnPresenter.toggleVideo();
if (command === "toggleAudienceHidden") window.__qlearnPresenter.toggleAudienceHidden();
if (command === "zoomIn") window.__qlearnPresenter.zoomIn();
if (command === "zoomOut") window.__qlearnPresenter.zoomOut();
if (command === "setZoom") window.__qlearnPresenter.setZoom(message.value);
if (command === "scrollUp") window.__qlearnPresenter.scrollUp();
if (command === "scrollDown") window.__qlearnPresenter.scrollDown();
if (command === "scrollBy") window.__qlearnPresenter.scrollBy(message.value);
if (command === "resetView") window.__qlearnPresenter.resetView();
if (command === "goTo") window.__qlearnPresenter.goTo(message.value);
if (command === "reportState") window.__qlearnPresenter.reportState();
if (command === "exit") window.__qlearnPresenter.exit(false);
}
});
})();