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 });
+ },
+});