Files
hudra/background.js
T
2026-07-21 16:10:41 +10:00

340 lines
10 KiB
JavaScript

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(() => {});
}
});