510 lines
15 KiB
JavaScript
510 lines
15 KiB
JavaScript
const sessions = new Map();
|
||
const tabToSession = new Map();
|
||
const windowToSession = new Map();
|
||
const previousWindowStates = new Map();
|
||
const sessionStorageKey = "hudraActiveSessions";
|
||
let persistStatePromise = Promise.resolve();
|
||
|
||
function rebuildSessionIndexes() {
|
||
tabToSession.clear();
|
||
windowToSession.clear();
|
||
|
||
sessions.forEach((session) => {
|
||
if (session.audienceTabId != null) {
|
||
tabToSession.set(session.audienceTabId, session.id);
|
||
}
|
||
if (session.audienceWindowId != null) {
|
||
windowToSession.set(session.audienceWindowId, session.id);
|
||
}
|
||
if (session.controlWindowId != null) {
|
||
windowToSession.set(session.controlWindowId, session.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function restoreSessionState() {
|
||
const stored = await chrome.storage.session.get(sessionStorageKey);
|
||
const state = stored[sessionStorageKey];
|
||
if (!state) return;
|
||
|
||
for (const storedSession of state.sessions || []) {
|
||
if (!storedSession?.id) continue;
|
||
sessions.set(storedSession.id, {
|
||
...storedSession,
|
||
pageTitle: "Hudra",
|
||
current: 0,
|
||
count: 0,
|
||
audienceHidden: false,
|
||
audienceZoom: 100,
|
||
audienceScrollY: 0,
|
||
currentPreview: "",
|
||
nextPreview: ""
|
||
});
|
||
}
|
||
|
||
for (const [windowId, windowState] of state.previousWindowStates || []) {
|
||
previousWindowStates.set(Number(windowId), windowState);
|
||
}
|
||
|
||
rebuildSessionIndexes();
|
||
}
|
||
|
||
const sessionStateReady = restoreSessionState().catch((error) => {
|
||
console.warn("Hudra could not restore its active sessions:", error);
|
||
});
|
||
|
||
async function ensureSessionStateReady() {
|
||
await sessionStateReady;
|
||
}
|
||
|
||
function persistSessionState() {
|
||
const state = {
|
||
sessions: [...sessions.values()].map((session) => ({
|
||
id: session.id,
|
||
mode: session.mode,
|
||
sourceTabId: session.sourceTabId,
|
||
audienceTabId: session.audienceTabId,
|
||
audienceWindowId: session.audienceWindowId,
|
||
controlWindowId: session.controlWindowId
|
||
})),
|
||
previousWindowStates: [...previousWindowStates.entries()]
|
||
};
|
||
|
||
persistStatePromise = persistStatePromise
|
||
.catch(() => {})
|
||
.then(() => chrome.storage.session.set({ [sessionStorageKey]: state }));
|
||
|
||
return persistStatePromise;
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
function isSupportedLessonUrl(value) {
|
||
try {
|
||
const url = new URL(value);
|
||
const hostname = url.hostname.toLowerCase();
|
||
return url.protocol === "https:" && (
|
||
hostname === "qlearn.eq.edu.au" ||
|
||
hostname.endsWith(".qlearn.eq.edu.au") ||
|
||
hostname.endsWith(".instructure.com")
|
||
);
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function showPageNotice(tabId, message) {
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
files: ["page-detection.js"]
|
||
});
|
||
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: (noticeMessage) => window.__hudraPageDetector.showNotice(noticeMessage),
|
||
args: [message]
|
||
});
|
||
}
|
||
|
||
async function inspectLessonPage(tabId) {
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
files: ["page-detection.js"]
|
||
});
|
||
|
||
const results = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: () => window.__hudraPageDetector.inspect()
|
||
});
|
||
|
||
return results[0]?.result || { supported: false, reason: "INSPECTION_FAILED" };
|
||
}
|
||
|
||
function messageForUnsupportedPage(reason) {
|
||
if (reason === "NO_LESSON_TITLE") {
|
||
return "Hudra couldn’t start because this lesson has no H1 title.";
|
||
}
|
||
if (reason === "EMPTY_LESSON") {
|
||
return "Hudra couldn’t start because this lesson has no presentable content.";
|
||
}
|
||
return "Hudra couldn’t start because this page does not contain a supported lesson.";
|
||
}
|
||
|
||
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: ["page-detection.js", "presenter.js"]
|
||
});
|
||
|
||
await chrome.tabs.sendMessage(tabId, {
|
||
type: "QLEARN_PRESENTER_START",
|
||
config
|
||
});
|
||
}
|
||
|
||
async function startSingleScreen(tab) {
|
||
await ensureSessionStateReady();
|
||
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 persistSessionState();
|
||
|
||
await injectPresenter(tab.id, { mode: "single", sessionId });
|
||
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
|
||
}
|
||
|
||
async function startDualScreen(tab, primary, secondary) {
|
||
await ensureSessionStateReady();
|
||
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 persistSessionState();
|
||
|
||
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") {
|
||
await ensureSessionStateReady();
|
||
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 persistSessionState();
|
||
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
|
||
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
|
||
});
|
||
} else {
|
||
await persistSessionState();
|
||
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) {
|
||
await ensureSessionStateReady();
|
||
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) {
|
||
await ensureSessionStateReady();
|
||
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;
|
||
}
|
||
|
||
if (!isSupportedLessonUrl(tab.url)) {
|
||
await showPageNotice(
|
||
tab.id,
|
||
"Hudra works on QLearn and Canvas lesson pages hosted by Instructure."
|
||
).catch(() => {});
|
||
return;
|
||
}
|
||
|
||
let inspection;
|
||
try {
|
||
inspection = await inspectLessonPage(tab.id);
|
||
} catch (error) {
|
||
await showPageNotice(
|
||
tab.id,
|
||
"Hudra couldn’t inspect this page. Wait for it to finish loading and try again."
|
||
).catch(() => {});
|
||
return;
|
||
}
|
||
|
||
if (!inspection.supported) {
|
||
await showPageNotice(tab.id, messageForUnsupportedPage(inspection.reason)).catch(() => {});
|
||
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") {
|
||
ensureSessionStateReady().then(() => {
|
||
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") {
|
||
ensureSessionStateReady().then(() => {
|
||
const session = sessions.get(message.sessionId);
|
||
if (session?.audienceTabId != null) {
|
||
chrome.tabs.sendMessage(session.audienceTabId, {
|
||
type: "QLEARN_PRESENTER_COMMAND",
|
||
command: "reportState"
|
||
}).catch(() => {});
|
||
}
|
||
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) => {
|
||
ensureSessionStateReady().then(() => {
|
||
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(() => {});
|
||
}
|
||
});
|
||
});
|