feat(player): slideshow controller + <hyperframes-slideshow> component (#1581)

DOM-free SlideshowController (discrete nav, fragment holds, branch stack)
driving the existing player; <hyperframes-slideshow> web component with a
unified mute+nav capsule (conditional prev/next), floating hotspot overlays,
presenter mode (BroadcastChannel), keyboard/touch, and a scenes getter fed
via the runtime message handler.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-19 01:31:27 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a38fc4e778
commit 7af3eb8f80
13 changed files with 3247 additions and 11 deletions
+13 -10
View File
@@ -22,7 +22,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -54,7 +54,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.6.112",
"version": "0.6.113",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -101,7 +101,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
@@ -135,7 +135,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -153,7 +153,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -173,7 +173,10 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@hyperframes/core": "workspace:*",
},
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -185,7 +188,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -226,7 +229,7 @@
},
"packages/sdk": {
"name": "@hyperframes/sdk",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@hyperframes/core": "workspace:*",
"linkedom": "^0.18.12",
@@ -251,7 +254,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -263,7 +266,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.6.112",
"version": "0.6.113",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
+9
View File
@@ -19,6 +19,12 @@
"script": "./dist/hyperframes-player.global.js",
"import": "./dist/hyperframes-player.js",
"require": "./dist/hyperframes-player.cjs"
},
"./slideshow": {
"types": "./dist/slideshow/hyperframes-slideshow.d.ts",
"script": "./dist/slideshow/hyperframes-slideshow.global.js",
"import": "./dist/slideshow/hyperframes-slideshow.js",
"require": "./dist/slideshow/hyperframes-slideshow.cjs"
}
},
"scripts": {
@@ -27,6 +33,9 @@
"test": "vitest run",
"perf": "bun run tests/perf/index.ts"
},
"dependencies": {
"@hyperframes/core": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
+10
View File
@@ -89,6 +89,7 @@ class HyperframesPlayer extends HTMLElement {
private _directTimelineClock: DirectTimelineClock;
private _parentTickRaf: number | null = null;
private _media: ParentMediaManager;
private _scenes: { id: string; start: number; duration: number }[] = [];
constructor() {
super();
@@ -261,6 +262,12 @@ class HyperframesPlayer extends HTMLElement {
return this.iframe;
}
/** Scene list from the last-received runtime timeline message. Empty until
* the composition runtime fires its first "timeline" postMessage. */
get scenes(): { id: string; start: number; duration: number }[] {
return this._scenes;
}
play() {
this.posterEl?.remove();
this.posterEl = null;
@@ -586,6 +593,9 @@ class HyperframesPlayer extends HTMLElement {
sendControl: (action, extra) => this._sendControl(action, extra),
getIframeDoc: () => this.iframe.contentDocument,
onRuntimeReady: () => this._replayBridgeState(),
setScenes: (scenes) => {
this._scenes = scenes;
},
updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d),
updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p),
dispatchEvent: (ev) => this.dispatchEvent(ev),
@@ -18,6 +18,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({
media: { mirrorTime: vi.fn(), promoteToParentProxy: vi.fn() } as unknown as ParentMediaManager,
getPlaybackState: vi.fn(() => ({ currentTime: 0, duration: 0, paused: true, lastUpdateMs: 0 })),
setPlaybackState: vi.fn(),
setScenes: vi.fn(),
getShaderLoadingMode: vi.fn(() => "auto"),
shaderLoader: { update: vi.fn() } as unknown as ShaderLoaderState,
setCompositionSize: vi.fn(),
@@ -15,6 +15,16 @@ import type { ShaderTransitionState } from "./shader-options.js";
const FPS = 30;
type SceneRecord = { id: string; start: number; duration: number };
function extractScenes(raw: unknown): SceneRecord[] {
if (!Array.isArray(raw)) return [];
return (raw as SceneRecord[]).filter(
(s) =>
typeof s.id === "string" && typeof s.start === "number" && typeof s.duration === "number",
);
}
export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
getPlaybackState: () => PlaybackState;
setPlaybackState: (next: PlaybackState) => void;
@@ -27,8 +37,11 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
* uses it to replay current bridge state (mute, volume, playback rate) so
* control messages sent before the iframe's listener registered aren't lost. */
onRuntimeReady: () => void;
/** Called with the scene list whenever a "timeline" message is received. */
setScenes: (scenes: SceneRecord[]) => void;
}
// fallow-ignore-next-line complexity
export function handleRuntimeMessage(
event: MessageEvent,
frameWindow: Window | null,
@@ -90,6 +103,7 @@ export function handleRuntimeMessage(
callbacks.setPlaybackState({ ...pb, duration });
callbacks.updateControlsTime(pb.currentTime, duration);
}
callbacks.setScenes(extractScenes(data["scenes"]));
return;
}
@@ -0,0 +1,574 @@
// fallow-ignore-file code-duplication
import { describe, it, expect, vi } from "vitest";
import { SlideshowController } from "./SlideshowController";
import type { ResolvedSlideshow } from "@hyperframes/core/slideshow";
function fakePlayer() {
let cb: ((t: number) => void) | null = null;
const player = {
currentTime: 0,
seek: vi.fn((t: number) => {
player.currentTime = t;
}),
play: vi.fn(() => {}),
pause: vi.fn(() => {}),
onTimeUpdate: (fn: (t: number) => void) => {
cb = fn;
return () => {
cb = null;
};
},
emit: (t: number) => {
player.currentTime = t;
cb?.(t);
},
};
return player;
}
const SHOW: ResolvedSlideshow = {
slides: [
{ sceneId: "a", start: 0, end: 5, fragments: [2, 4], hotspots: [] },
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
],
sequences: {
deep: {
id: "deep",
label: "Deep dive",
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
},
},
};
/**
* Factory: controller on SHOW, advanced to fragmentIndex=1 via playback
* (emit 2 → frag 0, next(), emit 4 → frag 1). Used across Fix 8b + backToMain tests.
*/
function showAtFrag1() {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
p.emit(2); // fragmentIndex=0
c.next(); // target=4
p.emit(4); // fragmentIndex=1
return { p, c };
}
/**
* Factory: controller on SHOW, at slide 1, inside the "deep" branch.
* Used across branching + backToMain tests that share goToSlide(1)+enterBranch setup.
*/
function showAtSlide1InDeep() {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.goToSlide(1);
c.enterBranch("deep");
return { p, c };
}
describe("SlideshowController linear nav", () => {
it("enters the first slide on construction: seek to start + play", () => {
const p = fakePlayer();
new SlideshowController(p, SHOW);
expect(p.seek).toHaveBeenCalledWith(0);
expect(p.play).toHaveBeenCalled();
});
it("holds (pauses) at slide end when timeupdate reaches it", () => {
const p = fakePlayer();
new SlideshowController(p, SHOW);
p.emit(2); // first fragment — handled separately; still inside slide
p.emit(5); // reached end
expect(p.pause).toHaveBeenCalled();
});
it("next stops at the first fragment, not the next slide", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
p.emit(2); // play reaches fragment 0, controller pauses
expect(p.pause).toHaveBeenCalled();
expect(c.position.slideIndex).toBe(0);
expect(c.position.fragmentIndex).toBe(0);
});
it("next past the last fragment advances to the next slide immediately", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.next(); // -> fragment 1 target (2)
p.emit(2);
c.next(); // -> fragment 2 target (4)
p.emit(4);
c.next(); // no more fragments — advance to slide b immediately
expect(c.position.slideIndex).toBe(1);
expect(p.seek).toHaveBeenLastCalledWith(5);
});
it("next() on a slide with NO fragments advances to the next slide immediately", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
// Go to slide b (index 1, no fragments, not at end yet)
c.goToSlide(1); // slide b: start=5, end=10, fragments=[]
expect(c.position.slideIndex).toBe(1);
// The demo SHOW only has 2 slides, so next on slide 1 is a no-op.
// Use a show with a third slide to verify advancement.
const show3: ResolvedSlideshow = {
slides: [
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
],
sequences: {},
};
const p2 = fakePlayer();
const c2 = new SlideshowController(p2, show3);
// slide 0 has no fragments; one next() should advance immediately to slide 1
c2.next();
expect(c2.position.slideIndex).toBe(1);
expect(p2.seek).toHaveBeenLastCalledWith(5);
});
it("next() on the last slide is a no-op", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.goToSlide(1); // slide b is last
c.next();
expect(c.position.slideIndex).toBe(1); // no change
});
it("prev returns to the previous slide start", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.goToSlide(1);
c.prev();
expect(c.position.slideIndex).toBe(0);
});
it("auto-pauses at a fragment, then next advances to the FOLLOWING fragment (not the end)", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
p.emit(2); // auto-pause at fragments[0]=2
expect(c.position.fragmentIndex).toBe(0);
p.pause.mockClear(); // clear the pause from the auto-stop above
c.next(); // should target fragments[1]=4, NOT slide.end=5
p.emit(4);
expect(p.pause).toHaveBeenCalled(); // must pause at 4, not skip to 5
expect(c.position.fragmentIndex).toBe(1);
});
});
describe("SlideshowController nextSlide", () => {
it("returns the next slide when not at the end", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
// At slide 0, next should be slide 1 (sceneId "b")
expect(c.nextSlide).not.toBeNull();
expect(c.nextSlide?.sceneId).toBe("b");
});
it("returns null when at the last slide in the sequence", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.goToSlide(1); // slide "b" is the last in main
expect(c.nextSlide).toBeNull();
});
it("nextSlide is scoped to the current sequence", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.enterBranch("deep"); // "deep" has only one slide
expect(c.nextSlide).toBeNull();
});
});
describe("SlideshowController branching", () => {
it("enterBranch pushes onto the stack and enters the branch's first slide", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.enterBranch("deep");
expect(c.position.sequenceId).toBe("deep");
expect(c.currentSlide?.sceneId).toBe("c");
expect(p.seek).toHaveBeenLastCalledWith(10);
});
it("counter is scoped to the current sequence", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.enterBranch("deep");
expect(c.counter).toEqual({ index: 1, total: 1 });
});
it("breadcrumb reflects the stack", () => {
const { c } = showAtSlide1InDeep();
expect(c.breadcrumb.map((b) => b.label)).toEqual(["Main deck", "Deep dive"]);
});
it("back returns to the exact parent slide", () => {
const { c } = showAtSlide1InDeep();
c.back();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(1);
});
it("backToMain clears nested branches to the root", () => {
const { c } = showAtSlide1InDeep();
c.backToMain();
expect(c.breadcrumb.length).toBe(1);
expect(c.position.slideIndex).toBe(1);
});
});
describe("SlideshowController Fix 8a — fragmentIndex advances via onTime not next()", () => {
it("next() does NOT pre-increment fragmentIndex; onTime advances it when hold fires", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
// fragmentIndex starts at -1 (enterSlide sets it)
expect(c.position.fragmentIndex).toBe(-1);
// Call next() — should NOT pre-increment fragmentIndex
c.next();
expect(c.position.fragmentIndex).toBe(-1); // still -1 until onTime fires
// Simulate playback reaching the hold point (fragments[0]=2)
p.emit(2);
expect(c.position.fragmentIndex).toBe(0); // onTime advanced it
});
it("next() after auto-pause targets the FOLLOWING fragment without pre-increment (regression)", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
p.emit(2); // auto-pause at fragments[0]=2; fragmentIndex=0
expect(c.position.fragmentIndex).toBe(0);
p.pause.mockClear();
c.next(); // should target fragments[1]=4 — fragmentIndex stays 0 until emit
expect(c.position.fragmentIndex).toBe(0); // NOT yet 1
p.emit(4);
expect(p.pause).toHaveBeenCalled();
expect(c.position.fragmentIndex).toBe(1); // onTime advanced it
});
});
describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", () => {
it("back() restores the saved fragmentIndex and seeks to the fragment time", () => {
const { p, c } = showAtFrag1();
expect(c.position.fragmentIndex).toBe(1);
// Enter branch — saves frame {main, slideIndex:0, fragmentIndex:1}
c.enterBranch("deep");
expect(c.position.sequenceId).toBe("deep");
// Back should restore main, slideIndex=0, fragmentIndex=1, seek to fragments[1]=4
c.back();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(0);
expect(c.position.fragmentIndex).toBe(1);
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
});
it("back() when parent fragmentIndex=-1 seeks to slide start", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
// Enter branch immediately (fragmentIndex is still -1)
c.enterBranch("deep");
c.back();
expect(c.position.fragmentIndex).toBe(-1);
// seek should have been called with slide.start=0 (no fragment yet)
expect(p.seek).toHaveBeenLastCalledWith(0);
});
});
describe("SlideshowController unknown-sequence degradation", () => {
it("enterBranch with an unknown id does not throw and leaves nav state unchanged", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
c.goToSlide(1);
expect(() => c.enterBranch("no-such-seq")).not.toThrow();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(1);
});
it("counter and currentSlide degrade gracefully when sequence is missing from show", () => {
// Construct a show where sequences has no entries, then verify slidesOf([missing])
// returns [] and counter/currentSlide do not throw.
const showNoSeq: ResolvedSlideshow = {
slides: [{ sceneId: "x", start: 0, end: 5, fragments: [], hotspots: [] }],
sequences: {},
};
const p = fakePlayer();
const c = new SlideshowController(p, showNoSeq);
// enterBranch guards — no bogus frame gets pushed. counter on main is safe.
expect(() => c.counter).not.toThrow();
expect(c.counter).toEqual({ index: 1, total: 1 });
// enterBranch with an unknown id: guard fires, state stays on main
expect(() => c.enterBranch("ghost")).not.toThrow();
expect(c.position.sequenceId).toBe("main");
// breadcrumb does not throw for unknown sequence in stack (regression guard)
expect(() => c.breadcrumb).not.toThrow();
expect(c.breadcrumb[0]?.id).toBe("main");
});
});
// ---------------------------------------------------------------------------
// Bug fix tests: #5-ctrl — enterSlide clears holdAt on empty-slide early return
// ---------------------------------------------------------------------------
describe("SlideshowController Fix #5-ctrl — enterSlide clears holdAt on empty branch", () => {
it("enterSlide into an empty sequence clears holdAt so no spurious pause fires later", () => {
// Build a show where "empty" sequence has no slides
const show: ResolvedSlideshow = {
slides: [{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] }],
sequences: {
empty: { id: "empty", label: "Empty", slides: [] },
},
};
const p = fakePlayer();
const c = new SlideshowController(p, show);
// Advance to a holdAt state by calling next() (sets holdAt to fragment 2)
c.next();
// Now enter a branch that has no slides — enterSlide should clear holdAt
c.enterBranch("empty");
// Simulate time advancing — must NOT call pause (stale holdAt would trigger it)
p.pause.mockClear();
p.emit(2);
expect(p.pause).not.toHaveBeenCalled();
});
it("enterSlide(0) on an empty main sequence does not throw", () => {
// This verifies the early-return path doesn't leave holdAt dirty
const show: ResolvedSlideshow = {
slides: [],
sequences: {},
};
const p = fakePlayer();
// Constructor calls enterSlide(0) — must not throw with empty slides
expect(() => new SlideshowController(p, show)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Bug fix tests: #backToMain — uses resumeSlide to preserve fragment position
// ---------------------------------------------------------------------------
describe("SlideshowController Fix #backToMain — restores fragment position like back()", () => {
it("backToMain restores the root frame's fragmentIndex (not reset to -1)", () => {
const { p, c } = showAtFrag1();
expect(c.position.fragmentIndex).toBe(1);
// Enter branch — saves root frame with fragmentIndex=1
c.enterBranch("deep");
expect(c.position.sequenceId).toBe("deep");
// backToMain should restore to main slideIndex=0, fragmentIndex=1 (not -1)
c.backToMain();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(0);
expect(c.position.fragmentIndex).toBe(1);
// resumeSlide seeks to the fragment time (fragments[1]=4)
expect(p.seek).toHaveBeenLastCalledWith(4);
});
it("backToMain when root fragmentIndex=-1 seeks to slide start", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW);
// Enter branch immediately (root fragmentIndex is still -1)
c.enterBranch("deep");
c.backToMain();
expect(c.position.fragmentIndex).toBe(-1);
expect(p.seek).toHaveBeenLastCalledWith(0); // slide start
});
it("backToMain with multiple nested branches restores root slide position", () => {
const show: ResolvedSlideshow = {
slides: [
{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] },
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
],
sequences: {
lvl1: {
id: "lvl1",
label: "Level 1",
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
},
},
};
const p = fakePlayer();
const c = new SlideshowController(p, show);
c.goToSlide(1); // root at slide 1
c.enterBranch("lvl1");
// backToMain must pop all frames back to root
c.backToMain();
expect(c.breadcrumb.length).toBe(1);
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Branch-edge navigation: prev/next at branch boundaries return to parent
// ---------------------------------------------------------------------------
// Show used only for branch-edge tests: 2 main slides + single- and multi-slide branches.
const SHOW_BRANCH_EDGE: ResolvedSlideshow = {
slides: [
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
],
sequences: {
single: {
id: "single",
label: "Single slide branch",
slides: [{ sceneId: "x", start: 10, end: 13, fragments: [], hotspots: [] }],
},
multi: {
id: "multi",
label: "Multi slide branch",
slides: [
{ sceneId: "y", start: 13, end: 16, fragments: [], hotspots: [] },
{ sceneId: "z", start: 16, end: 20, fragments: [], hotspots: [] },
],
},
},
};
/** Factory: controller on SHOW_BRANCH_EDGE, already inside the given branch. */
function inBranch(branchId: string): { c: SlideshowController } {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
c.enterBranch(branchId);
return { c };
}
describe("SlideshowController branch-edge nav — prev/next return to parent", () => {
it("single-slide branch: prev() returns to parent", () => {
const { c } = inBranch("single");
expect(c.breadcrumb.length).toBe(2);
c.prev();
expect(c.position.sequenceId).toBe("main");
expect(c.breadcrumb.length).toBe(1);
});
it("single-slide branch: next() (no fragments, last slide) returns to parent", () => {
const { c } = inBranch("single");
expect(c.breadcrumb.length).toBe(2);
c.next();
expect(c.position.sequenceId).toBe("main");
expect(c.breadcrumb.length).toBe(1);
});
it("multi-slide branch: prev() from slide 1 → slide 0, NOT popped", () => {
const { c } = inBranch("multi");
c.goToSlide(1);
c.prev();
expect(c.position.sequenceId).toBe("multi");
expect(c.position.slideIndex).toBe(0);
expect(c.breadcrumb.length).toBe(2);
});
it("multi-slide branch: prev() from slide 0 → parent", () => {
const { c } = inBranch("multi");
c.prev();
expect(c.position.sequenceId).toBe("main");
expect(c.breadcrumb.length).toBe(1);
});
it("multi-slide branch: next() from slide 0 → slide 1, NOT popped", () => {
const { c } = inBranch("multi");
c.next();
expect(c.position.sequenceId).toBe("multi");
expect(c.position.slideIndex).toBe(1);
expect(c.breadcrumb.length).toBe(2);
});
it("multi-slide branch: next() from slide 1 (last) → parent", () => {
const { c } = inBranch("multi");
c.goToSlide(1);
c.next();
expect(c.position.sequenceId).toBe("main");
expect(c.breadcrumb.length).toBe(1);
});
it("main line: prev() at slide 0 is a no-op (stack.length === 1)", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
c.prev();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(0);
expect(c.breadcrumb.length).toBe(1);
});
it("main line: next() at last slide is a no-op (does NOT call back)", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
c.goToSlide(1);
c.next();
expect(c.position.sequenceId).toBe("main");
expect(c.position.slideIndex).toBe(1);
expect(c.breadcrumb.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// canPrev / canNext getters
// ---------------------------------------------------------------------------
describe("SlideshowController canPrev / canNext", () => {
it("main first slide: canPrev=false, canNext=true", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
expect(c.canPrev).toBe(false);
expect(c.canNext).toBe(true);
});
it("main last slide: canPrev=true, canNext=false", () => {
const p = fakePlayer();
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
c.goToSlide(1); // last slide (total=2)
expect(c.canPrev).toBe(true);
expect(c.canNext).toBe(false);
});
it("main middle slide: canPrev=true, canNext=true", () => {
// Use SHOW (3+ slides via SHOW_BRANCH_EDGE is only 2; use a 3-slide show)
const threeSlideShow: ResolvedSlideshow = {
slides: [
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
],
sequences: {},
};
const p = fakePlayer();
const c = new SlideshowController(p, threeSlideShow);
c.goToSlide(1);
expect(c.canPrev).toBe(true);
expect(c.canNext).toBe(true);
});
it("single-slide main: canPrev=false, canNext=false", () => {
const oneSlide: ResolvedSlideshow = {
slides: [{ sceneId: "only", start: 0, end: 5, fragments: [], hotspots: [] }],
sequences: {},
};
const p = fakePlayer();
const c = new SlideshowController(p, oneSlide);
expect(c.canPrev).toBe(false);
expect(c.canNext).toBe(false);
});
it("inside a branch (first slide): canPrev=true (parent is prev), canNext=true (next-within or parent)", () => {
const { c } = inBranch("single");
// single-slide branch, slideIndex=0, stack.length=2
expect(c.canPrev).toBe(true);
expect(c.canNext).toBe(true);
});
it("inside a multi-slide branch (first slide): canPrev=true, canNext=true", () => {
const { c } = inBranch("multi");
// slideIndex=0, next slide exists within branch
expect(c.canPrev).toBe(true);
expect(c.canNext).toBe(true);
});
it("inside a multi-slide branch (last slide): canPrev=true, canNext=true (parent is next)", () => {
const { c } = inBranch("multi");
c.goToSlide(1); // last slide in branch
expect(c.canPrev).toBe(true);
expect(c.canNext).toBe(true);
});
});
@@ -0,0 +1,215 @@
import type { ResolvedSlideshow, ResolvedSlide } from "@hyperframes/core/slideshow";
export interface PlayerPort {
seek(t: number): void;
play(): void;
pause(): void;
readonly currentTime: number;
onTimeUpdate(cb: (t: number) => void): () => void;
}
interface StackFrame {
sequenceId: string;
slideIndex: number;
fragmentIndex: number; // -1 = before first fragment / at slide start
}
const MAIN = "main";
const EPS = 0.001;
export class SlideshowController {
private stack: StackFrame[] = [{ sequenceId: MAIN, slideIndex: 0, fragmentIndex: -1 }];
private holdAt: number | null = null;
private changeCbs = new Set<() => void>();
private unsub: () => void;
constructor(
private player: PlayerPort,
private show: ResolvedSlideshow,
) {
this.unsub = player.onTimeUpdate((t) => this.onTime(t));
this.enterSlide(0);
}
// fallow-ignore-next-line unused-class-member
dispose(): void {
this.unsub();
}
private slidesOf(sequenceId: string): ResolvedSlide[] {
if (sequenceId === MAIN) return this.show.slides;
return this.show.sequences[sequenceId]?.slides ?? [];
}
private get frame(): StackFrame {
return this.stack[this.stack.length - 1];
}
get currentSlide(): ResolvedSlide | undefined {
return this.slidesOf(this.frame.sequenceId)[this.frame.slideIndex];
}
get nextSlide(): ResolvedSlide | null {
const slides = this.slidesOf(this.frame.sequenceId);
const next = slides[this.frame.slideIndex + 1];
return next ?? null;
}
get position(): { sequenceId: string; slideIndex: number; fragmentIndex: number } {
return { ...this.frame };
}
get counter(): { index: number; total: number } {
return {
index: this.frame.slideIndex + 1,
total: this.slidesOf(this.frame.sequenceId).length,
};
}
get canPrev(): boolean {
// prev has a destination: an earlier slide in this sequence, OR (in a branch) the parent.
return this.frame.slideIndex > 0 || this.stack.length > 1;
}
get canNext(): boolean {
// next has a destination: a later slide in this sequence, OR (in a branch) the parent.
const slides = this.slidesOf(this.frame.sequenceId);
return this.frame.slideIndex + 1 < slides.length || this.stack.length > 1;
}
get breadcrumb(): { id: string; label: string }[] {
return this.stack.map((f) =>
f.sequenceId === MAIN
? { id: MAIN, label: "Main deck" }
: { id: f.sequenceId, label: this.show.sequences[f.sequenceId]?.label ?? f.sequenceId },
);
}
// fallow-ignore-next-line unused-class-member
onChange(cb: () => void): () => void {
this.changeCbs.add(cb);
return () => this.changeCbs.delete(cb);
}
private emitChange(): void {
for (const cb of this.changeCbs) cb();
}
private enterSlide(index: number): void {
this.frame.slideIndex = index;
this.frame.fragmentIndex = -1;
this.holdAt = null;
const slide = this.currentSlide;
if (!slide) return;
this.player.seek(slide.start);
this.playTo(this.nextStop(slide, -1));
this.emitChange();
}
/**
* Resumes a slide at a saved fragmentIndex without resetting to slide start.
* Used by back() to restore the caller's exact position in the parent slide.
*/
private resumeSlide(index: number, fragmentIndex: number): void {
this.frame.slideIndex = index;
this.frame.fragmentIndex = fragmentIndex;
const slide = this.currentSlide;
if (!slide) return;
// Seek to the fragment's hold time (or slide start if before any fragment).
const seekTime =
fragmentIndex >= 0 && fragmentIndex < slide.fragments.length
? (slide.fragments[fragmentIndex] ?? slide.start)
: slide.start;
this.holdAt = null;
this.player.seek(seekTime);
this.player.pause();
this.emitChange();
}
private nextStop(slide: ResolvedSlide, fragmentIndex: number): number {
const next = slide.fragments[fragmentIndex + 1];
return next ?? slide.end;
}
private playTo(t: number): void {
this.holdAt = t;
this.player.play();
}
private onTime(t: number): void {
if (this.holdAt !== null && t >= this.holdAt - EPS) {
const hold = this.holdAt;
this.holdAt = null;
// Advance fragmentIndex if this hold is a fragment boundary.
const slide = this.currentSlide;
if (slide) {
const fragIdx = slide.fragments.indexOf(hold);
if (fragIdx !== -1) {
this.frame.fragmentIndex = fragIdx;
this.emitChange();
}
}
this.player.pause();
}
}
next(): void {
const slide = this.currentSlide;
if (!slide) return;
const hasMoreFragments = this.frame.fragmentIndex + 1 < slide.fragments.length;
const atEnd = this.player.currentTime >= slide.end - EPS;
if (hasMoreFragments && !atEnd) {
// Reveal the next fragment (play-to-hold). onTime() advances fragmentIndex at the hold.
const nextTarget = this.nextStop(slide, this.frame.fragmentIndex);
this.playTo(nextTarget);
this.emitChange();
return;
}
// No more fragments to reveal — advance to the next slide immediately instead of
// playing the current slide out to its end.
const slides = this.slidesOf(this.frame.sequenceId);
if (this.frame.slideIndex + 1 < slides.length) {
this.enterSlide(this.frame.slideIndex + 1);
} else if (this.stack.length > 1) {
// End of a branch → return to the parent timeline.
this.back();
}
}
prev(): void {
if (this.frame.slideIndex > 0) {
this.enterSlide(this.frame.slideIndex - 1);
return;
}
if (this.stack.length > 1) {
// First slide of a branch → return to the parent timeline.
this.back();
}
}
goToSlide(index: number): void {
const slides = this.slidesOf(this.frame.sequenceId);
if (index >= 0 && index < slides.length) this.enterSlide(index);
}
enterBranch(sequenceId: string): void {
if (!this.show.sequences[sequenceId]) return;
this.stack.push({ sequenceId, slideIndex: 0, fragmentIndex: -1 });
this.enterSlide(0);
}
back(): void {
if (this.stack.length <= 1) return;
this.stack.pop();
// Restore the saved fragmentIndex from the parent frame rather than
// resetting to -1 (which enterSlide would do). This preserves the exact
// position the presenter was at before entering the branch.
this.resumeSlide(this.frame.slideIndex, this.frame.fragmentIndex);
}
backToMain(): void {
if (this.stack.length <= 1) return;
this.stack = [this.stack[0]];
this.resumeSlide(this.frame.slideIndex, this.frame.fragmentIndex);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,602 @@
import {
parseSlideshowManifest,
resolveSlideshow,
type ResolvedSlideshow,
} from "@hyperframes/core/slideshow";
import { SlideshowController, type PlayerPort } from "./SlideshowController";
import { SlideshowChannel, buildPresenterLayout, formatElapsed } from "./slideshowPresenter";
interface Hotspot {
id: string;
label: string;
target: string;
region?: { x: number; y: number; w: number; h: number };
}
interface ControllerLike {
next(): void;
prev(): void;
onChange(cb: () => void): () => void;
readonly counter: { index: number; total: number };
readonly breadcrumb: { id: string; label: string }[];
readonly currentSlide: { hotspots: Hotspot[]; notes?: string; sceneId?: string } | undefined;
readonly nextSlide: { sceneId: string; notes?: string } | null;
readonly position: { sequenceId: string; slideIndex: number; fragmentIndex: number };
readonly canPrev?: boolean;
readonly canNext?: boolean;
goToSlide?(index: number): void;
enterBranch?(id: string): void;
back?(): void;
backToMain?(): void;
dispose?(): void;
}
type PlayerElement = HTMLElement & {
seek(t: number): void;
play(): void;
pause(): void;
readonly currentTime: number;
readonly ready: boolean;
};
function isPlayerElement(el: HTMLElement): el is PlayerElement {
return (
typeof (el as PlayerElement).seek === "function" &&
typeof (el as PlayerElement).play === "function" &&
typeof (el as PlayerElement).pause === "function"
);
}
// Injected once per document to avoid duplicating @keyframes across multiple elements.
let _keyframesInjected = false;
function injectKeyframesOnce(): void {
if (_keyframesInjected) return;
_keyframesInjected = true;
const style = document.createElement("style");
style.textContent = `
@keyframes hf-hotspot-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.35), 0 4px 16px rgba(0,0,0,0.35); }
50% { box-shadow: 0 0 0 8px rgba(255,255,255,0), 0 4px 20px rgba(0,0,0,0.45); }
}
@media (prefers-reduced-motion: reduce) {
.hf-hotspot-pill { animation: none !important; }
}
`;
document.head.appendChild(style);
}
export class HyperframesSlideshow extends HTMLElement {
private controller: ControllerLike | null = null;
private offChange: (() => void) | null = null;
private chrome: HTMLDivElement | null = null;
private touchStartX = 0;
private touchStartY = 0;
private channel: SlideshowChannel | null = null;
private presenterStartMs: number | null = null;
private presenterInterval: ReturnType<typeof setInterval> | null = null;
private disconnected = false;
private initTimer: ReturnType<typeof setTimeout> | null = null;
private initInFlight = false;
private initGeneration = 0;
private _muted = false;
/** Whether audio is currently muted. Reflects `data-hf-muted` attribute. */
get muted(): boolean {
return this._muted;
}
connectedCallback(): void {
this.disconnected = false;
this.initInFlight = false;
this.initGeneration += 1;
this.tabIndex = 0;
// note: if the inner player iframe has keyboard focus, window keydown in the
// top document won't fire — that edge remains; this listener fixes the dominant
// case where the page loads and arrows should work without clicking the element.
window.addEventListener("keydown", this.onKey);
this.addEventListener("touchstart", this.onTouchStart, { passive: true });
this.addEventListener("touchend", this.onTouchEnd);
window.addEventListener("message", this.onMessage);
this.initChannel();
// 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
// connectedCallback fires while the parser is still inside the
// <hyperframes-slideshow> open tag — before its children exist. A microtask
// is NOT sufficient: during streamed parsing the children are appended in a
// later task, so a queued microtask still observes an empty subtree. A
// 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();
}, 0);
}
disconnectedCallback(): void {
this.disconnected = true;
this.initGeneration += 1;
if (this.initTimer !== null) {
clearTimeout(this.initTimer);
this.initTimer = null;
}
window.removeEventListener("keydown", this.onKey);
this.removeEventListener("touchstart", this.onTouchStart);
this.removeEventListener("touchend", this.onTouchEnd);
window.removeEventListener("message", this.onMessage);
this.offChange?.();
this.offChange = null;
this.controller?.dispose?.();
this.controller = null;
this.chrome = null;
this.channel?.destroy();
this.channel = null;
if (this.presenterInterval !== null) {
clearInterval(this.presenterInterval);
this.presenterInterval = null;
}
}
/** Test seam: inject a controller without a live player. */
__setControllerForTest(c: ControllerLike): void {
this.bindController(c);
}
/**
* Opens an audience window and switches this element to presenter layout.
* Audience window URL: current page URL with `mode=audience` query param.
*/
present(): void {
const sep = location.search ? "&" : "?";
window.open(location.href + sep + "mode=audience", "_blank");
this.setAttribute("data-hf-presenting", "true");
this.presenterStartMs = Date.now();
if (this.presenterInterval === null) {
this.presenterInterval = setInterval(() => this.render(), 1000);
}
this.render();
}
private initChannel(): void {
const mode = this.getAttribute("mode");
if (mode === "audience") {
this.channel = new SlideshowChannel("audience", (msg) => {
if (!this.controller) return;
if (msg.sequenceId !== "main") return; // V1: non-main branch gracefully ignored
this.controller.goToSlide?.(msg.slideIndex);
});
} else {
this.channel = new SlideshowChannel("presenter", () => {
// presenter channel does not receive; posting happens in bindController
});
}
}
// fallow-ignore-next-line complexity
private async init(): Promise<void> {
if (this.initInFlight) return;
this.initInFlight = true;
const gen = this.initGeneration;
try {
const playerEl = this.querySelector("hyperframes-player");
if (!playerEl || !(playerEl instanceof HTMLElement)) return;
if (!isPlayerElement(playerEl)) return;
await waitForReady(playerEl);
// Guard: if a disconnect or reconnect happened while waiting, bail out.
if (gen !== this.initGeneration) return;
const html = this.innerHTML;
let manifest: ReturnType<typeof parseSlideshowManifest>;
try {
manifest = parseSlideshowManifest(html);
} catch {
// Malformed island (e.g. bad JSON) — fail gracefully, no chrome.
return;
}
if (!manifest) return;
// Wait for scenes to be populated (the runtime "timeline" postMessage
// arrives ~1000ms after waitForReady resolves). Graceful fallback to []
// on timeout so explicit startTime/endTime slides still work.
const scenes = await waitForScenes(playerEl, 2500, () => gen !== this.initGeneration);
// Guard again in case we were disconnected or reconnected during the scenes wait.
if (gen !== this.initGeneration) return;
const { resolved, errors } = resolveSlideshow(manifest, scenes);
if (errors.length > 0) {
console.warn("[hyperframes-slideshow] manifest errors:", errors);
}
const cleaned = dropInvalidSlides(resolved);
const port: PlayerPort = {
seek: (t) => playerEl.seek(t),
play: () => playerEl.play(),
pause: () => playerEl.pause(),
get currentTime() {
return playerEl.currentTime;
},
onTimeUpdate: (cb) => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ currentTime: number }>).detail;
cb(detail.currentTime);
};
playerEl.addEventListener("timeupdate", handler);
return () => playerEl.removeEventListener("timeupdate", handler);
},
};
this.bindController(new SlideshowController(port, cleaned));
} finally {
this.initInFlight = false;
}
}
private bindController(c: ControllerLike): void {
this.offChange?.();
this.controller?.dispose?.();
this.controller = c;
this.offChange = c.onChange(() => {
// Presenter posts position to channel on every change
if (this.getAttribute("mode") !== "audience" && this.channel) {
this.channel.postPosition(c.position);
}
this.render();
});
// Post initial position if presenter
if (this.getAttribute("mode") !== "audience" && this.channel) {
this.channel.postPosition(c.position);
}
this.render();
}
// fallow-ignore-next-line complexity
private onKey = (e: KeyboardEvent): void => {
if (!this.controller) return;
const target = e.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (e.key === "ArrowRight" || e.key === " ") {
this.controller.next();
e.preventDefault();
} else if (e.key === "ArrowLeft" || e.key === "Backspace") {
this.controller.prev();
e.preventDefault();
}
};
// fallow-ignore-next-line complexity
private onMessage = (e: MessageEvent): void => {
// Audience mode is driven by BroadcastChannel; ignore embed postMessage nav.
if (this.getAttribute("mode") === "audience") return;
const data = e.data as { type?: unknown; slideIndex?: unknown } | null;
if (!data || !this.controller) return;
if (data.type === "next") {
this.controller.next();
} else if (data.type === "prev") {
this.controller.prev();
} else if (data.type === "goto" && typeof data.slideIndex === "number") {
this.controller.goToSlide?.(data.slideIndex);
} else if (data.type === "back") {
this.controller.back?.();
}
};
private onTouchStart = (e: TouchEvent): void => {
const touch = e.touches[0];
if (touch) {
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
}
};
private onTouchEnd = (e: TouchEvent): void => {
if (!this.controller) return;
const touch = e.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
// Require a dominant horizontal gesture: |deltaX| > 40 AND |deltaX| > |deltaY|
// so that diagonal page-scrolls do not accidentally trigger slide navigation.
if (Math.abs(deltaX) <= 40 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
if (deltaX < 0) {
this.controller.next();
} else {
this.controller.prev();
}
};
// fallow-ignore-next-line complexity
private render(): void {
if (!this.controller) return;
if (this.getAttribute("data-hf-presenting") === "true") {
this.renderPresenter();
return;
}
const { counter, currentSlide } = this.controller;
if (!currentSlide) return;
if (!this.chrome) {
this.chrome = document.createElement("div");
this.chrome.setAttribute("data-hf-chrome", "");
this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:10;";
this.appendChild(this.chrome);
}
// Inject keyframes for hotspot pulse animation once per document.
injectKeyframesOnce();
// Hotspot pills: compact floating buttons anchored to the region's top-left,
// sized to content (not filling the region). The region x/y positions the pill;
// w/h are ignored for sizing (pill is content-sized). XSS: escHtml guards all
// user-supplied strings.
const hotspotsHtml = currentSlide.hotspots
.map((h) => {
const posStyle = h.region
? `left:${h.region.x}%;top:${h.region.y}%;`
: "right:5%;bottom:18%;";
return `<button
class="hf-hotspot-pill"
data-hotspot-id="${escHtml(h.id)}"
data-hotspot-target="${escHtml(h.target)}"
type="button"
style="position:absolute;${posStyle}display:inline-flex;align-items:center;gap:6px;padding:8px 14px;background:var(--hf-slideshow-accent,rgba(255,255,255,0.92));color:#111;border:none;border-radius:999px;font-size:13px;font-weight:600;letter-spacing:0.01em;cursor:pointer;pointer-events:auto;box-shadow:0 4px 16px rgba(0,0,0,0.35);animation:hf-hotspot-pulse 1.8s ease-in-out infinite;white-space:nowrap;"
aria-label="${escHtml(h.label)}"
><span aria-hidden="true" style="font-size:14px;line-height:1;"></span>${escHtml(h.label)}</button>`;
})
.join("");
// Single cohesive nav cluster: [mute?] [prev |] counter [| next] — bottom-right capsule.
// Prev/next buttons are hidden when there is no destination in that direction:
// - Main deck first slide → no prev (nothing before it)
// - Main deck last slide → no next (nothing after it)
// - Inside a branch → always both (branch-edge returns to parent)
// The mute toggle is shown only when the `sound` boolean attribute is present.
const showPrev = this.controller.canPrev !== false;
const showNext = this.controller.canNext !== false;
const showSound = this.hasAttribute("sound");
const btnStyle =
"display:flex;align-items:center;justify-content:center;width:34px;height:34px;background:transparent;border:none;border-radius:999px;color:rgba(255,255,255,0.85);font-size:16px;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;";
// Inline SVG glyphs for speaker and speaker-muted (no emoji — consistent across platforms)
const speakerSvg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`;
const speakerMutedSvg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
const muteBtnHtml = showSound
? `<button
data-hf-mute
type="button"
aria-label="${this._muted ? "Unmute" : "Mute"}"
aria-pressed="${this._muted ? "true" : "false"}"
style="${btnStyle}${this._muted ? "color:rgba(255,255,255,0.45);" : ""}"
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='${this._muted ? "rgba(255,255,255,0.6)" : "#fff"}';"
onmouseout="this.style.background='transparent';this.style.color='${this._muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.85)"}';"
>${this._muted ? speakerMutedSvg : speakerSvg}</button>`
: "";
const prevBtnHtml = showPrev
? `<button
data-hf-prev
type="button"
aria-label="Previous slide"
style="${btnStyle}"
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='#fff';"
onmouseout="this.style.background='transparent';this.style.color='rgba(255,255,255,0.85)';"
>&#8249;</button>`
: "";
const nextBtnHtml = showNext
? `<button
data-hf-next
type="button"
aria-label="Next slide"
style="${btnStyle}"
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='#fff';"
onmouseout="this.style.background='transparent';this.style.color='rgba(255,255,255,0.85)';"
>&#8250;</button>`
: "";
// Counter padding adjusts so the pill looks centered when one button is absent.
const counterPadLeft = showPrev ? "4px" : "10px";
const counterPadRight = showNext ? "4px" : "10px";
const navClusterHtml = `
<div
data-hf-nav-cluster
style="position:absolute;bottom:28px;right:32px;display:inline-flex;align-items:center;gap:2px;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
>
${muteBtnHtml}
${showSound ? `<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>` : ""}
${prevBtnHtml}
<span
data-hf-counter
aria-label="Slide ${counter.index} of ${counter.total}"
style="min-width:46px;text-align:center;color:rgba(255,255,255,0.9);font-size:13px;font-weight:500;font-variant-numeric:tabular-nums;letter-spacing:0.02em;padding:0 ${counterPadRight} 0 ${counterPadLeft};user-select:none;"
>${counter.index}&thinsp;/&thinsp;${counter.total}</span>
${nextBtnHtml}
</div>
`;
this.chrome.innerHTML = hotspotsHtml + navClusterHtml;
const muteBtn = this.chrome.querySelector("[data-hf-mute]");
const prevBtn = this.chrome.querySelector("[data-hf-prev]");
const nextBtn = this.chrome.querySelector("[data-hf-next]");
if (muteBtn) muteBtn.addEventListener("click", () => this.toggleMute());
if (prevBtn) prevBtn.addEventListener("click", () => this.controller?.prev());
if (nextBtn) nextBtn.addEventListener("click", () => this.controller?.next());
// Wire hotspot clicks after innerHTML is set. Read target from data-hotspot-target
// so the handler does not close over stale loop state.
for (const btn of this.chrome.querySelectorAll("[data-hotspot-id]")) {
const target = btn.getAttribute("data-hotspot-target") ?? "";
btn.addEventListener("click", () => this.controller?.enterBranch?.(target));
}
}
private toggleMute(): void {
this._muted = !this._muted;
if (this._muted) {
this.setAttribute("data-hf-muted", "");
} else {
this.removeAttribute("data-hf-muted");
}
this.dispatchEvent(
new CustomEvent("hf-sound", {
detail: { muted: this._muted },
bubbles: true,
composed: true,
}),
);
// Re-render to flip the glyph.
this.render();
}
private renderPresenter(): void {
if (!this.controller) return;
const { counter, currentSlide, nextSlide } = this.controller;
if (!currentSlide) return;
const elapsedSec =
this.presenterStartMs !== null ? Math.floor((Date.now() - this.presenterStartMs) / 1000) : 0;
if (!this.chrome) {
this.chrome = document.createElement("div");
this.chrome.setAttribute("data-hf-chrome", "");
this.chrome.style.cssText = "position:absolute;inset:0;z-index:10;";
this.appendChild(this.chrome);
}
this.chrome.innerHTML = buildPresenterLayout({
// TODO: live next-slide thumbnail/preview deferred (needs a second seeked player) — V1 shows text
currentSlideHtml: currentPanelText(currentSlide),
nextSlideHtml: nextPanelText(nextSlide),
notes: currentSlide.notes ?? "",
counterText: `${counter.index} / ${counter.total}`,
elapsedText: formatElapsed(elapsedSec),
});
}
}
function currentPanelText(slide: { notes?: string; sceneId?: string }): string {
if (slide.notes != null && slide.notes.length > 0) return escHtml(slide.notes);
if (slide.sceneId != null) return `Current: ${escHtml(slide.sceneId)}`;
return "";
}
function nextPanelText(slide: { sceneId: string; notes?: string } | null): string {
if (slide === null) return "End of sequence";
const firstLine = slide.notes != null ? (slide.notes.split("\n")[0] ?? "") : "";
return firstLine.length > 0
? `${escHtml(slide.sceneId)}: ${escHtml(firstLine)}`
: escHtml(slide.sceneId);
}
function readScenes(player: HTMLElement): { id: string; start: number; duration: number }[] {
if ("scenes" in player && Array.isArray((player as { scenes: unknown }).scenes)) {
return (player as { scenes: { id: string; start: number; duration: number }[] }).scenes;
}
return [];
}
const WAIT_FOR_READY_TIMEOUT_MS = 5000;
function waitForReady(player: HTMLElement & { ready?: boolean }): Promise<void> {
if (player.ready === true) return Promise.resolve();
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = (): void => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
resolve();
};
player.addEventListener("ready", handler, { once: true });
timer = setTimeout(() => {
player.removeEventListener("ready", handler);
resolve();
}, WAIT_FOR_READY_TIMEOUT_MS);
});
}
/**
* Polls `player.scenes` until at least one scene is present, then resolves
* with the scenes array. Resolves with `[]` if no scenes appear within
* `timeoutMs` (graceful: explicit startTime/endTime slides still work).
*
* Avoids Date.now(): counts poll iterations instead (100ms per iteration).
*
* `isCancelled` is checked before each poll iteration; if it returns true
* the promise resolves with `[]` immediately so the caller can bail out.
*/
function waitForScenes(
player: HTMLElement,
timeoutMs: number,
isCancelled: () => boolean = () => false,
): Promise<{ id: string; start: number; duration: number }[]> {
const scenes = readScenes(player);
if (scenes.length > 0) return Promise.resolve(scenes);
const maxIterations = Math.ceil(timeoutMs / 100);
return new Promise((resolve) => {
let iterations = 0;
const poll = (): void => {
if (isCancelled()) {
resolve([]);
return;
}
const current = readScenes(player);
if (current.length > 0) {
resolve(current);
return;
}
iterations += 1;
if (iterations >= maxIterations) {
resolve([]);
return;
}
setTimeout(poll, 100);
};
setTimeout(poll, 100);
});
}
/**
* Returns a new ResolvedSlideshow with zero-duration (end <= start) slides
* removed from the main slide list and every sequence's slide list.
*
* Valid manifests never produce zero-duration slides this only drops
* phantom slides created from partially-specified refs whose scene is absent.
*
* Exported as a seam for unit testing.
*/
export function dropInvalidSlides(show: ResolvedSlideshow): ResolvedSlideshow {
const validSlide = (s: { start: number; end: number }): boolean => s.end > s.start;
const slides = show.slides.filter(validSlide);
const sequences: ResolvedSlideshow["sequences"] = {};
for (const [id, seq] of Object.entries(show.sequences)) {
sequences[id] = { ...seq, slides: seq.slides.filter(validSlide) };
}
return { slides, sequences };
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
if (!customElements.get("hyperframes-slideshow")) {
customElements.define("hyperframes-slideshow", HyperframesSlideshow);
}
@@ -0,0 +1,103 @@
export interface PresenterPosition {
sequenceId: string;
slideIndex: number;
fragmentIndex: number;
}
interface GotoMessage {
type: "goto";
sequenceId: string;
slideIndex: number;
fragmentIndex: number;
}
function isGotoMessage(data: unknown): data is GotoMessage {
if (typeof data !== "object" || data === null) return false;
const d = data as Record<string, unknown>;
return (
d["type"] === "goto" &&
typeof d["sequenceId"] === "string" &&
typeof d["slideIndex"] === "number" &&
typeof d["fragmentIndex"] === "number"
);
}
/**
* Manages the BroadcastChannel connection for a single slideshow element.
* Presenter (default) mode: posts position updates to the channel.
* Audience mode: listens for goto messages and calls the provided handler.
*/
export class SlideshowChannel {
private channel: BroadcastChannel | null = null;
constructor(
private readonly mode: "presenter" | "audience",
private readonly onGoto: (msg: GotoMessage) => void,
) {
try {
this.channel = new BroadcastChannel("hf-slideshow");
} catch {
// BroadcastChannel unavailable (e.g. unsupported env); degrade silently.
return;
}
if (mode === "audience") {
this.channel.onmessage = (e: MessageEvent) => {
if (isGotoMessage(e.data)) {
this.onGoto(e.data);
}
};
}
}
postPosition(pos: PresenterPosition): void {
if (this.mode !== "presenter" || !this.channel) return;
const msg: GotoMessage = { type: "goto", ...pos };
this.channel.postMessage(msg);
}
destroy(): void {
if (this.channel) {
this.channel.onmessage = null;
this.channel.close();
this.channel = null;
}
}
}
/**
* Builds the presenter-mode inner HTML showing current slide area,
* next-slide preview, notes, counter, and elapsed timer.
*/
export function buildPresenterLayout(opts: {
currentSlideHtml: string;
nextSlideHtml: string;
notes: string;
counterText: string;
elapsedText: string;
}): string {
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `
<div data-hf-presenter style="display:grid;grid-template-columns:2fr 1fr;grid-template-rows:auto 1fr;gap:12px;padding:12px;height:100%;box-sizing:border-box;background:#1a1a1a;color:#fff;font-family:sans-serif;">
<div data-hf-presenter-current style="grid-column:1;grid-row:1/3;border:2px solid #444;border-radius:6px;overflow:hidden;position:relative;">
${opts.currentSlideHtml}
</div>
<div style="grid-column:2;grid-row:1;display:flex;flex-direction:column;gap:8px;">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;opacity:.6;">Next</div>
<div data-hf-presenter-next style="border:1px solid #333;border-radius:4px;overflow:hidden;opacity:.7;">
${opts.nextSlideHtml}
</div>
<div data-hf-presenter-counter style="font-size:13px;opacity:.8;">${esc(opts.counterText)}</div>
<div data-hf-presenter-elapsed style="font-size:13px;font-variant-numeric:tabular-nums;">${esc(opts.elapsedText)}</div>
</div>
<div data-hf-presenter-notes style="grid-column:2;grid-row:2;overflow-y:auto;font-size:13px;line-height:1.5;opacity:.9;border-top:1px solid #333;padding-top:8px;">${esc(opts.notes)}</div>
</div>`.trim();
}
/** Format elapsed seconds as mm:ss */
export function formatElapsed(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
@@ -0,0 +1,46 @@
/**
* Vitest setup: install a minimal in-memory BroadcastChannel polyfill so that
* happy-dom tests can exercise the presenter/audience channel code path.
* This polyfill is intentionally NOT shipped in production code.
*/
type MsgHandler = (event: MessageEvent) => void;
const registry = new Map<string, Set<InMemoryBroadcastChannel>>();
class InMemoryBroadcastChannel {
onmessage: MsgHandler | null = null;
readonly name: string;
private _closed = false;
constructor(name: string) {
this.name = name;
let set = registry.get(name);
if (!set) {
set = new Set();
registry.set(name, set);
}
set.add(this);
}
// fallow-ignore-next-line complexity
postMessage(data: unknown): void {
if (this._closed) return;
const peers = registry.get(this.name);
if (!peers) return;
for (const peer of peers) {
if (peer === this) continue;
peer.onmessage?.(new MessageEvent("message", { data }));
}
}
close(): void {
if (this._closed) return;
this._closed = true;
registry.get(this.name)?.delete(this);
}
}
if (typeof globalThis.BroadcastChannel === "undefined") {
(globalThis as Record<string, unknown>)["BroadcastChannel"] = InMemoryBroadcastChannel;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/hyperframes-player.ts"],
entry: ["src/hyperframes-player.ts", "src/slideshow/hyperframes-slideshow.ts"],
format: ["esm", "cjs", "iife"],
globalName: "HyperframesPlayer",
dts: true,
+9
View File
@@ -1,7 +1,16 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
const coreRoot = resolve(new URL("..", import.meta.url).pathname, "core/src");
export default defineConfig({
resolve: {
alias: {
"@hyperframes/core/slideshow": resolve(coreRoot, "slideshow/index.ts"),
},
},
test: {
environment: "happy-dom",
setupFiles: ["./src/slideshow/test-setup.ts"],
},
});