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
19 changes: 19 additions & 0 deletions packages/core/src/slideshow/parseSlideshow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,23 @@ describe("resolveSlideshow", () => {
expect(resolved.slides[0].start).toBe(1);
expect(resolved.slides[0].end).toBe(4);
});

it("parses and carries through the per-slide autoplay flag", () => {
const island = `<script type="application/hyperframes-slideshow+json">
{ "slides": [ { "sceneId": "a", "autoplay": true }, { "sceneId": "b" } ] }
</script>`;
const m = parseSlideshowManifest(island);
expect(m?.slides[0].autoplay).toBe(true);
expect(m?.slides[1].autoplay).toBeUndefined();
const { resolved } = resolveSlideshow(m!, SCENES);
expect(resolved.slides[0].autoplay).toBe(true);
expect(resolved.slides[1].autoplay).toBeUndefined();
});

it("rejects a manifest whose slide autoplay is not a boolean", () => {
const island = `<script type="application/hyperframes-slideshow+json">
{ "slides": [ { "sceneId": "a", "autoplay": "yes" } ] }
</script>`;
expect(() => parseSlideshowManifest(island)).toThrow();
});
});
15 changes: 10 additions & 5 deletions packages/core/src/slideshow/parseSlideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,21 @@ export function parseSlideshowManifest(html: string): SlideshowManifest | null {
return parsed;
}

function isOptionalNumberArray(v: unknown): boolean {
return v === undefined || (Array.isArray(v) && v.every((n) => typeof n === "number"));
}

function isOptionalBoolean(v: unknown): v is boolean | undefined {
return v === undefined || typeof v === "boolean";
}

