mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
a38fc4e778
commit
7af3eb8f80
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)';"
|
||||
>‹</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)';"
|
||||
>›</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} / ${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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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,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,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user