Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion details-notes/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ for (let details of document.querySelectorAll("details.notes")) {
create.start(details, `<summary>Notes</summary>`);
}

if (Inspire.projector) {
if (Inspire.projector || document.body.classList.contains("presenter")) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely dead code — this check can never be true at evaluation time

This module runs at import time (top-level for loop, no hooks). At that point, neither Inspire.projector nor the presenter body class is set — both are added later by user interaction (Ctrl+P) or by the init-end hook (reconnect path).

The original Inspire.projector check had the same problem, so this isn't a regression — but adding a second never-true condition makes it look intentional. In practice, notes are opened by enterPresenterView() and onPresenterSlidechange() in the presenter plugins, so this line is redundant.

Worth either removing the whole if block (since it's always false) or, if the intent is to support late-loaded plugins where the class might already be set, adding a comment explaining the scenario.

// Speaker view, let's have the notes open by default
details.open = true;
}
Expand Down
3 changes: 2 additions & 1 deletion plugin-autoload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*",
Expand Down
50 changes: 3 additions & 47 deletions presenter/plugin.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,10 +22,7 @@ Inspire.hooks.add({
window.focus();

// Switch this one to presenter view
document.body.classList.add("presenter", "show-next");

// Are there <details class="notes"> elements in the current slide? Open them
$$("details.notes", Inspire.currentSlide).forEach(d => (d.open = true));
enterPresenterView();
}
},
slidechange: env => {
Expand All @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions presenter/presenter-ui.js
Original file line number Diff line number Diff line change
@@ -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";
}
}
}
56 changes: 56 additions & 0 deletions presenter2/README.md
Original file line number Diff line number Diff line change
@@ -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 `<body>`:

```html
<body class="experimental-presentation-api">
```

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
(`<details class="notes">`) are found in any slide **and** the
`experimental-presentation-api` class is present on `<body>`.

## Usage

Enter presenter mode by pressing <kbd>Ctrl</kbd> + <kbd>P</kbd>, 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.
4 changes: 4 additions & 0 deletions presenter2/plugin.css
Original file line number Diff line number Diff line change
@@ -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");
147 changes: 147 additions & 0 deletions presenter2/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 <body>; 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);
}
};

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.",
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: cancelled picker leaves presenter view stuck

enterPresenterView() runs on line 115 before start() resolves. If the user cancels the display picker (or no display is available), the .catch(() => {}) on line 122 silently swallows the rejection — but the body already has presenter + show-next classes and notes are open, with no audience view and no way to revert short of reloading.

The same gap exists on disconnect: drop() (line 72) nulls transport and clears sessionStorage, but never reverts the presenter UI.

Suggested fix — move enterPresenterView() into the .then() (like the reconnect path already does correctly on line 100), and add a leavePresenterView() counterpart called from drop() when isController:

// keyup handler
new PresentationRequest([location.href]).start()
    .then(connection => {
        enterPresenterView();
        wireConnection(connection, true);
        window.focus();
    })
    .catch(() => {}); // picker cancelled — no UI change needed now

For drop(), something like:

let drop = () => {
    transport = null;
    if (isController) {
        sessionStorage.removeItem(STORAGE_KEY);
        document.body.classList.remove("presenter", "show-next");
    }
};

return;
}

// Switch this window to presenter view
enterPresenterView();

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