Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
73 changes: 73 additions & 0 deletions packages/player/src/slideshow/hyperframes-slideshow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2179,3 +2179,76 @@ describe("<hyperframes-slideshow> Fix 4 — back affordance (postMessage only; c
el.remove();
});
});

// ---------------------------------------------------------------------------
// Mechanical `interactive` attribute on inner <hyperframes-player>
// ---------------------------------------------------------------------------
// The slideshow auto-applies the `interactive` attribute to every inner
// <hyperframes-player>, so clickable controls, links, native media controls,
// and custom players inside the composition iframe receive pointer events
// without the author having to remember the attribute. The player's default
// is `pointer-events: none` on the iframe; `interactive` flips it to `auto`
// via the `:host([interactive])` rule in player styles.
// ---------------------------------------------------------------------------
describe("<hyperframes-slideshow> auto-sets `interactive` on inner <hyperframes-player>", () => {
beforeEach(async () => {
await import("./hyperframes-slideshow.js");
});

const tick = () => new Promise<void>((r) => setTimeout(r, 0));

it("inner <hyperframes-player> gets `interactive` attribute after mount", async () => {
const el = document.createElement("hyperframes-slideshow");
const player = document.createElement("hyperframes-player");
el.appendChild(player);
document.body.appendChild(el);

// Allow the deferred initTimer macrotask to run.
await tick();

expect(player.hasAttribute("interactive")).toBe(true);
expect(player.getAttribute("interactive")).toBe("");

el.remove();
});

it("preserves any author-supplied `interactive` attribute value verbatim", async () => {
const el = document.createElement("hyperframes-slideshow");
const player = document.createElement("hyperframes-player");
// Preserve any author-supplied `interactive` value verbatim. Note: the
// CSS rule `:host([interactive])` is presence-based per HTML
// boolean-attribute convention, so the runtime behavior is identical
// regardless of the value — the attribute always enables pointer
// events. The preservation guarantee here is about DOM hygiene
// (idempotent mechanical wire-up, no clobber on re-runs), not a
// runtime opt-out — `interactive="false"` is NOT an opt-out.
player.setAttribute("interactive", "false");
el.appendChild(player);
document.body.appendChild(el);

await tick();

expect(player.getAttribute("interactive")).toBe("false");

el.remove();
});

it("dynamically-inserted <hyperframes-player> children also get `interactive`", async () => {
const el = document.createElement("hyperframes-slideshow");
document.body.appendChild(el);

await tick();

// Late insertion — picked up by the MutationObserver.
const player = document.createElement("hyperframes-player");
el.appendChild(player);

// MutationObserver callbacks deliver on a microtask; flush twice to be safe.
await tick();
await tick();

expect(player.hasAttribute("interactive")).toBe(true);

el.remove();
});
});
41 changes: 40 additions & 1 deletion packages/player/src/slideshow/hyperframes-slideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export class HyperframesSlideshow extends HTMLElement {
private initGeneration = 0;
private _muted = false;
private mediaWireInterval: ReturnType<typeof setInterval> | null = null;
private playerObserver: MutationObserver | null = null;
private applyingRemoteMedia = false;
private lastMediaTimeBroadcastMs = 0;
private audienceMutedPlaybackKeys = new Set<string>();
Expand Down Expand Up @@ -215,6 +216,7 @@ export class HyperframesSlideshow extends HTMLElement {
window.addEventListener("message", this.onMessage);
document.addEventListener("fullscreenchange", this.onFsChange);
this.initChannel();
this.observeInteractivePlayers();
// Defer player-dependent init to a macrotask so that child elements are
// parsed before we query for <hyperframes-player>. This matters when the
// bundle is loaded synchronously (e.g. <script src> in <head>), where
Expand All @@ -225,7 +227,10 @@ export class HyperframesSlideshow extends HTMLElement {
// setTimeout(0) macrotask yields to the parser so the children land first.
this.initTimer = setTimeout(() => {
this.initTimer = null;
if (this.isConnected && !this.disconnected) void this.init();
if (this.isConnected && !this.disconnected) {
this.ensureInteractivePlayers();
void this.init();
}
}, 0);
}

Expand All @@ -252,6 +257,10 @@ export class HyperframesSlideshow extends HTMLElement {
clearInterval(this.mediaWireInterval);
this.mediaWireInterval = null;
}
if (this.playerObserver !== null) {
this.playerObserver.disconnect();
this.playerObserver = null;
}
this.audienceMediaUnlockButton?.remove();
this.audienceMediaUnlockButton = null;
this.audienceMutedPlaybackKeys.clear();
Expand Down Expand Up @@ -490,6 +499,36 @@ export class HyperframesSlideshow extends HTMLElement {
);
}

/**
* Inner `<hyperframes-player>` instances inside a slideshow need the
* `interactive` attribute so clickable controls, links, native media
* controls, and custom players inside the composition iframe receive
* pointer events (the player's default is `pointer-events: none`).
*
* Set it mechanically so authors / agents don't have to remember.
* Idempotent: if the host already declared `interactive` (any value,
* including `interactive="false"`), it is preserved.
*/
private ensureInteractivePlayers(): void {
for (const player of this.querySelectorAll("hyperframes-player")) {
if (!player.hasAttribute("interactive")) {
player.setAttribute("interactive", "");
}
}
}

/**
* Watch for `<hyperframes-player>` children added after the initial mount
* (dynamic templating, hydration, drag-drop authoring) and apply the
* `interactive` attribute to those too.
*/
private observeInteractivePlayers(): void {
if (typeof MutationObserver === "undefined") return;
if (this.playerObserver !== null) return;
this.playerObserver = new MutationObserver(() => this.ensureInteractivePlayers());
this.playerObserver.observe(this, { childList: true, subtree: true });
}

private playerFrameDocument(player: Partial<PlayerElement> & HTMLElement): Document | null {
const frame = player.iframeElement;
if (!(frame instanceof HTMLIFrameElement)) return null;
Expand Down
4 changes: 2 additions & 2 deletions skills/slideshow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,13 +401,13 @@ Wrap the composition in `<hyperframes-slideshow>` around `<hyperframes-player>`

```html
<hyperframes-slideshow>
<hyperframes-player interactive src="deck.html"></hyperframes-player>
<hyperframes-player src="deck.html"></hyperframes-player>
</hyperframes-slideshow>
```

`<hyperframes-slideshow>` provides the navigation chrome (Present, Prev / Next, counter, global mute when `sound` is present, fullscreen), keyboard handling (← / →, Space / Backspace, and P for Present), touch swipe, and hotspot overlays.

Use the `interactive` attribute whenever the source page contains clickable controls, links, native media controls, or custom players. Without it, `<hyperframes-player>` intentionally blocks iframe pointer events; media controls inside the composition cannot be clicked, and clicks on the player host can toggle timeline playback instead of interacting with the slide content.
The slideshow automatically sets the `interactive` attribute on every inner `<hyperframes-player>` at mount time, so clickable controls, links, native media controls, and custom players inside the composition iframe receive pointer events as expected. (Outside a slideshow wrapper, you must add `interactive` manually on `<hyperframes-player>` — the player defaults to `pointer-events: none` on the iframe so clicks on the player host don't get hijacked into toggling timeline playback.)

**Presenter mode:** use the built-in Present icon button in the slideshow nav capsule, or press P. It calls `window.open('?mode=audience')` for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via `BroadcastChannel('hf-slideshow:' + location.pathname)`. Do not add a custom wrapper-level Present button; the shared component owns its placement, icon, styling, and audience-mode hiding.

Expand Down
Loading