diff --git a/details-notes/plugin.js b/details-notes/plugin.js index e76d788..4b3b342 100644 --- a/details-notes/plugin.js +++ b/details-notes/plugin.js @@ -19,7 +19,7 @@ for (let details of document.querySelectorAll("details.notes")) { create.start(details, `Notes`); } - if (Inspire.projector) { + if (Inspire.projector || document.body.classList.contains("presenter")) { // Speaker view, let's have the notes open by default details.open = true; } diff --git a/package-lock.json b/package-lock.json index 3164751..de2a886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@inspirejs/plugins", - "version": "1.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@inspirejs/plugins", - "version": "1.0.0", + "version": "3.0.1", "license": "MIT", "devDependencies": { "prettier": "^3.8.3", diff --git a/package.json b/package.json index b5a8578..0ed03e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inspirejs/plugins", - "version": "3.0.0", + "version": "3.0.1", "description": "Official plugins for Inspire.js, the lean, hackable, extensible slide deck framework", "type": "module", "main": "index.js", diff --git a/plugin-autoload.js b/plugin-autoload.js index b3d7ac9..4628ff3 100644 --- a/plugin-autoload.js +++ b/plugin-autoload.js @@ -4,7 +4,8 @@ */ export default { timer: "[data-duration]", - presenter: "details.notes", + presenter: "body:not(.experimental-presentation-api) details.notes", + presenter2: "body.experimental-presentation-api details.notes", "lazy-load": "[data-src]:not(.slide)", "slide-style": "style[data-slide]", overview: "*", diff --git a/presenter/plugin.js b/presenter/plugin.js index a4b000c..114c4ea 100644 --- a/presenter/plugin.js +++ b/presenter/plugin.js @@ -1,5 +1,5 @@ import Inspire from "@inspirejs/core"; -import { $$ } from "@inspirejs/core/util"; +import { enterPresenterView, onPresenterSlidechange } from "./presenter-ui.js"; export const hasCSS = true; @@ -22,10 +22,7 @@ Inspire.hooks.add({ window.focus(); // Switch this one to presenter view - document.body.classList.add("presenter", "show-next"); - - // Are there
elements in the current slide? Open them - $$("details.notes", Inspire.currentSlide).forEach(d => (d.open = true)); + enterPresenterView(); } }, slidechange: env => { @@ -36,48 +33,7 @@ Inspire.hooks.add({ otherWindow.Inspire.goto(env.which); } - if (Inspire.projector) { - // We are in the presenter window - $$("details.notes", Inspire.currentSlide).forEach(d => (d.open = true)); - - if (Inspire.currentSlide.matches("[data-start-time] [data-time]")) { - // This slide has a time hint, show if we're running behind - - // Scheduled start time - let startTime = Inspire.currentSlide - .closest("[data-start-time]") - ?.getAttribute("data-start-time"); - let startTimeParsed = startTime.split(":").map(n => +n); - - // Ideal offset from start time - let time = Inspire.currentSlide.dataset.time; - let timeParsed = time.split(":").map(n => +n); - - // Current local time - let currentTime = new Date().toLocaleString("en", { - timeStyle: "short", - hour12: false, - }); - let currentTimeParsed = currentTime.split(":").map(n => +n); - - // Actual offset from start time, in minutes - let actualTime = - (currentTimeParsed[0] - startTimeParsed[0]) * 60 + - (currentTimeParsed[1] - startTimeParsed[1]); - - let offset = actualTime - (timeParsed[0] * 60 + timeParsed[1]); - let offsetHours = Math.floor(Math.abs(offset / 60)) - .toString() - .padStart(2, "0"); - let offsetMinutes = (Math.abs(offset) % 60).toString().padStart(2, "0"); - - Inspire.currentSlide.dataset.offset = `${offsetHours}:${offsetMinutes}`; - - if (offset !== 0) { - Inspire.currentSlide.dataset.running = offset > 0 ? "behind" : "ahead"; - } - } - } + onPresenterSlidechange(); }, "gotoitem-end": env => { let otherWindow = Inspire.projector || Inspire.presenter; diff --git a/presenter/presenter-ui.js b/presenter/presenter-ui.js new file mode 100644 index 0000000..06585e9 --- /dev/null +++ b/presenter/presenter-ui.js @@ -0,0 +1,71 @@ +import Inspire from "@inspirejs/core"; +import { $$ } from "@inspirejs/core/util"; + +/** + * Transport-independent presenter UI, shared between the classic `presenter` + * plugin (window.open transport) and the experimental `presenter2` plugin + * (Presentation API transport). Anything here only touches the *local* + * presenter view, never the audience view, so it works regardless of how the + * two views are linked. + */ + +/** + * Turn the current window into the presenter view: show the next-slide preview, + * and open any speaker notes in the current slide. + */ +export function enterPresenterView () { + document.body.classList.add("presenter", "show-next"); + $$("details.notes", Inspire.currentSlide).forEach(d => (d.open = true)); +} + +/** + * Run on every slide change, on the presenter view only. Opens the new slide's + * speaker notes and, if the slide carries timing hints, computes how far ahead + * or behind schedule we are (surfaced via `data-offset` / `data-running`). + */ +export function onPresenterSlidechange () { + if (!document.body.classList.contains("presenter")) { + // Not the presenter view, nothing to do + return; + } + + $$("details.notes", Inspire.currentSlide).forEach(d => (d.open = true)); + + if (Inspire.currentSlide.matches("[data-start-time] [data-time]")) { + // This slide has a time hint, show if we're running behind + + // Scheduled start time + let startTime = Inspire.currentSlide + .closest("[data-start-time]") + ?.getAttribute("data-start-time"); + let startTimeParsed = startTime.split(":").map(n => +n); + + // Ideal offset from start time + let time = Inspire.currentSlide.dataset.time; + let timeParsed = time.split(":").map(n => +n); + + // Current local time + let currentTime = new Date().toLocaleString("en", { + timeStyle: "short", + hour12: false, + }); + let currentTimeParsed = currentTime.split(":").map(n => +n); + + // Actual offset from start time, in minutes + let actualTime = + (currentTimeParsed[0] - startTimeParsed[0]) * 60 + + (currentTimeParsed[1] - startTimeParsed[1]); + + let offset = actualTime - (timeParsed[0] * 60 + timeParsed[1]); + let offsetHours = Math.floor(Math.abs(offset / 60)) + .toString() + .padStart(2, "0"); + let offsetMinutes = (Math.abs(offset) % 60).toString().padStart(2, "0"); + + Inspire.currentSlide.dataset.offset = `${offsetHours}:${offsetMinutes}`; + + if (offset !== 0) { + Inspire.currentSlide.dataset.running = offset > 0 ? "behind" : "ahead"; + } + } +} diff --git a/presenter2/README.md b/presenter2/README.md new file mode 100644 index 0000000..cc26899 --- /dev/null +++ b/presenter2/README.md @@ -0,0 +1,56 @@ +# Presenter View (experimental, Presentation API) + +An experimental, opt-in alternative to the classic [`presenter`](../presenter) +plugin, built on the [W3C Presentation API](https://developer.mozilla.org/en-US/docs/Web/API/Presentation_API) +instead of `window.open()`. The presenter (controller) asks the browser to +render the deck on a **secondary attached display or Cast device**, and the two +views stay in sync by exchanging messages over a `PresentationConnection`. + +It shares all of its presenter UI (speaker notes, next-slide preview, future +delayed items, timing help) with the classic plugin, and is intended to +eventually replace it. + +## Why use it + +- Picks the secondary display through the browser's own picker — no dragging a + popup across screens, no popup blockers. +- Can target Cast / AirPlay-style remote displays, not just attached monitors. +- **Auto-reconnects when the presenter view is reloaded**, with no picker and no + extra keypress, by reattaching to the still-open audience view. + +## Enabling + +Add the `experimental-presentation-api` class to ``: + +```html + +``` + +This **disables the classic `presenter` plugin** for that deck, so the two never +run together. Without the class, the classic window.open presenter loads as +before — that's the fallback wherever the Presentation API isn't available. + +## Autoload + +Like the classic plugin, this autoloads when speaker notes +(`
`) are found in any slide **and** the +`experimental-presentation-api` class is present on ``. + +## Usage + +Enter presenter mode by pressing Ctrl + P, then pick the +display to present on. This window becomes the Presenter view; the audience view +is rendered on the chosen display. Slide and item navigation (including +`.delayed` items) is synced across the two views. + +If you reload the presenter view, it reconnects to the audience view +automatically. To exit, close/stop the presentation from the browser. + +## Requirements & limitations + +- Needs a browser that supports the Presentation API (Chromium-based) and a + **secure context** (HTTPS or `localhost`). On unsupported browsers, remove the + `experimental-presentation-api` class to fall back to the classic presenter. +- Only slide/item navigation is synced, not keyboard or mouse events — interact + with the presenter view to drive navigation; play videos / open links / run + live demos on the audience display directly. diff --git a/presenter2/plugin.css b/presenter2/plugin.css new file mode 100644 index 0000000..e2a799f --- /dev/null +++ b/presenter2/plugin.css @@ -0,0 +1,4 @@ +/* Experimental Presentation API presenter view. + Reuses the classic presenter styling (next-slide preview, dimmed delayed + items, timing badges); add experimental-only tweaks below it as needed. */ +@import url("../presenter/plugin.css"); diff --git a/presenter2/plugin.js b/presenter2/plugin.js new file mode 100644 index 0000000..91f7e0c --- /dev/null +++ b/presenter2/plugin.js @@ -0,0 +1,146 @@ +import Inspire from "@inspirejs/core"; +import { enterPresenterView, onPresenterSlidechange } from "../presenter/presenter-ui.js"; + +export const hasCSS = true; + +/** + * Experimental presenter plugin built on the W3C Presentation API instead of + * `window.open()`. The presenter (controller) asks the browser to render the + * deck on a secondary display or Cast device, and the two views stay in sync + * by exchanging serialized messages over a PresentationConnection. + * + * Opt in by adding `class="experimental-presentation-api"` to ; doing so + * also disables the classic `presenter` plugin (see plugin-autoload.js), so the + * classic window.open presenter is the fallback wherever this isn't supported. + * + * The transport-independent presenter UI (notes, next-slide preview, timing) is + * shared with the classic plugin via ../presenter/presenter-ui.js. + */ + +// sessionStorage key holding the live presentation id, used to silently +// reconnect the presenter view after a reload. +const STORAGE_KEY = "inspire-presentation-id"; + +let transport = null; // { send(message) } while connected, else null +let applyingRemote = false; // guards against echoing a remote update back + +const supported = () => "presentation" in navigator && "PresentationRequest" in window; + +/** Send a navigation message to the other view, unless we're applying one. */ +function sync (message) { + if (!applyingRemote) { + transport?.send(message); + } +} + +/** Apply a navigation message received from the other view. */ +function applyRemote (message) { + applyingRemote = true; + + try { + if (message.type === "goto") { + Inspire.goto(message.which); + } + else if (message.type === "gotoItem") { + Inspire.gotoItem(message.which); + } + } + finally { + applyingRemote = false; + } +} + +/** + * Wire up a PresentationConnection as our transport. `isController` is true on + * the presenter side (which persists the id so it can reconnect after reload). + */ +function wireConnection (connection, isController) { + transport = { + send: message => { + if (connection.state === "connected") { + connection.send(JSON.stringify(message)); + } + }, + }; + + connection.addEventListener("message", e => applyRemote(JSON.parse(e.data))); + + let drop = () => { + transport = null; + + if (isController) { + sessionStorage.removeItem(STORAGE_KEY); + document.body.classList.remove("presenter", "show-next"); + } + }; + + connection.addEventListener("close", drop); + connection.addEventListener("terminate", drop); + + if (isController) { + sessionStorage.setItem(STORAGE_KEY, connection.id); + } +} + +Inspire.hooks.add({ + "init-end": () => { + if (navigator.presentation?.receiver) { + // We are the audience view, rendered on the secondary display + document.body.classList.add("projector"); + + navigator.presentation.receiver.connectionList.then(list => { + list.connections.forEach(c => wireConnection(c, false)); + list.addEventListener("connectionavailable", e => + wireConnection(e.connection, false)); + }); + } + else if (supported() && sessionStorage.getItem(STORAGE_KEY)) { + // Presenter view was reloaded: silently reconnect to the still-open + // audience display (no picker and no user gesture required). + let id = sessionStorage.getItem(STORAGE_KEY); + + new PresentationRequest([location.href]).reconnect(id) + .then(connection => { + enterPresenterView(); + wireConnection(connection, true); + }) + .catch(() => sessionStorage.removeItem(STORAGE_KEY)); // audience gone + } + }, + keyup: env => { + // Ctrl+P : Open Presenter view + if (env.letter !== "P" || transport) { + // Not our shortcut, or we're already presenting + return; + } + + if (!supported()) { + console.warn( + "[presenter2] The Presentation API is not supported in this browser. " + + "Remove the `experimental-presentation-api` class to use the classic presenter.", + ); + return; + } + + // Ask the browser to render the deck on a secondary display. start() + // must run inside this user gesture (the keypress). + new PresentationRequest([location.href]).start() + .then(connection => { + enterPresenterView(); + wireConnection(connection, true); + window.focus(); + }) + .catch(() => {}); // picker cancelled or no display available + }, + slidechange: env => { + // Sync slide navigation. Send the slide id (stable & serializable); + // env.which may be an Element, which wouldn't survive JSON. + sync({ type: "goto", which: Inspire.currentSlide.id }); + + onPresenterSlidechange(); + }, + "gotoitem-end": env => { + // Sync slide item navigation + sync({ type: "gotoItem", which: env.which }); + }, +});