-
Notifications
You must be signed in to change notification settings - Fork 0
Add experimental Presentation API presenter plugin #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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"; | ||
| } | ||
| } | ||
| } |
| 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. |
| 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"); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <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); | ||
| 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.", | ||
| ); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: cancelled picker leaves presenter view stuck
The same gap exists on disconnect: Suggested fix — move // keyup handler
new PresentationRequest([location.href]).start()
.then(connection => {
enterPresenterView();
wireConnection(connection, true);
window.focus();
})
.catch(() => {}); // picker cancelled — no UI change needed nowFor let drop = () => {
transport = null;
if (isController) {
sessionStorage.removeItem(STORAGE_KEY);
document.body.classList.remove("presenter", "show-next");
}
}; |
||
| 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 }); | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
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
forloop, no hooks). At that point, neitherInspire.projectornor thepresenterbody class is set — both are added later by user interaction (Ctrl+P) or by theinit-endhook (reconnect path).The original
Inspire.projectorcheck 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 byenterPresenterView()andonPresenterSlidechange()in the presenter plugins, so this line is redundant.Worth either removing the whole
ifblock (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.