function isSlideRef(v: unknown): v is SlideRef {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
if (typeof r["sceneId"] !== "string") return false;
if (
r["fragments"] !== undefined &&
!(Array.isArray(r["fragments"]) && r["fragments"].every((n) => typeof n === "number"))
)
return false;
if (!isOptionalNumberArray(r["fragments"])) return false;
if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false;
if (!isOptionalBoolean(r["autoplay"])) return false;
return true;
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/slideshow/slideshow.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export interface SlideRef {
notes?: string;
fragments?: number[];
hotspots?: SlideHotspot[];
/**
* When true, the slide's first `<video>` plays automatically on enter (the
* presenter lands on the slide and the clip plays). The slideshow still holds
* — it never auto-advances — so the presenter clicks Next when ready.
* Defaults to false. Use it when the video is the slide's primary content and
* its natural end is the cue to advance, not for background/ambient clips.
*/
autoplay?: boolean;
// Reserved — TTS deferred. Parsed and carried, never consumed.
ttsScript?: string;
ttsAudioUrl?: string;
Expand Down
45 changes: 45 additions & 0 deletions packages/player/src/slideshow/SlideshowController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function fakePlayer() {
play: vi.fn(() => {}),
pause: vi.fn(() => {}),
stopMedia: vi.fn(() => {}),
playSceneMedia: vi.fn((_sceneId: string) => {}),
onTimeUpdate: (fn: (t: number) => void) => {
cb = fn;
return () => {
Expand Down Expand Up @@ -683,3 +684,47 @@ describe("SlideshowController syncTo", () => {
expect(c.position.slideIndex).toBe(0);
});
});

describe("SlideshowController autoplay", () => {
// "v" autoplays; "w" does not. Both sit on the main line.
const AUTOPLAY_SHOW: ResolvedSlideshow = {
slides: [
{ sceneId: "v", start: 0, end: 5, fragments: [], hotspots: [], autoplay: true },
{ sceneId: "w", start: 5, end: 10, fragments: [], hotspots: [] },
],
sequences: {},
};

it("plays the slide's media on enter when autoplay is set", () => {
const p = fakePlayer();
new SlideshowController(p, AUTOPLAY_SHOW); // constructs on slide "v"
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
expect(p.playSceneMedia).toHaveBeenCalledTimes(1);
});

it("does not play media when entering a non-autoplay slide", () => {
const p = fakePlayer();
const c = new SlideshowController(p, AUTOPLAY_SHOW);
p.playSceneMedia.mockClear();
c.next(); // v → w (w is not autoplay)
expect(c.position.slideIndex).toBe(1);
expect(p.playSceneMedia).not.toHaveBeenCalled();
});

it("stops prior media and plays again when navigating back into an autoplay slide", () => {
const p = fakePlayer();
const c = new SlideshowController(p, AUTOPLAY_SHOW);
p.playSceneMedia.mockClear();
c.next(); // → w
expect(p.stopMedia).toHaveBeenCalled(); // leaving v stops its clip
p.playSceneMedia.mockClear();
c.prev(); // back into v (enterSlide) → replays
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
});

it("does not require autoplay support on the port (optional hook)", () => {
// A port without playSceneMedia must not throw when entering an autoplay slide.
const { playSceneMedia: _omitted, ...port } = fakePlayer();
expect(() => new SlideshowController(port, AUTOPLAY_SHOW)).not.toThrow();
});
});
9 changes: 9 additions & 0 deletions packages/player/src/slideshow/SlideshowController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface PlayerPort {
play(): void;
pause(): void;
stopMedia?(): void;
/** Play the `<video>` inside the given scene (for `autoplay` slides). */
playSceneMedia?(sceneId: string): void;
readonly currentTime: number;
onTimeUpdate(cb: (t: number) => void): () => void;
}
Expand Down Expand Up @@ -115,6 +117,13 @@ export class SlideshowController {
this.frame.fragmentIndex = -1;
this.playTo(this.restFrame(slide));
}
// Opt-in: play the slide's own clip on enter (saves a click into the
// pointer-events:none composition). We never auto-advance — the presenter
// still clicks Next. Fires from enterSlide (next / prev / goToSlide), NOT
// from resumeSlide (back / backToMain / syncTo), which restores a saved
// position; the component also skips it on the audience, which mirrors the
// presenter's media events rather than driving its own.
if (slide.autoplay) this.player.playSceneMedia?.(slide.sceneId);
this.emitChange();
}

Expand Down
99 changes: 99 additions & 0 deletions packages/player/src/slideshow/hyperframes-slideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ interface SlideNotesTarget {

type SlideshowManifest = NonNullable<ReturnType<typeof parseSlideshowManifest>>;

// Autoplay re-assert poll (see playSceneDocumentMedia): the player drives clips
// during bootstrap and on enter, so a single play() loses the race; we poll
// briefly until the clip is advancing.
const AUTOPLAY_STEP_MS = 150;
const AUTOPLAY_MAX_MS = 6000;
interface AutoplayPollState {
started: boolean;
lastTime: number;
advancingTicks: number;
waited: number;
warned: boolean;
}

type PlayerElement = HTMLElement & {
seek(t: number): void;
play(): void;
Expand Down Expand Up @@ -172,6 +185,9 @@ export class HyperframesSlideshow extends HTMLElement {
private audienceMutedPlaybackKeys = new Set<string>();
private blockedAudienceMedia = new Map<string, PresenterMediaMessage>();
private audienceMediaUnlockButton: HTMLButtonElement | null = null;
// Bumped whenever autoplay starts or media is stopped (slide change), so a
// pending re-assert from a previous autoplay can't replay a clip we've left.
private autoplayToken = 0;

/** Whether audio is currently muted. Reflects `data-hf-muted` attribute. */
get muted(): boolean {
Expand Down Expand Up @@ -232,6 +248,7 @@ export class HyperframesSlideshow extends HTMLElement {
disconnectedCallback(): void {
this.disconnected = true;
this.initGeneration += 1;
this.autoplayToken++; // cancel any in-flight autoplay re-assert loop
if (this.initTimer !== null) {
clearTimeout(this.initTimer);
this.initTimer = null;
Expand Down Expand Up @@ -381,6 +398,7 @@ export class HyperframesSlideshow extends HTMLElement {
playerEl.stopMedia?.();
this.stopDocumentMedia();
},
playSceneMedia: (sceneId) => this.playSceneDocumentMedia(sceneId),
get currentTime() {
return playerEl.currentTime;
},
Expand Down Expand Up @@ -1023,12 +1041,93 @@ export class HyperframesSlideshow extends HTMLElement {
}

private stopDocumentMedia(): void {
// Invalidate any in-flight autoplay re-assert so leaving a slide can't be
// undone by a pending timeout replaying the clip we just paused.
this.autoplayToken++;
const doc = this.ownerDocument;
for (const el of doc.querySelectorAll("video, audio")) {
if (el instanceof HTMLMediaElement) el.pause();
}
}

/**
* Play the `<video>` inside a given scene from its start — the runtime side of
* a slide's `autoplay`. Reaches into the same-origin composition iframe (which
* is pointer-events:none, so its own controls can't be clicked). The play()
* fires a "play" event that wireSlideshowMedia() mirrors to any audience
* window, so this runs on the presenter only — the audience drives its copy
* from those mirrored events, never on its own.
*
* Robust against two timing hazards: (1) the clip may not be in the iframe DOM
* yet at construction (first slide), and (2) the player drives clips during
* bootstrap and seeks the timeline on enter — both pause the clip (and reject
* an in-flight play() with AbortError), so a single play() loses the race. So
* we poll on a short timer: locate the clip, then assert play() until it is
* actually advancing across two ticks, then stop — leaving a later real user
* pause (presenter media controls) alone. A user gesture within the window
* (real browsers gate autoplay on one) lets the next tick's play() take.
* Token-guarded, so leaving the slide or disconnecting cancels it.
*/
private playSceneDocumentMedia(sceneId: string): void {
if (this.resolveMode() === "audience") return;
const safeId = sceneId.replace(/["\\]/g, "\\$&");
const token = ++this.autoplayToken;
const state: AutoplayPollState = {
started: false,
lastTime: -1,
advancingTicks: 0,
waited: 0,
warned: false,
};
const tick = (): void => {
if (token !== this.autoplayToken) return; // left the slide / disconnected
const done = this.stepAutoplay(safeId, state);
state.waited += AUTOPLAY_STEP_MS;
if (!done && state.waited <= AUTOPLAY_MAX_MS) window.setTimeout(tick, AUTOPLAY_STEP_MS);
};
tick();
}

/** Locate the scene's clip in the composition iframe(s). */
private findSceneVideo(safeId: string): HTMLVideoElement | null {
for (const player of this.mediaPlayerElements()) {
const doc = this.playerFrameDocument(player);
const video = doc?.querySelector(`[data-composition-id="${safeId}"] video`) ?? null;
if (video instanceof HTMLVideoElement) return video;
}
return null;
}

/** One autoplay poll step. Returns true once the clip is confirmed playing. */
private stepAutoplay(safeId: string, state: AutoplayPollState): boolean {
const video = this.findSceneVideo(safeId);
if (!video) return false;
if (!state.started) {
state.started = true;
video.muted = this._muted || video.defaultMuted;
try {
video.currentTime = 0;
} catch {
// not seekable yet — play from wherever it is
}
}
const advancing = !video.paused && video.currentTime > state.lastTime;
state.lastTime = video.currentTime;
if (advancing) return ++state.advancingTicks >= 2; // confirmed playing — stop polling
state.advancingTicks = 0;
void video.play().catch((err: unknown) => {
// Expected during the poll: AbortError (a timeline-sync seek interrupts
// the play) and NotAllowedError (autoplay gated on a user gesture). Surface
// anything else once — a real failure (bad src, decode) shouldn't be silent.
const name = err instanceof DOMException ? err.name : "";
if (name !== "AbortError" && name !== "NotAllowedError" && !state.warned) {
state.warned = true;
console.warn("[hyperframes-slideshow] autoplay play() failed:", err);
}
});
return false;
}

private presenterNotesDeckKey(): string {
const explicit = this.getAttribute("notes-storage-key")?.trim();
if (explicit) return explicit;
Expand Down
14 changes: 14 additions & 0 deletions skills/slideshow/references/standalone-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@ For public or user-facing generated projects, make this wrapper the root `index.

`interactive` is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction.

### Per-slide `autoplay`

Add `"autoplay": true` to a slide in the island to play that slide's first `<video>` from the start when the presenter lands on it. The slideshow still holds — it never auto-advances — so the presenter clicks Next when ready; autoplay only saves a manual play click into the composition.

```json
{
"sceneId": "cold-open",
"autoplay": true,
"notes": "Promo plays on enter; click Next when it ends."
}
```

Use `autoplay` when the video **is** the slide's primary content and its natural end is the cue to advance — a cold-open promo, a demo clip you let run and then move on from. Do **not** use it for background/ambient loops or for footage the presenter talks over; those should start on the presenter's own cue (a click on the clip's controls via `interactive`), not automatically. One autoplay clip per slide (the first `<video>` in the scene).

### Presenter media bridge for interactive media

Presenter/audience mode syncs slide position through a deck-scoped `BroadcastChannel`. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state.
Expand Down
Loading