mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(slideshow): harden media controls in present decks (#1619)
This commit is contained in:
@@ -11,6 +11,8 @@ function createMockDeps() {
|
|||||||
onSetMuted: vi.fn(),
|
onSetMuted: vi.fn(),
|
||||||
onSetVolume: vi.fn(),
|
onSetVolume: vi.fn(),
|
||||||
onSetMediaOutputMuted: vi.fn(),
|
onSetMediaOutputMuted: vi.fn(),
|
||||||
|
onSetNativeMediaSyncDisabled: vi.fn(),
|
||||||
|
onSetWebAudioMediaDisabled: vi.fn(),
|
||||||
onSetPlaybackRate: vi.fn(),
|
onSetPlaybackRate: vi.fn(),
|
||||||
onSetColorGrading: vi.fn(),
|
onSetColorGrading: vi.fn(),
|
||||||
onSetColorGradingCompare: vi.fn(),
|
onSetColorGradingCompare: vi.fn(),
|
||||||
@@ -107,6 +109,38 @@ describe("installRuntimeControlBridge", () => {
|
|||||||
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches set-native-media-sync-disabled command", () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
handler(makeControlMessage("set-native-media-sync-disabled", { disabled: true }));
|
||||||
|
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(true);
|
||||||
|
handler(makeControlMessage("set-native-media-sync-disabled", { disabled: false }));
|
||||||
|
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set-native-media-sync-disabled coerces absent flag to false", () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
handler(makeControlMessage("set-native-media-sync-disabled"));
|
||||||
|
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches set-web-audio-media-disabled command", () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
handler(makeControlMessage("set-web-audio-media-disabled", { disabled: true }));
|
||||||
|
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(true);
|
||||||
|
handler(makeControlMessage("set-web-audio-media-disabled", { disabled: false }));
|
||||||
|
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set-web-audio-media-disabled coerces absent flag to false", () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
handler(makeControlMessage("set-web-audio-media-disabled"));
|
||||||
|
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches set-playback-rate command", () => {
|
it("dispatches set-playback-rate command", () => {
|
||||||
const deps = createMockDeps();
|
const deps = createMockDeps();
|
||||||
const handler = installRuntimeControlBridge(deps);
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type BridgeDeps = {
|
|||||||
onSetMuted: (muted: boolean) => void;
|
onSetMuted: (muted: boolean) => void;
|
||||||
onSetVolume: (volume: number) => void;
|
onSetVolume: (volume: number) => void;
|
||||||
onSetMediaOutputMuted: (muted: boolean) => void;
|
onSetMediaOutputMuted: (muted: boolean) => void;
|
||||||
|
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
|
||||||
|
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
|
||||||
onSetPlaybackRate: (rate: number) => void;
|
onSetPlaybackRate: (rate: number) => void;
|
||||||
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
|
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
|
||||||
onSetColorGradingCompare: (
|
onSetColorGradingCompare: (
|
||||||
@@ -46,6 +48,10 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
|
|||||||
"set-volume": (data, deps) =>
|
"set-volume": (data, deps) =>
|
||||||
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
|
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
|
||||||
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
|
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
|
||||||
|
"set-native-media-sync-disabled": (data, deps) =>
|
||||||
|
deps.onSetNativeMediaSyncDisabled(Boolean(data.disabled)),
|
||||||
|
"set-web-audio-media-disabled": (data, deps) =>
|
||||||
|
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
|
||||||
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
||||||
"set-color-grading": (data, deps) =>
|
"set-color-grading": (data, deps) =>
|
||||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
|
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
|
||||||
|
|||||||
@@ -1147,6 +1147,57 @@ describe("initSandboxRuntimeModular", () => {
|
|||||||
expect(audio.muted).toBe(false);
|
expect(audio.muted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("native media sync opt-out leaves user-started media playing while timeline is paused", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "root");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-start", "0");
|
||||||
|
root.setAttribute("data-duration", "10");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.setAttribute("data-start", "0");
|
||||||
|
audio.setAttribute("data-duration", "10");
|
||||||
|
audio.setAttribute("src", "voiceover.mp3");
|
||||||
|
Object.defineProperty(audio, "duration", { value: 10, configurable: true });
|
||||||
|
Object.defineProperty(audio, "readyState", {
|
||||||
|
value: HTMLMediaElement.HAVE_FUTURE_DATA,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
|
||||||
|
Object.defineProperty(audio, "paused", { value: true, writable: true, configurable: true });
|
||||||
|
audio.pause = vi.fn(() => {
|
||||||
|
Object.defineProperty(audio, "paused", {
|
||||||
|
value: true,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.appendChild(audio);
|
||||||
|
|
||||||
|
window.__timelines = { root: createMockTimeline(10) };
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
source: "hf-parent",
|
||||||
|
type: "control",
|
||||||
|
action: "set-native-media-sync-disabled",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Object.defineProperty(audio, "paused", { value: false, writable: true, configurable: true });
|
||||||
|
vi.mocked(audio.pause).mockClear();
|
||||||
|
|
||||||
|
window.__player?.renderSeek(5);
|
||||||
|
|
||||||
|
expect(audio.pause).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips the per-frame transport re-seek while a Studio manual-edit gesture is active", () => {
|
it("skips the per-frame transport re-seek while a Studio manual-edit gesture is active", () => {
|
||||||
const raf = createManualRaf();
|
const raf = createManualRaf();
|
||||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||||
|
|||||||
@@ -1501,23 +1501,27 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
|
|
||||||
const forceSync = state.mediaForceSyncNextTick;
|
const forceSync = state.mediaForceSyncNextTick;
|
||||||
if (forceSync) state.mediaForceSyncNextTick = false;
|
if (forceSync) state.mediaForceSyncNextTick = false;
|
||||||
syncRuntimeMedia({
|
if (!state.nativeMediaSyncDisabled) {
|
||||||
clips: cache.mediaClips,
|
syncRuntimeMedia({
|
||||||
timeSeconds: state.currentTime,
|
clips: cache.mediaClips,
|
||||||
playing: state.isPlaying,
|
timeSeconds: state.currentTime,
|
||||||
playbackRate: state.playbackRate,
|
playing: state.isPlaying,
|
||||||
outputMuted: state.mediaOutputMuted,
|
playbackRate: state.playbackRate,
|
||||||
userMuted: state.bridgeMuted,
|
outputMuted:
|
||||||
userVolume: state.bridgeVolume,
|
state.mediaOutputMuted ||
|
||||||
forceSync,
|
(!state.webAudioMediaDisabled && !state.nativeMediaSyncDisabled && webAudio.isActive()),
|
||||||
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
|
userMuted: state.bridgeMuted,
|
||||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
userVolume: state.bridgeVolume,
|
||||||
onAutoplayBlocked: () => {
|
forceSync,
|
||||||
if (state.mediaAutoplayBlockedPosted) return;
|
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
|
||||||
state.mediaAutoplayBlockedPosted = true;
|
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||||
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
|
onAutoplayBlocked: () => {
|
||||||
},
|
if (state.mediaAutoplayBlockedPosted) return;
|
||||||
});
|
state.mediaAutoplayBlockedPosted = true;
|
||||||
|
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
|
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
|
||||||
const rootComp = resolveRootCompositionElement();
|
const rootComp = resolveRootCompositionElement();
|
||||||
for (const rawNode of visibilityNodes) {
|
for (const rawNode of visibilityNodes) {
|
||||||
@@ -1882,6 +1886,29 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
el.muted = effective || el.defaultMuted;
|
el.muted = effective || el.defaultMuted;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSetNativeMediaSyncDisabled: (disabled) => {
|
||||||
|
if (state.nativeMediaSyncDisabled === disabled) return;
|
||||||
|
state.nativeMediaSyncDisabled = disabled;
|
||||||
|
state.mediaForceSyncNextTick = true;
|
||||||
|
if (disabled) {
|
||||||
|
webAudio.stopAll();
|
||||||
|
clock.detachAudioSource();
|
||||||
|
} else {
|
||||||
|
syncMediaForCurrentState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSetWebAudioMediaDisabled: (disabled) => {
|
||||||
|
if (state.webAudioMediaDisabled === disabled) return;
|
||||||
|
state.webAudioMediaDisabled = disabled;
|
||||||
|
state.mediaForceSyncNextTick = true;
|
||||||
|
if (disabled) {
|
||||||
|
webAudio.stopAll();
|
||||||
|
clock.detachAudioSource();
|
||||||
|
syncMediaForCurrentState();
|
||||||
|
} else {
|
||||||
|
syncMediaForCurrentState();
|
||||||
|
}
|
||||||
|
},
|
||||||
onSetPlaybackRate: (rate) => {
|
onSetPlaybackRate: (rate) => {
|
||||||
applyPlaybackRate(rate);
|
applyPlaybackRate(rate);
|
||||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||||
@@ -2201,7 +2228,12 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
|
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
|
||||||
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
|
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
|
||||||
if (clock.isPlaying() && !state.mediaOutputMuted) {
|
if (clock.isPlaying() && !state.mediaOutputMuted) {
|
||||||
if (webAudio.isActive() && webAudio.context) {
|
if (
|
||||||
|
!state.nativeMediaSyncDisabled &&
|
||||||
|
!state.webAudioMediaDisabled &&
|
||||||
|
webAudio.isActive() &&
|
||||||
|
webAudio.context
|
||||||
|
) {
|
||||||
const webAudioTime = webAudio.getTime();
|
const webAudioTime = webAudio.getTime();
|
||||||
if (webAudioTime >= 0) {
|
if (webAudioTime >= 0) {
|
||||||
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
|
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
|
||||||
@@ -2313,6 +2345,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
|
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
|
||||||
// handler (a rate change can't rescale a bounded source in place).
|
// handler (a rate change can't rescale a bounded source in place).
|
||||||
const scheduleWebAudioForActiveClips = () => {
|
const scheduleWebAudioForActiveClips = () => {
|
||||||
|
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
|
||||||
const gen = webAudio.startGeneration();
|
const gen = webAudio.startGeneration();
|
||||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||||
for (const rawEl of audioEls) {
|
for (const rawEl of audioEls) {
|
||||||
@@ -2362,7 +2395,14 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
|
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
|
||||||
const applyWebAudioRate = () => {
|
const applyWebAudioRate = () => {
|
||||||
const changed = webAudio.setRate(state.playbackRate);
|
const changed = webAudio.setRate(state.playbackRate);
|
||||||
if (changed && webAudioReady && clock.isPlaying() && webAudio.hasBoundedActiveSources()) {
|
if (
|
||||||
|
changed &&
|
||||||
|
!state.nativeMediaSyncDisabled &&
|
||||||
|
!state.webAudioMediaDisabled &&
|
||||||
|
webAudioReady &&
|
||||||
|
clock.isPlaying() &&
|
||||||
|
webAudio.hasBoundedActiveSources()
|
||||||
|
) {
|
||||||
webAudio.stopAll();
|
webAudio.stopAll();
|
||||||
scheduleWebAudioForActiveClips();
|
scheduleWebAudioForActiveClips();
|
||||||
}
|
}
|
||||||
@@ -2392,7 +2432,9 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
// Schedule audio through WebAudio for sample-accurate timing.
|
// Schedule audio through WebAudio for sample-accurate timing.
|
||||||
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
||||||
// or decoding fails (the syncRuntimeMedia path handles that).
|
// or decoding fails (the syncRuntimeMedia path handles that).
|
||||||
if (webAudioReady) scheduleWebAudioForActiveClips();
|
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
|
||||||
|
scheduleWebAudioForActiveClips();
|
||||||
|
}
|
||||||
runAdapters("play");
|
runAdapters("play");
|
||||||
syncMediaForCurrentState();
|
syncMediaForCurrentState();
|
||||||
colorGrading.redraw();
|
colorGrading.redraw();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ describe("createRuntimeState", () => {
|
|||||||
expect(state.canonicalFps).toBe(30);
|
expect(state.canonicalFps).toBe(30);
|
||||||
expect(state.playbackRate).toBe(1);
|
expect(state.playbackRate).toBe(1);
|
||||||
expect(state.bridgeMuted).toBe(false);
|
expect(state.bridgeMuted).toBe(false);
|
||||||
|
expect(state.nativeMediaSyncDisabled).toBe(false);
|
||||||
|
expect(state.webAudioMediaDisabled).toBe(false);
|
||||||
expect(state.capturedTimeline).toBeNull();
|
expect(state.capturedTimeline).toBeNull();
|
||||||
expect(state.tornDown).toBe(false);
|
expect(state.tornDown).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export type RuntimeState = {
|
|||||||
* accuracy but produces no audio of its own.
|
* accuracy but produces no audio of its own.
|
||||||
*/
|
*/
|
||||||
mediaOutputMuted: boolean;
|
mediaOutputMuted: boolean;
|
||||||
|
/**
|
||||||
|
* Disable runtime ownership of native media elements. Slideshow presenter
|
||||||
|
* mode keeps the slide timeline paused while users interact with embedded
|
||||||
|
* media, so the runtime must not auto-play, auto-pause, seek, or volume-sync
|
||||||
|
* those native elements on every transport tick.
|
||||||
|
*/
|
||||||
|
nativeMediaSyncDisabled: boolean;
|
||||||
|
/**
|
||||||
|
* Disable the runtime's WebAudio replacement for native <audio> elements.
|
||||||
|
* Slideshow presenter/audience windows mirror native media element events
|
||||||
|
* across browsers, so muting those elements for WebAudio ownership breaks
|
||||||
|
* audible presenter playback and remote sync.
|
||||||
|
*/
|
||||||
|
webAudioMediaDisabled: boolean;
|
||||||
/**
|
/**
|
||||||
* Latch so the `media-autoplay-blocked` outbound message is posted at most
|
* Latch so the `media-autoplay-blocked` outbound message is posted at most
|
||||||
* once per runtime session. The parent only needs the first signal — it
|
* once per runtime session. The parent only needs the first signal — it
|
||||||
@@ -88,6 +102,8 @@ export function createRuntimeState(): RuntimeState {
|
|||||||
bridgeMuted: false,
|
bridgeMuted: false,
|
||||||
bridgeVolume: 1,
|
bridgeVolume: 1,
|
||||||
mediaOutputMuted: false,
|
mediaOutputMuted: false,
|
||||||
|
nativeMediaSyncDisabled: false,
|
||||||
|
webAudioMediaDisabled: false,
|
||||||
mediaAutoplayBlockedPosted: false,
|
mediaAutoplayBlockedPosted: false,
|
||||||
mediaForceSyncNextTick: false,
|
mediaForceSyncNextTick: false,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type RuntimeBridgeControlAction =
|
|||||||
| "tick"
|
| "tick"
|
||||||
| "set-volume"
|
| "set-volume"
|
||||||
| "set-media-output-muted"
|
| "set-media-output-muted"
|
||||||
|
| "set-native-media-sync-disabled"
|
||||||
|
| "set-web-audio-media-disabled"
|
||||||
| "stop-media"
|
| "stop-media"
|
||||||
| "flash-elements";
|
| "flash-elements";
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export type RuntimeBridgeControlMessage = {
|
|||||||
frame?: number;
|
frame?: number;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
|
disabled?: boolean;
|
||||||
playbackRate?: number;
|
playbackRate?: number;
|
||||||
target?: HfColorGradingTarget | string | null;
|
target?: HfColorGradingTarget | string | null;
|
||||||
grading?: RuntimeJson;
|
grading?: RuntimeJson;
|
||||||
|
|||||||
@@ -247,6 +247,52 @@ describe("HyperframesPlayer parent-frame media", () => {
|
|||||||
expect(mockAudio.pause).toHaveBeenCalled();
|
expect(mockAudio.pause).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function dispatchAutoplayBlockedFromPlayerFrame(player: HTMLElement): HTMLMediaElement {
|
||||||
|
const iframe = player.shadowRoot?.querySelector("iframe");
|
||||||
|
if (!(iframe instanceof HTMLIFrameElement)) throw new Error("expected player iframe");
|
||||||
|
const iframeDoc = iframe.contentDocument;
|
||||||
|
if (!iframeDoc) throw new Error("expected player iframe document");
|
||||||
|
const video = iframeDoc.createElement("video");
|
||||||
|
video.setAttribute("data-start", "0");
|
||||||
|
video.setAttribute("data-duration", "10");
|
||||||
|
video.muted = false;
|
||||||
|
iframeDoc.body.appendChild(video);
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
source: iframe.contentWindow,
|
||||||
|
data: { source: "hf-preview", type: "media-autoplay-blocked" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not mute iframe media on autoplay fallback inside presenter slideshow", () => {
|
||||||
|
const slideshow = document.createElement("hyperframes-slideshow");
|
||||||
|
slideshow.appendChild(player);
|
||||||
|
document.body.appendChild(slideshow);
|
||||||
|
|
||||||
|
const video = dispatchAutoplayBlockedFromPlayerFrame(player);
|
||||||
|
|
||||||
|
expect(video.muted).toBe(false);
|
||||||
|
expect(player._audioOwner).toBe("runtime");
|
||||||
|
slideshow.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promote autoplay fallback inside audience slideshow", () => {
|
||||||
|
const slideshow = document.createElement("hyperframes-slideshow");
|
||||||
|
slideshow.setAttribute("mode", "audience");
|
||||||
|
slideshow.appendChild(player);
|
||||||
|
document.body.appendChild(slideshow);
|
||||||
|
|
||||||
|
const video = dispatchAutoplayBlockedFromPlayerFrame(player);
|
||||||
|
|
||||||
|
expect(video.muted).toBe(false);
|
||||||
|
expect(player._audioOwner).toBe("runtime");
|
||||||
|
slideshow.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => {
|
it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => {
|
||||||
// Regression: previously `seek()` only called `seekAll()`, leaving the
|
// Regression: previously `seek()` only called `seekAll()`, leaving the
|
||||||
// proxy playing. With the timeline frozen at the new seek target, the
|
// proxy playing. With the timeline frozen at the new seek target, the
|
||||||
@@ -1834,6 +1880,40 @@ describe("HyperframesPlayer runtime ready handshake", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps runtime WebAudio media enabled outside slideshow embeds", () => {
|
||||||
|
postSpy.mockClear();
|
||||||
|
|
||||||
|
player._onMessage(readyMessage());
|
||||||
|
|
||||||
|
expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
|
||||||
|
action: "set-native-media-sync-disabled",
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
|
||||||
|
action: "set-web-audio-media-disabled",
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables runtime WebAudio media inside slideshow embeds", () => {
|
||||||
|
const slideshow = document.createElement("hyperframes-slideshow");
|
||||||
|
slideshow.appendChild(player);
|
||||||
|
document.body.appendChild(slideshow);
|
||||||
|
postSpy.mockClear();
|
||||||
|
|
||||||
|
player._onMessage(readyMessage());
|
||||||
|
|
||||||
|
expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
|
||||||
|
action: "set-native-media-sync-disabled",
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
|
||||||
|
action: "set-web-audio-media-disabled",
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
slideshow.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("replays the muted state forced by audio-locked", () => {
|
it("replays the muted state forced by audio-locked", () => {
|
||||||
// The audio-locked attribute is the original motivating case for this
|
// The audio-locked attribute is the original motivating case for this
|
||||||
// handshake — its `muted = true` side effect must survive an iframe race.
|
// handshake — its `muted = true` side effect must survive an iframe race.
|
||||||
|
|||||||
@@ -430,6 +430,10 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
|
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _isSlideshowPlayer(): boolean {
|
||||||
|
return this.closest("hyperframes-slideshow") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
|
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
|
||||||
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
|
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
|
||||||
private _handleMutedChange(val: string | null): void {
|
private _handleMutedChange(val: string | null): void {
|
||||||
@@ -526,6 +530,12 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
this._sendControl("set-muted", { muted: this.muted });
|
this._sendControl("set-muted", { muted: this.muted });
|
||||||
this._sendControl("set-volume", { volume: this._volume });
|
this._sendControl("set-volume", { volume: this._volume });
|
||||||
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
|
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
|
||||||
|
this._sendControl("set-native-media-sync-disabled", {
|
||||||
|
disabled: this._isSlideshowPlayer(),
|
||||||
|
});
|
||||||
|
this._sendControl("set-web-audio-media-disabled", {
|
||||||
|
disabled: this._isSlideshowPlayer(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _reloadShaderOptions(): void {
|
private _reloadShaderOptions(): void {
|
||||||
@@ -633,6 +643,7 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
sendControl: (action, extra) => this._sendControl(action, extra),
|
sendControl: (action, extra) => this._sendControl(action, extra),
|
||||||
getIframeDoc: () => this.iframe.contentDocument,
|
getIframeDoc: () => this.iframe.contentDocument,
|
||||||
onRuntimeReady: () => this._replayBridgeState(),
|
onRuntimeReady: () => this._replayBridgeState(),
|
||||||
|
shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(),
|
||||||
setScenes: (scenes) => {
|
setScenes: (scenes) => {
|
||||||
this._scenes = scenes;
|
this._scenes = scenes;
|
||||||
this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } }));
|
this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } }));
|
||||||
|
|||||||
@@ -67,3 +67,37 @@ describe("handleRuntimeMessage stage-size", () => {
|
|||||||
expect(callbacks.setCompositionSize).not.toHaveBeenCalled();
|
expect(callbacks.setCompositionSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleRuntimeMessage media autoplay fallback", () => {
|
||||||
|
const autoplayBlockedEvent = (source: object): MessageEvent =>
|
||||||
|
({
|
||||||
|
source,
|
||||||
|
data: { source: "hf-preview", type: "media-autoplay-blocked" },
|
||||||
|
}) as unknown as MessageEvent;
|
||||||
|
|
||||||
|
it("promotes and mutes iframe output by default", () => {
|
||||||
|
const frameWindow = {} as Window;
|
||||||
|
const callbacks = makeCallbacks();
|
||||||
|
|
||||||
|
handleRuntimeMessage(autoplayBlockedEvent(frameWindow), frameWindow, callbacks);
|
||||||
|
|
||||||
|
expect(callbacks.media.promoteToParentProxy).toHaveBeenCalled();
|
||||||
|
expect(callbacks.sendControl).toHaveBeenCalledWith("set-media-output-muted", {
|
||||||
|
muted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promote or mute iframe output when the host vetoes the fallback", () => {
|
||||||
|
const frameWindow = {} as Window;
|
||||||
|
const callbacks = {
|
||||||
|
...makeCallbacks(),
|
||||||
|
shouldPromoteMediaAutoplayFallback: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRuntimeMessage(autoplayBlockedEvent(frameWindow), frameWindow, callbacks);
|
||||||
|
|
||||||
|
expect(callbacks.shouldPromoteMediaAutoplayFallback).toHaveBeenCalled();
|
||||||
|
expect(callbacks.media.promoteToParentProxy).not.toHaveBeenCalled();
|
||||||
|
expect(callbacks.sendControl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
|
|||||||
onRuntimeReady: () => void;
|
onRuntimeReady: () => void;
|
||||||
/** Called with the scene list whenever a "timeline" message is received. */
|
/** Called with the scene list whenever a "timeline" message is received. */
|
||||||
setScenes: (scenes: SceneRecord[]) => void;
|
setScenes: (scenes: SceneRecord[]) => void;
|
||||||
|
/** Return false to ignore the iframe runtime's audible-media autoplay fallback.
|
||||||
|
* Slideshow embeds keep iframe media under native element ownership because
|
||||||
|
* presenter/audience sync mirrors those media events directly. */
|
||||||
|
shouldPromoteMediaAutoplayFallback?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -87,6 +91,7 @@ export function handleRuntimeMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data["type"] === "media-autoplay-blocked") {
|
if (data["type"] === "media-autoplay-blocked") {
|
||||||
|
if (callbacks.shouldPromoteMediaAutoplayFallback?.() === false) return;
|
||||||
let iframeDoc: Document | null = null;
|
let iframeDoc: Document | null = null;
|
||||||
try {
|
try {
|
||||||
iframeDoc = callbacks.getIframeDoc();
|
iframeDoc = callbacks.getIframeDoc();
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ describe("<hyperframes-slideshow>", () => {
|
|||||||
onPrev?: () => void;
|
onPrev?: () => void;
|
||||||
index?: number;
|
index?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
sound?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const el = document.createElement("hyperframes-slideshow") as any;
|
const el = document.createElement("hyperframes-slideshow") as any;
|
||||||
|
if (opts.sound) el.setAttribute("sound", "");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
el.__setControllerForTest({
|
el.__setControllerForTest({
|
||||||
next: opts.onNext ?? (() => {}),
|
next: opts.onNext ?? (() => {}),
|
||||||
@@ -30,6 +32,9 @@ describe("<hyperframes-slideshow>", () => {
|
|||||||
breadcrumb: [{ id: "main", label: "Main deck" }],
|
breadcrumb: [{ id: "main", label: "Main deck" }],
|
||||||
currentSlide: { hotspots: [] },
|
currentSlide: { hotspots: [] },
|
||||||
nextSlide: null,
|
nextSlide: null,
|
||||||
|
get position() {
|
||||||
|
return { sequenceId: "main", slideIndex: (opts.index ?? 1) - 1, fragmentIndex: -1 };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -122,12 +127,71 @@ describe("<hyperframes-slideshow>", () => {
|
|||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders an icon-only present button inside the nav cluster in normal mode", () => {
|
||||||
|
const el = makeEl({ index: 1, total: 3 });
|
||||||
|
const cluster = el.querySelector("[data-hf-nav-cluster]");
|
||||||
|
const presentBtn = el.querySelector("[data-hf-present]");
|
||||||
|
expect(cluster).toBeTruthy();
|
||||||
|
expect(presentBtn).toBeTruthy();
|
||||||
|
expect(cluster?.contains(presentBtn)).toBe(true);
|
||||||
|
expect(presentBtn?.textContent?.trim()).toBe("");
|
||||||
|
expect(presentBtn?.querySelector("svg")).toBeTruthy();
|
||||||
|
expect(presentBtn?.querySelector('path[d="M10 8.5v4l4-2-4-2z"]')).toBeTruthy();
|
||||||
|
expect(presentBtn?.getAttribute("aria-label")).toBe("Present");
|
||||||
|
expect(presentBtn?.getAttribute("title")).toBe("Present");
|
||||||
|
expect(presentBtn?.getAttribute("data-hf-tooltip")).toBe("Present");
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the present button in audience mode", () => {
|
||||||
|
const el = document.createElement("hyperframes-slideshow") as any;
|
||||||
|
el.setAttribute("mode", "audience");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.__setControllerForTest({
|
||||||
|
next: () => {},
|
||||||
|
prev: () => {},
|
||||||
|
onChange: () => () => {},
|
||||||
|
counter: { index: 1, total: 3 },
|
||||||
|
breadcrumb: [{ id: "main", label: "Main deck" }],
|
||||||
|
currentSlide: { hotspots: [] },
|
||||||
|
nextSlide: null,
|
||||||
|
get position() {
|
||||||
|
return { sequenceId: "main", slideIndex: 0, fragmentIndex: -1 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(el.querySelector("[data-hf-present]")).toBeNull();
|
||||||
|
expect(el.querySelector("[data-hf-fullscreen]")).toBeTruthy();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders counter text", () => {
|
it("renders counter text", () => {
|
||||||
const el = makeEl({ index: 2, total: 5 });
|
const el = makeEl({ index: 2, total: 5 });
|
||||||
const counter = el.querySelector("[data-hf-counter]");
|
const counter = el.querySelector("[data-hf-counter]");
|
||||||
expect(counter).toBeTruthy();
|
expect(counter).toBeTruthy();
|
||||||
expect(counter.textContent).toContain("2");
|
expect(counter.textContent).toContain("2");
|
||||||
expect(counter.textContent).toContain("5");
|
expect(counter.textContent).toContain("5");
|
||||||
|
expect(counter.getAttribute("style")).toContain("font-family:Inter");
|
||||||
|
expect(counter.getAttribute("style")).toContain("font-variant-numeric:tabular-nums");
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds hover tooltips to all nav control buttons", () => {
|
||||||
|
const el = makeEl({ index: 2, total: 5, sound: true });
|
||||||
|
const expected: [string, string][] = [
|
||||||
|
["[data-hf-mute]", "Mute"],
|
||||||
|
["[data-hf-prev]", "Previous slide"],
|
||||||
|
["[data-hf-next]", "Next slide"],
|
||||||
|
["[data-hf-present]", "Present"],
|
||||||
|
["[data-hf-fullscreen]", "Full screen"],
|
||||||
|
];
|
||||||
|
for (const [selector, label] of expected) {
|
||||||
|
const button = el.querySelector(selector);
|
||||||
|
expect(button).toBeTruthy();
|
||||||
|
expect(button?.getAttribute("aria-label")).toBe(label);
|
||||||
|
expect(button?.getAttribute("title")).toBe(label);
|
||||||
|
expect(button?.getAttribute("data-hf-tooltip")).toBe(label);
|
||||||
|
}
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,6 +620,78 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
|||||||
return { el, triggerChange: () => onChangeCb?.() };
|
return { el, triggerChange: () => onChangeCb?.() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMediaSyncedEl(mode: "presenter" | "audience", opts: { playRejects?: boolean } = {}) {
|
||||||
|
const el = document.createElement("hyperframes-slideshow") as any;
|
||||||
|
if (mode === "audience") el.setAttribute("mode", "audience");
|
||||||
|
|
||||||
|
const player = document.createElement("hyperframes-player");
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
Object.defineProperty(player, "iframeElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: iframe,
|
||||||
|
});
|
||||||
|
player.appendChild(iframe);
|
||||||
|
el.appendChild(player);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const frameDoc = iframe.contentDocument;
|
||||||
|
if (!frameDoc) throw new Error("expected iframe document in test");
|
||||||
|
const video = frameDoc.createElement("video");
|
||||||
|
video.id = "demo";
|
||||||
|
video.volume = 0.75;
|
||||||
|
video.playbackRate = 1;
|
||||||
|
frameDoc.body.appendChild(video);
|
||||||
|
|
||||||
|
let paused = true;
|
||||||
|
Object.defineProperty(video, "paused", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return paused;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(video, "ended", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const play = vi.fn(() => {
|
||||||
|
paused = false;
|
||||||
|
return opts.playRejects ? Promise.reject(new Error("blocked")) : Promise.resolve();
|
||||||
|
});
|
||||||
|
const pause = vi.fn(() => {
|
||||||
|
paused = true;
|
||||||
|
});
|
||||||
|
Object.defineProperty(video, "play", { configurable: true, value: play });
|
||||||
|
Object.defineProperty(video, "pause", { configurable: true, value: pause });
|
||||||
|
|
||||||
|
el.__setControllerForTest({
|
||||||
|
next: () => {},
|
||||||
|
prev: () => {},
|
||||||
|
goToSlide: () => {},
|
||||||
|
syncTo: () => {},
|
||||||
|
onChange: () => () => {},
|
||||||
|
counter: { index: 1, total: 2 },
|
||||||
|
breadcrumb: [{ id: "main", label: "Main deck" }],
|
||||||
|
currentSlide: { hotspots: [] },
|
||||||
|
nextSlide: null,
|
||||||
|
get position() {
|
||||||
|
return MAIN_POS;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
video,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
key: "player:0|id:demo",
|
||||||
|
setPaused(value: boolean) {
|
||||||
|
paused = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
it("audience mode: mirrors full position (sequence + slide + fragment) via syncTo", async () => {
|
it("audience mode: mirrors full position (sequence + slide + fragment) via syncTo", async () => {
|
||||||
@@ -620,6 +756,181 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
|||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("presenter mode: broadcasts iframe media play events", async () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const listenerChannel = new BroadcastChannel(slideshowChannelName());
|
||||||
|
listenerChannel.onmessage = (e: MessageEvent) => received.push(e.data);
|
||||||
|
|
||||||
|
const { el, video, setPaused } = makeMediaSyncedEl("presenter");
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
setPaused(false);
|
||||||
|
video.currentTime = 7.25;
|
||||||
|
video.dispatchEvent(new Event("play"));
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const msg = received.find(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate === "object" &&
|
||||||
|
candidate !== null &&
|
||||||
|
(candidate as Record<string, unknown>)["type"] === "media",
|
||||||
|
) as Record<string, unknown> | undefined;
|
||||||
|
expect(msg).toMatchObject({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key: "player:0|id:demo",
|
||||||
|
action: "play",
|
||||||
|
currentTime: 7.25,
|
||||||
|
paused: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 0.75,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerChannel.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audience mode: does not echo local muted autoplay state back to presenter", async () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const listenerChannel = new BroadcastChannel(slideshowChannelName());
|
||||||
|
listenerChannel.onmessage = (e: MessageEvent) => received.push(e.data);
|
||||||
|
|
||||||
|
const { el, video } = makeMediaSyncedEl("audience");
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
video.muted = true;
|
||||||
|
video.dispatchEvent(new Event("volumechange"));
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
received.some(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate === "object" &&
|
||||||
|
candidate !== null &&
|
||||||
|
(candidate as Record<string, unknown>)["type"] === "media",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
listenerChannel.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audience mode: remote play starts iframe media muted", async () => {
|
||||||
|
const presenterChannel = new BroadcastChannel(slideshowChannelName());
|
||||||
|
const { el, video, play, key } = makeMediaSyncedEl("audience");
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
presenterChannel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key,
|
||||||
|
action: "play",
|
||||||
|
currentTime: 12.5,
|
||||||
|
paused: false,
|
||||||
|
ended: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 0.8,
|
||||||
|
playbackRate: 1.25,
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(video.currentTime).toBe(12.5);
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
expect(video.volume).toBe(0.8);
|
||||||
|
expect(video.playbackRate).toBe(1.25);
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
presenterChannel.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audience mode: keeps remote media muted during subsequent time sync", async () => {
|
||||||
|
const presenterChannel = new BroadcastChannel(slideshowChannelName());
|
||||||
|
const { el, video, key } = makeMediaSyncedEl("audience");
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
presenterChannel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key,
|
||||||
|
action: "play",
|
||||||
|
currentTime: 1,
|
||||||
|
paused: false,
|
||||||
|
ended: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 1,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
presenterChannel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key,
|
||||||
|
action: "timeupdate",
|
||||||
|
currentTime: 4,
|
||||||
|
paused: false,
|
||||||
|
ended: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 1,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(video.currentTime).toBe(4);
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
|
||||||
|
presenterChannel.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audience mode: rejected muted play stops chasing remote timeupdates", async () => {
|
||||||
|
const presenterChannel = new BroadcastChannel(slideshowChannelName());
|
||||||
|
const { el, video, play, key } = makeMediaSyncedEl("audience", { playRejects: true });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
presenterChannel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key,
|
||||||
|
action: "play",
|
||||||
|
currentTime: 2,
|
||||||
|
paused: false,
|
||||||
|
ended: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 1,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
presenterChannel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender: "presenter",
|
||||||
|
key,
|
||||||
|
action: "timeupdate",
|
||||||
|
currentTime: 20,
|
||||||
|
paused: false,
|
||||||
|
ended: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 1,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(video.currentTime).toBe(2);
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
const buttonTexts = Array.from(
|
||||||
|
el.querySelectorAll("button") as NodeListOf<HTMLButtonElement>,
|
||||||
|
(button) => button.textContent,
|
||||||
|
);
|
||||||
|
expect(buttonTexts).toContain("Play audience media muted");
|
||||||
|
|
||||||
|
presenterChannel.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("present() opens a new window with mode=audience and sets presenter attribute", () => {
|
it("present() opens a new window with mode=audience and sets presenter attribute", () => {
|
||||||
const openCalls: { url: string; target: string }[] = [];
|
const openCalls: { url: string; target: string }[] = [];
|
||||||
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||||
@@ -638,6 +949,61 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
|||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("built-in nav present button opens presenter mode and then hides itself", () => {
|
||||||
|
const openCalls: { url: string; target: string }[] = [];
|
||||||
|
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||||
|
openCalls.push({ url: String(url), target: String(target) });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { el } = makePresenterEl();
|
||||||
|
const presentBtn = el.querySelector("[data-hf-present]") as HTMLButtonElement;
|
||||||
|
expect(presentBtn).toBeTruthy();
|
||||||
|
|
||||||
|
presentBtn.click();
|
||||||
|
|
||||||
|
expect(openCalls).toHaveLength(1);
|
||||||
|
expect(openCalls[0].url).toContain("mode=audience");
|
||||||
|
expect(el.getAttribute("data-hf-presenting")).toBe("true");
|
||||||
|
expect(el.querySelector("[data-hf-present]")).toBeNull();
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P shortcut opens presenter mode from the shared component", () => {
|
||||||
|
const openCalls: { url: string; target: string }[] = [];
|
||||||
|
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||||
|
openCalls.push({ url: String(url), target: String(target) });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { el } = makePresenterEl();
|
||||||
|
el.focus();
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "P" }));
|
||||||
|
|
||||||
|
expect(openCalls).toHaveLength(1);
|
||||||
|
expect(openCalls[0].url).toContain("mode=audience");
|
||||||
|
expect(el.getAttribute("data-hf-presenting")).toBe("true");
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("present() rebroadcasts the current position for a newly opened audience window", async () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const spy = new BroadcastChannel(slideshowChannelName());
|
||||||
|
spy.onmessage = (e: MessageEvent) => received.push(e.data);
|
||||||
|
vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
|
||||||
|
const { el } = makePresenterEl();
|
||||||
|
el.present();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(received).toContainEqual({ type: "goto", ...MAIN_POS });
|
||||||
|
|
||||||
|
spy.close();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("disconnectedCallback closes the BroadcastChannel", async () => {
|
it("disconnectedCallback closes the BroadcastChannel", async () => {
|
||||||
const { el } = makePresenterEl();
|
const { el } = makePresenterEl();
|
||||||
await tick();
|
await tick();
|
||||||
@@ -690,6 +1056,23 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
|||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("presenter counters use the shared sans-serif number style", () => {
|
||||||
|
const el = makePresenterWithSlides({
|
||||||
|
currentSlide: { sceneId: "intro", notes: "Intro notes" },
|
||||||
|
nextSlide: { sceneId: "features", notes: "Feature notes" },
|
||||||
|
index: 2,
|
||||||
|
total: 5,
|
||||||
|
});
|
||||||
|
const counter = el.querySelector("[data-hf-presenter-counter]");
|
||||||
|
const elapsed = el.querySelector("[data-hf-presenter-elapsed]");
|
||||||
|
expect(counter?.textContent).toContain("2 / 5");
|
||||||
|
expect(counter?.getAttribute("style")).toContain("font-family:Inter");
|
||||||
|
expect(counter?.getAttribute("style")).toContain("font-variant-numeric:tabular-nums");
|
||||||
|
expect(elapsed?.getAttribute("style")).toContain("font-family:Inter");
|
||||||
|
expect(elapsed?.getAttribute("style")).toContain("font-variant-numeric:tabular-nums");
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("presenter notes are editable and reload from localStorage", () => {
|
it("presenter notes are editable and reload from localStorage", () => {
|
||||||
const el = makePresenterWithSlides({
|
const el = makePresenterWithSlides({
|
||||||
currentSlide: { sceneId: "intro", notes: "Original manifest notes" },
|
currentSlide: { sceneId: "intro", notes: "Original manifest notes" },
|
||||||
@@ -978,6 +1361,56 @@ describe("<hyperframes-slideshow> deferred init (Bug 1)", () => {
|
|||||||
// init bailed (isConnected=false / timer cleared), so no chrome was mounted.
|
// init bailed (isConnected=false / timer cleared), so no chrome was mounted.
|
||||||
expect(el.querySelector("[data-hf-chrome]")).toBeNull();
|
expect(el.querySelector("[data-hf-chrome]")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders initial nav chrome from the manifest before scene metadata arrives", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const el = document.createElement("hyperframes-slideshow") as any;
|
||||||
|
el.innerHTML = `
|
||||||
|
<script type="application/hyperframes-slideshow+json">
|
||||||
|
{
|
||||||
|
"slides": [
|
||||||
|
{ "sceneId": "intro", "startTime": 0, "endTime": 1 },
|
||||||
|
{ "sceneId": "second", "startTime": 1, "endTime": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fakePlayer = document.createElement("hyperframes-player");
|
||||||
|
Object.defineProperty(fakePlayer, "ready", { get: () => true });
|
||||||
|
Object.defineProperty(fakePlayer, "seek", { value: () => {} });
|
||||||
|
Object.defineProperty(fakePlayer, "play", { value: () => {} });
|
||||||
|
Object.defineProperty(fakePlayer, "pause", { value: () => {} });
|
||||||
|
Object.defineProperty(fakePlayer, "currentTime", { get: () => 0 });
|
||||||
|
Object.defineProperty(fakePlayer, "scenes", { get: () => [] });
|
||||||
|
el.appendChild(fakePlayer);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
const chrome = el.querySelector("[data-hf-chrome]");
|
||||||
|
const counter = el.querySelector("[data-hf-counter]");
|
||||||
|
expect(chrome).toBeTruthy();
|
||||||
|
expect(counter?.textContent).toContain("1");
|
||||||
|
expect(counter?.textContent).toContain("2");
|
||||||
|
expect(el.querySelector("[data-hf-prev]")).toBeNull();
|
||||||
|
expect(el.querySelector("[data-hf-next]")).toBeNull();
|
||||||
|
const loading = el.querySelector("[data-hf-nav-loading]");
|
||||||
|
expect(loading).toBeTruthy();
|
||||||
|
expect(loading?.getAttribute("aria-label")).toBe("Loading slides");
|
||||||
|
expect(el.querySelector("[data-hf-present]")).toBeTruthy();
|
||||||
|
expect(el.querySelector("[data-hf-fullscreen]")).toBeTruthy();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2600);
|
||||||
|
|
||||||
|
expect(el.querySelector("[data-hf-nav-loading]")).toBeNull();
|
||||||
|
expect(el.querySelector("[data-hf-next]")).toBeTruthy();
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
type ResolvedSlideshow,
|
type ResolvedSlideshow,
|
||||||
} from "@hyperframes/core/slideshow";
|
} from "@hyperframes/core/slideshow";
|
||||||
import { SlideshowController, type PlayerPort } from "./SlideshowController";
|
import { SlideshowController, type PlayerPort } from "./SlideshowController";
|
||||||
import { SlideshowChannel, buildPresenterLayout, formatElapsed } from "./slideshowPresenter";
|
import {
|
||||||
|
SlideshowChannel,
|
||||||
|
buildPresenterLayout,
|
||||||
|
formatElapsed,
|
||||||
|
type PresenterMediaAction,
|
||||||
|
type PresenterMediaMessage,
|
||||||
|
} from "./slideshowPresenter";
|
||||||
|
|
||||||
interface Hotspot {
|
interface Hotspot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,16 +42,23 @@ interface SlideNotesTarget {
|
|||||||
sceneId?: string;
|
sceneId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SlideshowManifest = NonNullable<ReturnType<typeof parseSlideshowManifest>>;
|
||||||
|
|
||||||
type PlayerElement = HTMLElement & {
|
type PlayerElement = HTMLElement & {
|
||||||
seek(t: number): void;
|
seek(t: number): void;
|
||||||
play(): void;
|
play(): void;
|
||||||
pause(): void;
|
pause(): void;
|
||||||
stopMedia?(): void;
|
stopMedia?(): void;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
|
readonly iframeElement?: HTMLIFrameElement;
|
||||||
readonly currentTime: number;
|
readonly currentTime: number;
|
||||||
readonly ready: boolean;
|
readonly ready: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SlideshowMediaElement = HTMLMediaElement & {
|
||||||
|
dataset: DOMStringMap;
|
||||||
|
};
|
||||||
|
|
||||||
function isPlayerElement(el: HTMLElement): el is PlayerElement {
|
function isPlayerElement(el: HTMLElement): el is PlayerElement {
|
||||||
return (
|
return (
|
||||||
typeof (el as PlayerElement).seek === "function" &&
|
typeof (el as PlayerElement).seek === "function" &&
|
||||||
@@ -67,8 +80,12 @@ function injectKeyframesOnce(): void {
|
|||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.35), 0 4px 16px rgba(0,0,0,0.35); }
|
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); }
|
50% { box-shadow: 0 0 0 8px rgba(255,255,255,0), 0 4px 20px rgba(0,0,0,0.45); }
|
||||||
}
|
}
|
||||||
|
@keyframes hf-nav-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hf-hotspot-pill { animation: none !important; }
|
.hf-hotspot-pill,
|
||||||
|
.hf-nav-spinner { animation: none !important; }
|
||||||
}
|
}
|
||||||
/* Nav-button hover (replaces inline onmouseover/onmouseout — CSP-safe).
|
/* Nav-button hover (replaces inline onmouseover/onmouseout — CSP-safe).
|
||||||
!important beats the inline base color set on each button. */
|
!important beats the inline base color set on each button. */
|
||||||
@@ -76,6 +93,47 @@ function injectKeyframesOnce(): void {
|
|||||||
background: rgba(255,255,255,0.12) !important;
|
background: rgba(255,255,255,0.12) !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]::before,
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%) translateY(3px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]::before {
|
||||||
|
content: "";
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: rgba(12,12,14,0.95);
|
||||||
|
}
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]::after {
|
||||||
|
content: attr(data-hf-tooltip);
|
||||||
|
bottom: calc(100% + 14px);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(12,12,14,0.95);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]:hover::before,
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]:hover::after,
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]:focus-visible::before,
|
||||||
|
[data-hf-nav-cluster] button[data-hf-tooltip]:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
/* When muted, the speaker button stays dimmed on hover so the mute-state
|
/* When muted, the speaker button stays dimmed on hover so the mute-state
|
||||||
affordance isn't erased (higher specificity than the rule above). */
|
affordance isn't erased (higher specificity than the rule above). */
|
||||||
[data-hf-muted] [data-hf-mute]:hover {
|
[data-hf-muted] [data-hf-mute]:hover {
|
||||||
@@ -89,6 +147,9 @@ function injectKeyframesOnce(): void {
|
|||||||
// so onFsChange can swap just this glyph without re-rendering the whole chrome.
|
// so onFsChange can swap just this glyph without re-rendering the whole chrome.
|
||||||
const ENTER_FS_SVG = `<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"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>`;
|
const ENTER_FS_SVG = `<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"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>`;
|
||||||
const EXIT_FS_SVG = `<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"><path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3"/></svg>`;
|
const EXIT_FS_SVG = `<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"><path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3"/></svg>`;
|
||||||
|
const PRESENT_SVG = `<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"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="M10 8.5v4l4-2-4-2z"/></svg>`;
|
||||||
|
const COUNTER_FONT_FAMILY =
|
||||||
|
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
||||||
|
|
||||||
export class HyperframesSlideshow extends HTMLElement {
|
export class HyperframesSlideshow extends HTMLElement {
|
||||||
private controller: ControllerLike | null = null;
|
private controller: ControllerLike | null = null;
|
||||||
@@ -99,11 +160,18 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
private channel: SlideshowChannel | null = null;
|
private channel: SlideshowChannel | null = null;
|
||||||
private presenterStartMs: number | null = null;
|
private presenterStartMs: number | null = null;
|
||||||
private presenterInterval: ReturnType<typeof setInterval> | null = null;
|
private presenterInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private presenterPositionTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
private disconnected = false;
|
private disconnected = false;
|
||||||
private initTimer: ReturnType<typeof setTimeout> | null = null;
|
private initTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private initInFlight = false;
|
private initInFlight = false;
|
||||||
private initGeneration = 0;
|
private initGeneration = 0;
|
||||||
private _muted = false;
|
private _muted = false;
|
||||||
|
private mediaWireInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private applyingRemoteMedia = false;
|
||||||
|
private lastMediaTimeBroadcastMs = 0;
|
||||||
|
private audienceMutedPlaybackKeys = new Set<string>();
|
||||||
|
private blockedAudienceMedia = new Map<string, PresenterMediaMessage>();
|
||||||
|
private audienceMediaUnlockButton: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
/** Whether audio is currently muted. Reflects `data-hf-muted` attribute. */
|
/** Whether audio is currently muted. Reflects `data-hf-muted` attribute. */
|
||||||
get muted(): boolean {
|
get muted(): boolean {
|
||||||
@@ -180,10 +248,19 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
this.chrome = null;
|
this.chrome = null;
|
||||||
this.channel?.destroy();
|
this.channel?.destroy();
|
||||||
this.channel = null;
|
this.channel = null;
|
||||||
|
if (this.mediaWireInterval !== null) {
|
||||||
|
clearInterval(this.mediaWireInterval);
|
||||||
|
this.mediaWireInterval = null;
|
||||||
|
}
|
||||||
|
this.audienceMediaUnlockButton?.remove();
|
||||||
|
this.audienceMediaUnlockButton = null;
|
||||||
|
this.audienceMutedPlaybackKeys.clear();
|
||||||
|
this.blockedAudienceMedia.clear();
|
||||||
if (this.presenterInterval !== null) {
|
if (this.presenterInterval !== null) {
|
||||||
clearInterval(this.presenterInterval);
|
clearInterval(this.presenterInterval);
|
||||||
this.presenterInterval = null;
|
this.presenterInterval = null;
|
||||||
}
|
}
|
||||||
|
this.clearPresenterPositionTimers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam: inject a controller without a live player. */
|
/** Test seam: inject a controller without a live player. */
|
||||||
@@ -196,11 +273,15 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
* Audience window URL: current page URL with `mode=audience` query param.
|
* Audience window URL: current page URL with `mode=audience` query param.
|
||||||
*/
|
*/
|
||||||
present(): void {
|
present(): void {
|
||||||
|
if (this.resolveMode() === "audience" || this.getAttribute("data-hf-presenting") === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const sep = location.search ? "&" : "?";
|
const sep = location.search ? "&" : "?";
|
||||||
// noopener,noreferrer: the audience window must not get a reference back to
|
// noopener,noreferrer: the audience window must not get a reference back to
|
||||||
// this window (it syncs over BroadcastChannel, not window.opener).
|
// this window (it syncs over BroadcastChannel, not window.opener).
|
||||||
window.open(location.href + sep + "mode=audience", "_blank", "noopener,noreferrer");
|
window.open(location.href + sep + "mode=audience", "_blank", "noopener,noreferrer");
|
||||||
this.setAttribute("data-hf-presenting", "true");
|
this.setAttribute("data-hf-presenting", "true");
|
||||||
|
this.postCurrentPresenterPositionBurst();
|
||||||
this.presenterStartMs = Date.now();
|
this.presenterStartMs = Date.now();
|
||||||
if (this.presenterInterval === null) {
|
if (this.presenterInterval === null) {
|
||||||
this.presenterInterval = setInterval(() => this.updateElapsed(), 1000);
|
this.presenterInterval = setInterval(() => this.updateElapsed(), 1000);
|
||||||
@@ -224,14 +305,22 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
private initChannel(): void {
|
private initChannel(): void {
|
||||||
const mode = this.resolveMode();
|
const mode = this.resolveMode();
|
||||||
if (mode === "audience") {
|
if (mode === "audience") {
|
||||||
this.channel = new SlideshowChannel("audience", (msg) => {
|
this.channel = new SlideshowChannel(
|
||||||
if (!this.controller) return;
|
"audience",
|
||||||
this.controller.syncTo?.(msg.sequenceId, msg.slideIndex, msg.fragmentIndex);
|
(msg) => {
|
||||||
});
|
if (!this.controller) return;
|
||||||
|
this.controller.syncTo?.(msg.sequenceId, msg.slideIndex, msg.fragmentIndex);
|
||||||
|
},
|
||||||
|
this.onRemoteMedia,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.channel = new SlideshowChannel("presenter", () => {
|
this.channel = new SlideshowChannel(
|
||||||
// presenter channel does not receive; posting happens in bindController
|
"presenter",
|
||||||
});
|
() => {
|
||||||
|
// presenter channel does not receive goto messages; posting happens in bindController.
|
||||||
|
},
|
||||||
|
this.onRemoteMedia,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +333,6 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
try {
|
try {
|
||||||
const playerEl = this.querySelector("hyperframes-player");
|
const playerEl = this.querySelector("hyperframes-player");
|
||||||
if (!playerEl || !(playerEl instanceof HTMLElement)) return;
|
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;
|
const html = this.innerHTML;
|
||||||
let manifest: ReturnType<typeof parseSlideshowManifest>;
|
let manifest: ReturnType<typeof parseSlideshowManifest>;
|
||||||
@@ -261,6 +344,15 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
}
|
}
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
|
|
||||||
|
this.renderInitialChrome(manifest);
|
||||||
|
|
||||||
|
if (!isPlayerElement(playerEl)) return;
|
||||||
|
|
||||||
|
await waitForReady(playerEl);
|
||||||
|
|
||||||
|
// Guard: if a disconnect or reconnect happened while waiting, bail out.
|
||||||
|
if (gen !== this.initGeneration) return;
|
||||||
|
|
||||||
// Wait for scenes to be populated (the runtime "timeline" postMessage
|
// Wait for scenes to be populated (the runtime "timeline" postMessage
|
||||||
// arrives ~1000ms after waitForReady resolves). Graceful fallback to []
|
// arrives ~1000ms after waitForReady resolves). Graceful fallback to []
|
||||||
// on timeout so explicit startTime/endTime slides still work.
|
// on timeout so explicit startTime/endTime slides still work.
|
||||||
@@ -321,10 +413,27 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderInitialChrome(manifest: SlideshowManifest): void {
|
||||||
|
if (this.controller || manifest.slides.length === 0) return;
|
||||||
|
const counter = { index: 1, total: manifest.slides.length };
|
||||||
|
if (this.resolveMode() === "audience") {
|
||||||
|
this.paintChrome(this.buildNavCluster(counter, "28px", "fs-only"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.paintChrome(
|
||||||
|
this.buildNavCluster(counter, "28px", "full", {
|
||||||
|
canPrev: false,
|
||||||
|
canNext: manifest.slides.length > 1,
|
||||||
|
loading: manifest.slides.length > 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private bindController(c: ControllerLike): void {
|
private bindController(c: ControllerLike): void {
|
||||||
this.offChange?.();
|
this.offChange?.();
|
||||||
this.controller?.dispose?.();
|
this.controller?.dispose?.();
|
||||||
this.controller = c;
|
this.controller = c;
|
||||||
|
this.startMediaSync();
|
||||||
this.offChange = c.onChange(() => {
|
this.offChange = c.onChange(() => {
|
||||||
// Presenter posts position to channel on every change
|
// Presenter posts position to channel on every change
|
||||||
if (this.resolveMode() !== "audience" && this.channel) {
|
if (this.resolveMode() !== "audience" && this.channel) {
|
||||||
@@ -339,6 +448,231 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private postCurrentPresenterPosition(): void {
|
||||||
|
if (this.resolveMode() === "audience" || !this.channel || !this.controller) return;
|
||||||
|
this.channel.postPosition(this.controller.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private postCurrentPresenterPositionBurst(): void {
|
||||||
|
this.clearPresenterPositionTimers();
|
||||||
|
this.postCurrentPresenterPosition();
|
||||||
|
for (const delay of [250, 750, 1500, 3000, 5000]) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.presenterPositionTimers = this.presenterPositionTimers.filter(
|
||||||
|
(item) => item !== timer,
|
||||||
|
);
|
||||||
|
this.postCurrentPresenterPosition();
|
||||||
|
}, delay);
|
||||||
|
this.presenterPositionTimers.push(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPresenterPositionTimers(): void {
|
||||||
|
for (const timer of this.presenterPositionTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.presenterPositionTimers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMediaSync(): void {
|
||||||
|
this.wireSlideshowMedia();
|
||||||
|
if (this.mediaWireInterval === null) {
|
||||||
|
// Same-origin player iframes can hydrate media after the slideshow binds.
|
||||||
|
// The dataset guard prevents duplicate listeners, and removed iframe nodes
|
||||||
|
// are collectable because this component keeps no media element references.
|
||||||
|
this.mediaWireInterval = setInterval(() => this.wireSlideshowMedia(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mediaPlayerElements(): (Partial<PlayerElement> & HTMLElement)[] {
|
||||||
|
return Array.from(this.querySelectorAll("hyperframes-player")).filter(
|
||||||
|
(player): player is Partial<PlayerElement> & HTMLElement => player instanceof HTMLElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private playerFrameDocument(player: Partial<PlayerElement> & HTMLElement): Document | null {
|
||||||
|
const frame = player.iframeElement;
|
||||||
|
if (!(frame instanceof HTMLIFrameElement)) return null;
|
||||||
|
try {
|
||||||
|
return frame.contentDocument;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mediaKey(
|
||||||
|
player: HTMLElement,
|
||||||
|
playerIndex: number,
|
||||||
|
media: SlideshowMediaElement,
|
||||||
|
mediaIndex: number,
|
||||||
|
): string {
|
||||||
|
const playerKey = player.id ? `player-id:${player.id}` : `player:${playerIndex}`;
|
||||||
|
const mediaKey = media.id ? `id:${media.id}` : `${media.tagName.toLowerCase()}:${mediaIndex}`;
|
||||||
|
return `${playerKey}|${mediaKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mediaEntries(): { key: string; el: SlideshowMediaElement }[] {
|
||||||
|
const entries: { key: string; el: SlideshowMediaElement }[] = [];
|
||||||
|
this.mediaPlayerElements().forEach((player, playerIndex) => {
|
||||||
|
const doc = this.playerFrameDocument(player);
|
||||||
|
if (!doc) return;
|
||||||
|
Array.from(doc.querySelectorAll("video,audio"))
|
||||||
|
.filter(isSlideshowMediaElement)
|
||||||
|
.forEach((el, mediaIndex) => {
|
||||||
|
entries.push({ key: this.mediaKey(player, playerIndex, el, mediaIndex), el });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private wireSlideshowMedia(): void {
|
||||||
|
const actions: PresenterMediaAction[] = [
|
||||||
|
"play",
|
||||||
|
"pause",
|
||||||
|
"seeking",
|
||||||
|
"seeked",
|
||||||
|
"ratechange",
|
||||||
|
"volumechange",
|
||||||
|
"ended",
|
||||||
|
"timeupdate",
|
||||||
|
];
|
||||||
|
for (const { key, el } of this.mediaEntries()) {
|
||||||
|
if (el.dataset.hfSlideshowMediaSync === "1") continue;
|
||||||
|
el.dataset.hfSlideshowMediaSync = "1";
|
||||||
|
for (const action of actions) {
|
||||||
|
el.addEventListener(action, () => this.publishMediaState(el, key, action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishMediaState(
|
||||||
|
el: SlideshowMediaElement,
|
||||||
|
key: string,
|
||||||
|
action: PresenterMediaAction,
|
||||||
|
): void {
|
||||||
|
if (this.applyingRemoteMedia || this.resolveMode() === "audience" || !this.channel) return;
|
||||||
|
if (action === "timeupdate") {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - this.lastMediaTimeBroadcastMs < 450 && !el.paused) return;
|
||||||
|
this.lastMediaTimeBroadcastMs = now;
|
||||||
|
}
|
||||||
|
this.channel.postMedia({
|
||||||
|
key,
|
||||||
|
action,
|
||||||
|
currentTime: finiteMediaNumber(el.currentTime, 0),
|
||||||
|
paused: el.paused,
|
||||||
|
ended: el.ended,
|
||||||
|
muted: el.muted,
|
||||||
|
volume: finiteMediaNumber(el.volume, 1),
|
||||||
|
playbackRate: finiteMediaNumber(el.playbackRate, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRemoteMedia = (msg: PresenterMediaMessage): void => {
|
||||||
|
if (this.resolveMode() !== "audience") return;
|
||||||
|
if (this.blockedAudienceMedia.has(msg.key) && msg.action === "timeupdate") {
|
||||||
|
this.blockedAudienceMedia.set(msg.key, msg);
|
||||||
|
this.showAudienceMediaUnlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wireSlideshowMedia();
|
||||||
|
const entry = this.mediaEntries().find((candidate) => candidate.key === msg.key);
|
||||||
|
if (!entry) return;
|
||||||
|
this.applyingRemoteMedia = true;
|
||||||
|
try {
|
||||||
|
this.applyRemoteMedia(entry.el, msg);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.applyingRemoteMedia = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private applyRemoteMedia(el: SlideshowMediaElement, msg: PresenterMediaMessage): void {
|
||||||
|
if (msg.action === "pause" || msg.action === "ended") {
|
||||||
|
this.audienceMutedPlaybackKeys.delete(msg.key);
|
||||||
|
this.blockedAudienceMedia.delete(msg.key);
|
||||||
|
this.syncRemoteMediaState(el, msg, true);
|
||||||
|
el.pause();
|
||||||
|
this.hideAudienceMediaUnlockIfClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteWantsPlayback =
|
||||||
|
msg.action === "play" || (msg.action === "timeupdate" && msg.paused === false && el.paused);
|
||||||
|
if (remoteWantsPlayback) {
|
||||||
|
this.playAudienceMediaMuted(el, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncRemoteMediaState(el, msg, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncRemoteMediaState(
|
||||||
|
el: SlideshowMediaElement,
|
||||||
|
msg: PresenterMediaMessage,
|
||||||
|
allowTimeSync: boolean,
|
||||||
|
): void {
|
||||||
|
el.playbackRate = finiteMediaNumber(msg.playbackRate, 1);
|
||||||
|
el.volume = Math.max(0, Math.min(1, finiteMediaNumber(msg.volume, 1)));
|
||||||
|
el.muted = this.audienceMutedPlaybackKeys.has(msg.key) ? true : msg.muted;
|
||||||
|
if (
|
||||||
|
allowTimeSync &&
|
||||||
|
Number.isFinite(msg.currentTime) &&
|
||||||
|
Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35
|
||||||
|
) {
|
||||||
|
el.currentTime = Math.max(0, msg.currentTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private playAudienceMediaMuted(el: SlideshowMediaElement, msg: PresenterMediaMessage): void {
|
||||||
|
this.audienceMutedPlaybackKeys.add(msg.key);
|
||||||
|
this.syncRemoteMediaState(el, msg, true);
|
||||||
|
el.muted = true;
|
||||||
|
try {
|
||||||
|
void el
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
this.blockedAudienceMedia.delete(msg.key);
|
||||||
|
this.hideAudienceMediaUnlockIfClear();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.blockedAudienceMedia.set(msg.key, msg);
|
||||||
|
this.showAudienceMediaUnlock();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.blockedAudienceMedia.set(msg.key, msg);
|
||||||
|
this.showAudienceMediaUnlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryBlockedAudienceMedia = (): void => {
|
||||||
|
this.wireSlideshowMedia();
|
||||||
|
const entries = new Map(this.mediaEntries().map((entry) => [entry.key, entry.el]));
|
||||||
|
for (const [key, msg] of this.blockedAudienceMedia) {
|
||||||
|
const el = entries.get(key);
|
||||||
|
if (el) this.playAudienceMediaMuted(el, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private showAudienceMediaUnlock(): void {
|
||||||
|
if (this.resolveMode() !== "audience" || this.audienceMediaUnlockButton) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Play audience media muted";
|
||||||
|
button.style.cssText =
|
||||||
|
"position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;pointer-events:auto;";
|
||||||
|
button.addEventListener("click", this.retryBlockedAudienceMedia);
|
||||||
|
this.appendChild(button);
|
||||||
|
this.audienceMediaUnlockButton = button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideAudienceMediaUnlockIfClear(): void {
|
||||||
|
if (this.blockedAudienceMedia.size > 0 || !this.audienceMediaUnlockButton) return;
|
||||||
|
this.audienceMediaUnlockButton.remove();
|
||||||
|
this.audienceMediaUnlockButton = null;
|
||||||
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
private onKey = (e: KeyboardEvent): void => {
|
private onKey = (e: KeyboardEvent): void => {
|
||||||
if (!this.controller) return;
|
if (!this.controller) return;
|
||||||
@@ -380,6 +714,10 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
if (!focused) return;
|
if (!focused) return;
|
||||||
this.toggleFullscreen();
|
this.toggleFullscreen();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
} else if ((e.key === "p" || e.key === "P") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
if (!ambient || !this.shouldShowPresentControl()) return;
|
||||||
|
this.present();
|
||||||
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,7 +818,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
this.wireChromeButtons();
|
this.wireChromeButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the nav cluster ([mute?] [prev] counter [next] | [fullscreen]) as a
|
// Builds the nav cluster ([mute?] [prev] counter [next] | [present?] [fullscreen]) as a
|
||||||
// floating capsule. `bottomCss` positions it (normal view: "28px"; presenter
|
// floating capsule. `bottomCss` positions it (normal view: "28px"; presenter
|
||||||
// view: above the notes panel). Reused by render() and renderPresenter().
|
// view: above the notes panel). Reused by render() and renderPresenter().
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -488,11 +826,12 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
counter: { index: number; total: number },
|
counter: { index: number; total: number },
|
||||||
bottomCss: string,
|
bottomCss: string,
|
||||||
variant: "full" | "fs-only" = "full",
|
variant: "full" | "fs-only" = "full",
|
||||||
|
options: { canPrev?: boolean; canNext?: boolean; loading?: boolean } = {},
|
||||||
): string {
|
): string {
|
||||||
const c = this.controller;
|
const c = this.controller;
|
||||||
if (!c) return "";
|
const showPrev = options.canPrev ?? c?.canPrev ?? true;
|
||||||
const showPrev = c.canPrev !== false;
|
const showNext = options.canNext ?? c?.canNext ?? true;
|
||||||
const showNext = c.canNext !== false;
|
const showLoading = options.loading === true && showNext;
|
||||||
const showSound = this.hasAttribute("sound");
|
const showSound = this.hasAttribute("sound");
|
||||||
const btnStyle =
|
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;";
|
"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;";
|
||||||
@@ -503,6 +842,8 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
data-hf-mute
|
data-hf-mute
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="${this._muted ? "Unmute" : "Mute"}"
|
aria-label="${this._muted ? "Unmute" : "Mute"}"
|
||||||
|
title="${this._muted ? "Unmute" : "Mute"}"
|
||||||
|
data-hf-tooltip="${this._muted ? "Unmute" : "Mute"}"
|
||||||
aria-pressed="${this._muted ? "true" : "false"}"
|
aria-pressed="${this._muted ? "true" : "false"}"
|
||||||
style="${btnStyle}${this._muted ? "color:rgba(255,255,255,0.45);" : ""}"
|
style="${btnStyle}${this._muted ? "color:rgba(255,255,255,0.45);" : ""}"
|
||||||
>${this._muted ? speakerMutedSvg : speakerSvg}</button>`
|
>${this._muted ? speakerMutedSvg : speakerSvg}</button>`
|
||||||
@@ -512,20 +853,47 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
data-hf-prev
|
data-hf-prev
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Previous slide"
|
aria-label="Previous slide"
|
||||||
|
title="Previous slide"
|
||||||
|
data-hf-tooltip="Previous slide"
|
||||||
style="${btnStyle}" >‹</button>`
|
style="${btnStyle}" >‹</button>`
|
||||||
: "";
|
: "";
|
||||||
const nextBtnHtml = showNext
|
const loadingHtml = showLoading
|
||||||
? `<button
|
? `<span
|
||||||
|
data-hf-nav-loading
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading slides"
|
||||||
|
title="Loading slides"
|
||||||
|
style="${btnStyle}cursor:progress;color:rgba(255,255,255,0.72);"
|
||||||
|
><span class="hf-nav-spinner" aria-hidden="true" style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.32);border-top-color:rgba(255,255,255,0.92);border-radius:999px;animation:hf-nav-spin 0.8s linear infinite;"></span></span>`
|
||||||
|
: "";
|
||||||
|
const nextBtnHtml = showLoading
|
||||||
|
? loadingHtml
|
||||||
|
: showNext
|
||||||
|
? `<button
|
||||||
data-hf-next
|
data-hf-next
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Next slide"
|
aria-label="Next slide"
|
||||||
|
title="Next slide"
|
||||||
|
data-hf-tooltip="Next slide"
|
||||||
style="${btnStyle}" >›</button>`
|
style="${btnStyle}" >›</button>`
|
||||||
|
: "";
|
||||||
|
const presentBtnHtml = this.shouldShowPresentControl()
|
||||||
|
? `<button
|
||||||
|
data-hf-present
|
||||||
|
type="button"
|
||||||
|
aria-label="Present"
|
||||||
|
title="Present"
|
||||||
|
data-hf-tooltip="Present"
|
||||||
|
style="${btnStyle}" >${PRESENT_SVG}</button>`
|
||||||
: "";
|
: "";
|
||||||
const isFs = document.fullscreenElement === this;
|
const isFs = document.fullscreenElement === this;
|
||||||
|
const fsLabel = isFs ? "Exit full screen" : "Full screen";
|
||||||
const fsBtnHtml = `<button
|
const fsBtnHtml = `<button
|
||||||
data-hf-fullscreen
|
data-hf-fullscreen
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="${isFs ? "Exit full screen" : "Full screen"}"
|
aria-label="${fsLabel}"
|
||||||
|
title="${fsLabel}"
|
||||||
|
data-hf-tooltip="${fsLabel}"
|
||||||
aria-pressed="${isFs ? "true" : "false"}"
|
aria-pressed="${isFs ? "true" : "false"}"
|
||||||
style="${btnStyle}" >${isFs ? EXIT_FS_SVG : ENTER_FS_SVG}</button>`;
|
style="${btnStyle}" >${isFs ? EXIT_FS_SVG : ENTER_FS_SVG}</button>`;
|
||||||
// Audience/viewer: only the fullscreen control (no navigation).
|
// Audience/viewer: only the fullscreen control (no navigation).
|
||||||
@@ -549,10 +917,11 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
<span
|
<span
|
||||||
data-hf-counter
|
data-hf-counter
|
||||||
aria-label="Slide ${counter.index} of ${counter.total}"
|
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;"
|
style="min-width:46px;text-align:center;color:rgba(255,255,255,0.9);font-family:${COUNTER_FONT_FAMILY};font-size:13px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;padding:0 ${counterPadRight} 0 ${counterPadLeft};user-select:none;"
|
||||||
>${counter.index} / ${counter.total}</span>
|
>${counter.index} / ${counter.total}</span>
|
||||||
${nextBtnHtml}
|
${nextBtnHtml}
|
||||||
<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>
|
<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>
|
||||||
|
${presentBtnHtml}
|
||||||
${fsBtnHtml}
|
${fsBtnHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -560,19 +929,29 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
private wireChromeButtons(): void {
|
private wireChromeButtons(): void {
|
||||||
const chrome = this.chrome;
|
const chrome = this.chrome;
|
||||||
if (!chrome) return;
|
if (!chrome) return;
|
||||||
const muteBtn = chrome.querySelector("[data-hf-mute]");
|
this.wireChromeClick(chrome, "[data-hf-mute]", () => this.toggleMute());
|
||||||
const prevBtn = chrome.querySelector("[data-hf-prev]");
|
this.wireChromeClick(chrome, "[data-hf-prev]", () => this.controller?.prev());
|
||||||
const nextBtn = chrome.querySelector("[data-hf-next]");
|
this.wireChromeClick(chrome, "[data-hf-next]", () => this.controller?.next());
|
||||||
|
this.wireChromeClick(chrome, "[data-hf-present]", () => this.present());
|
||||||
|
this.wireChromeClick(chrome, "[data-hf-fullscreen]", () => this.toggleFullscreen());
|
||||||
|
this.wirePresenterNotes(chrome);
|
||||||
|
this.wireHotspots(chrome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private wireChromeClick(chrome: HTMLDivElement, selector: string, handler: () => void): void {
|
||||||
|
const btn = chrome.querySelector(selector);
|
||||||
|
if (btn) btn.addEventListener("click", handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private wirePresenterNotes(chrome: HTMLDivElement): void {
|
||||||
const notesInput = chrome.querySelector("[data-hf-presenter-notes]");
|
const notesInput = chrome.querySelector("[data-hf-presenter-notes]");
|
||||||
if (muteBtn) muteBtn.addEventListener("click", () => this.toggleMute());
|
|
||||||
if (prevBtn) prevBtn.addEventListener("click", () => this.controller?.prev());
|
|
||||||
if (nextBtn) nextBtn.addEventListener("click", () => this.controller?.next());
|
|
||||||
if (notesInput instanceof HTMLTextAreaElement) {
|
if (notesInput instanceof HTMLTextAreaElement) {
|
||||||
const key = notesInput.getAttribute("data-hf-presenter-notes-key");
|
const key = notesInput.getAttribute("data-hf-presenter-notes-key");
|
||||||
notesInput.addEventListener("input", () => this.writePresenterNotes(key, notesInput.value));
|
notesInput.addEventListener("input", () => this.writePresenterNotes(key, notesInput.value));
|
||||||
}
|
}
|
||||||
const fsBtn = chrome.querySelector("[data-hf-fullscreen]");
|
}
|
||||||
if (fsBtn) fsBtn.addEventListener("click", () => this.toggleFullscreen());
|
|
||||||
|
private wireHotspots(chrome: HTMLDivElement): void {
|
||||||
for (const btn of chrome.querySelectorAll("[data-hotspot-id]")) {
|
for (const btn of chrome.querySelectorAll("[data-hotspot-id]")) {
|
||||||
const target = btn.getAttribute("data-hotspot-target") ?? "";
|
const target = btn.getAttribute("data-hotspot-target") ?? "";
|
||||||
btn.addEventListener("click", () => this.controller?.enterBranch?.(target));
|
btn.addEventListener("click", () => this.controller?.enterBranch?.(target));
|
||||||
@@ -586,7 +965,10 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const isFs = document.fullscreenElement === this;
|
const isFs = document.fullscreenElement === this;
|
||||||
btn.innerHTML = isFs ? EXIT_FS_SVG : ENTER_FS_SVG;
|
btn.innerHTML = isFs ? EXIT_FS_SVG : ENTER_FS_SVG;
|
||||||
btn.setAttribute("aria-label", isFs ? "Exit full screen" : "Full screen");
|
const label = isFs ? "Exit full screen" : "Full screen";
|
||||||
|
btn.setAttribute("aria-label", label);
|
||||||
|
btn.setAttribute("title", label);
|
||||||
|
btn.setAttribute("data-hf-tooltip", label);
|
||||||
btn.setAttribute("aria-pressed", isFs ? "true" : "false");
|
btn.setAttribute("aria-pressed", isFs ? "true" : "false");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -598,6 +980,10 @@ export class HyperframesSlideshow extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldShowPresentControl(): boolean {
|
||||||
|
return this.resolveMode() !== "audience" && this.getAttribute("data-hf-presenting") !== "true";
|
||||||
|
}
|
||||||
|
|
||||||
private toggleMute(): void {
|
private toggleMute(): void {
|
||||||
this._muted = !this._muted;
|
this._muted = !this._muted;
|
||||||
if (this._muted) {
|
if (this._muted) {
|
||||||
@@ -738,6 +1124,16 @@ function nextPanelText(slide: { sceneId: string; notes?: string } | null): strin
|
|||||||
: escHtml(slide.sceneId);
|
: escHtml(slide.sceneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSlideshowMediaElement(el: Element): el is SlideshowMediaElement {
|
||||||
|
const win = el.ownerDocument.defaultView;
|
||||||
|
if (!win || typeof win.HTMLMediaElement !== "function") return false;
|
||||||
|
return el instanceof win.HTMLMediaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteMediaNumber(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function readScenes(player: HTMLElement): { id: string; start: number; duration: number }[] {
|
function readScenes(player: HTMLElement): { id: string; start: number; duration: number }[] {
|
||||||
if ("scenes" in player && Array.isArray((player as { scenes: unknown }).scenes)) {
|
if ("scenes" in player && Array.isArray((player as { scenes: unknown }).scenes)) {
|
||||||
return (player as { scenes: { id: string; start: number; duration: number }[] }).scenes;
|
return (player as { scenes: { id: string; start: number; duration: number }[] }).scenes;
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ export interface PresenterPosition {
|
|||||||
fragmentIndex: number;
|
fragmentIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COUNTER_FONT_FAMILY =
|
||||||
|
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
||||||
|
|
||||||
|
export type PresenterMediaAction =
|
||||||
|
| "play"
|
||||||
|
| "pause"
|
||||||
|
| "seeking"
|
||||||
|
| "seeked"
|
||||||
|
| "ratechange"
|
||||||
|
| "volumechange"
|
||||||
|
| "ended"
|
||||||
|
| "timeupdate";
|
||||||
|
|
||||||
|
const MEDIA_ACTIONS = new Set<unknown>([
|
||||||
|
"play",
|
||||||
|
"pause",
|
||||||
|
"seeking",
|
||||||
|
"seeked",
|
||||||
|
"ratechange",
|
||||||
|
"volumechange",
|
||||||
|
"ended",
|
||||||
|
"timeupdate",
|
||||||
|
]);
|
||||||
|
const MEDIA_NUMBER_FIELDS = ["currentTime", "volume", "playbackRate"] as const;
|
||||||
|
const MEDIA_BOOLEAN_FIELDS = ["paused", "ended", "muted"] as const;
|
||||||
|
|
||||||
interface GotoMessage {
|
interface GotoMessage {
|
||||||
type: "goto";
|
type: "goto";
|
||||||
sequenceId: string;
|
sequenceId: string;
|
||||||
@@ -11,8 +37,26 @@ interface GotoMessage {
|
|||||||
fragmentIndex: number;
|
fragmentIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGotoMessage(data: unknown): data is GotoMessage {
|
export interface PresenterMediaMessage {
|
||||||
|
type: "media";
|
||||||
|
sender: "presenter" | "audience";
|
||||||
|
key: string;
|
||||||
|
action: PresenterMediaAction;
|
||||||
|
currentTime: number;
|
||||||
|
paused: boolean;
|
||||||
|
ended: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
volume: number;
|
||||||
|
playbackRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(data: unknown): data is Record<string, unknown> {
|
||||||
if (typeof data !== "object" || data === null) return false;
|
if (typeof data !== "object" || data === null) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGotoMessage(data: unknown): data is GotoMessage {
|
||||||
|
if (!isRecord(data)) return false;
|
||||||
const d = data as Record<string, unknown>;
|
const d = data as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
d["type"] === "goto" &&
|
d["type"] === "goto" &&
|
||||||
@@ -22,6 +66,35 @@ function isGotoMessage(data: unknown): data is GotoMessage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMediaAction(value: unknown): value is PresenterMediaAction {
|
||||||
|
return MEDIA_ACTIONS.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMediaSender(value: unknown): value is PresenterMediaMessage["sender"] {
|
||||||
|
return value === "presenter" || value === "audience";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMediaNumberFields(data: Record<string, unknown>): boolean {
|
||||||
|
return MEDIA_NUMBER_FIELDS.every((field) => typeof data[field] === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMediaBooleanFields(data: Record<string, unknown>): boolean {
|
||||||
|
return MEDIA_BOOLEAN_FIELDS.every((field) => typeof data[field] === "boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMediaMessage(data: unknown): data is PresenterMediaMessage {
|
||||||
|
if (!isRecord(data)) return false;
|
||||||
|
const d = data;
|
||||||
|
return (
|
||||||
|
d["type"] === "media" &&
|
||||||
|
isMediaSender(d["sender"]) &&
|
||||||
|
typeof d["key"] === "string" &&
|
||||||
|
isMediaAction(d["action"]) &&
|
||||||
|
hasMediaNumberFields(d) &&
|
||||||
|
hasMediaBooleanFields(d)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the BroadcastChannel connection for a single slideshow element.
|
* Manages the BroadcastChannel connection for a single slideshow element.
|
||||||
* Presenter (default) mode: posts position updates to the channel.
|
* Presenter (default) mode: posts position updates to the channel.
|
||||||
@@ -43,6 +116,7 @@ export class SlideshowChannel {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly mode: "presenter" | "audience",
|
private readonly mode: "presenter" | "audience",
|
||||||
private readonly onGoto: (msg: GotoMessage) => void,
|
private readonly onGoto: (msg: GotoMessage) => void,
|
||||||
|
private readonly onMedia: (msg: PresenterMediaMessage) => void = () => {},
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
this.channel = new BroadcastChannel(slideshowChannelName());
|
this.channel = new BroadcastChannel(slideshowChannelName());
|
||||||
@@ -51,13 +125,17 @@ export class SlideshowChannel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "audience") {
|
this.channel.onmessage = (e: MessageEvent) => {
|
||||||
this.channel.onmessage = (e: MessageEvent) => {
|
if (isGotoMessage(e.data)) {
|
||||||
if (isGotoMessage(e.data)) {
|
if (mode === "audience") {
|
||||||
this.onGoto(e.data);
|
this.onGoto(e.data);
|
||||||
}
|
}
|
||||||
};
|
return;
|
||||||
}
|
}
|
||||||
|
if (isMediaMessage(e.data) && e.data.sender !== mode) {
|
||||||
|
this.onMedia(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
postPosition(pos: PresenterPosition): void {
|
postPosition(pos: PresenterPosition): void {
|
||||||
@@ -66,6 +144,11 @@ export class SlideshowChannel {
|
|||||||
this.channel.postMessage(msg);
|
this.channel.postMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postMedia(msg: Omit<PresenterMediaMessage, "type" | "sender">): void {
|
||||||
|
if (!this.channel) return;
|
||||||
|
this.channel.postMessage({ type: "media", sender: this.mode, ...msg });
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.channel) {
|
if (this.channel) {
|
||||||
this.channel.onmessage = null;
|
this.channel.onmessage = null;
|
||||||
@@ -102,7 +185,7 @@ export function buildPresenterLayout(opts: {
|
|||||||
${opts.hotspots
|
${opts.hotspots
|
||||||
.map(
|
.map(
|
||||||
(h) =>
|
(h) =>
|
||||||
`<button data-hotspot-id="${escAttr(h.id)}" data-hotspot-target="${escAttr(h.target)}" type="button" style="text-align:left;background:rgba(244,183,64,0.14);color:#f4b740;border:1px solid rgba(244,183,64,0.4);border-radius:8px;padding:8px 12px;font-size:15px;cursor:pointer;pointer-events:auto;font-family:inherit;">↳ ${esc(h.label)}</button>`,
|
`<button data-hotspot-id="${escAttr(h.id)}" data-hotspot-target="${escAttr(h.target)}" type="button" title="${escAttr(h.label)}" style="text-align:left;background:rgba(244,183,64,0.14);color:#f4b740;border:1px solid rgba(244,183,64,0.4);border-radius:8px;padding:8px 12px;font-size:15px;cursor:pointer;pointer-events:auto;font-family:inherit;">↳ ${esc(h.label)}</button>`,
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
</div>`
|
</div>`
|
||||||
@@ -115,8 +198,8 @@ export function buildPresenterLayout(opts: {
|
|||||||
<div data-hf-presenter-next style="font-size:17px;opacity:.9;line-height:1.4;">${esc(opts.nextText)}</div>
|
<div data-hf-presenter-next style="font-size:17px;opacity:.9;line-height:1.4;">${esc(opts.nextText)}</div>
|
||||||
${branches}
|
${branches}
|
||||||
<div style="display:flex;gap:34px;margin-top:auto;">
|
<div style="display:flex;gap:34px;margin-top:auto;">
|
||||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Slide</div><div data-hf-presenter-counter style="font-size:23px;font-variant-numeric:tabular-nums;">${esc(opts.counterText)}</div></div>
|
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Slide</div><div data-hf-presenter-counter style="font-family:${COUNTER_FONT_FAMILY};font-size:23px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;">${esc(opts.counterText)}</div></div>
|
||||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Elapsed</div><div data-hf-presenter-elapsed style="font-size:23px;font-variant-numeric:tabular-nums;">${esc(opts.elapsedText)}</div></div>
|
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Elapsed</div><div data-hf-presenter-elapsed style="font-family:${COUNTER_FONT_FAMILY};font-size:23px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;">${esc(opts.elapsedText)}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`.trim();
|
</div>`.trim();
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ name: slideshow
|
|||||||
description: >
|
description: >
|
||||||
Author a HyperFrames slideshow composition — a presentation, pitch deck, or
|
Author a HyperFrames slideshow composition — a presentation, pitch deck, or
|
||||||
interactive deck with discrete slides, fragment reveals, branching sequences,
|
interactive deck with discrete slides, fragment reveals, branching sequences,
|
||||||
and hotspot navigation. Read when the request is to build or edit a slideshow,
|
and hotspot navigation. Use as an intent check when the user asks for a
|
||||||
presentation, or pitch deck as a HyperFrames composition.
|
presentation, pitch deck, slide deck, interactive deck, or page-to-deck
|
||||||
|
conversion that might be a slideshow; if the user did not explicitly ask for a
|
||||||
|
slideshow / slide show, confirm before authoring.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Slideshow authoring contract
|
# Slideshow authoring contract
|
||||||
@@ -13,6 +15,20 @@ A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, G
|
|||||||
|
|
||||||
**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
|
**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
|
||||||
|
|
||||||
|
## Intent confirmation
|
||||||
|
|
||||||
|
If the user explicitly asks for a slideshow, slide show, or HyperFrames slideshow, proceed with this skill.
|
||||||
|
|
||||||
|
If the skill triggered from an adjacent request such as "presentation", "pitch deck", "deck", "interactive deck", or "convert this page", pause before authoring and frame the choice before asking for confirmation. Briefly explain that a HyperFrames slideshow means a runnable deck with discrete slides, built-in navigation and presenter mode, editable speaker notes, shared media handling, and validation before handoff. For source-page conversions, also mention that the goal is to preserve the original page's visual design, interactions, motion, and media behavior while translating page movement into slide-to-slide transitions.
|
||||||
|
|
||||||
|
Then ask a short confirmation question:
|
||||||
|
|
||||||
|
> Do you want this as a HyperFrames slideshow?
|
||||||
|
|
||||||
|
Use a yes/no choice UI when the environment provides one; otherwise ask the question in plain text.
|
||||||
|
|
||||||
|
Do not implement the slideshow until the user says yes. If they say no, stop using this skill and switch to the appropriate non-slideshow workflow.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The two pieces
|
## The two pieces
|
||||||
@@ -51,6 +67,8 @@ Add exactly one `<script type="application/hyperframes-slideshow+json">` block t
|
|||||||
|
|
||||||
The island is the single source of truth for slide order, notes, fragment hold-points, hotspots, and branch sequences. Keep it near the top of the `<body>`, before the scene divs, so it is easy to find.
|
The island is the single source of truth for slide order, notes, fragment hold-points, hotspots, and branch sequences. Keep it near the top of the `<body>`, before the scene divs, so it is easy to find.
|
||||||
|
|
||||||
|
Do not hide the slideshow manifest behind an alternate `<script type="application/json">` block plus runtime code that creates the island. The `present` command reads the composition HTML statically and expects the real `application/hyperframes-slideshow+json` island to already be present.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
@@ -141,8 +159,12 @@ These are hard constraints, not suggestions. A slide that violates them will be
|
|||||||
|
|
||||||
When converting an existing page into a slideshow, source fidelity is part of the contract. Do not replace source-specific widgets with simplified approximations unless the user explicitly asks for a redesign.
|
When converting an existing page into a slideshow, source fidelity is part of the contract. Do not replace source-specific widgets with simplified approximations unless the user explicitly asks for a redesign.
|
||||||
|
|
||||||
|
- Preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. When the slideshow system supports presenter mode, include speaker notes using the shared editable-notes behavior rather than a deck-specific implementation.
|
||||||
- Port mechanical visuals from the source DOM/CSS/JS as exactly as practical: custom players, canvas visualizers, timelines, playheads, stems, expanding circles, hover states, and other interactive details should survive the conversion.
|
- Port mechanical visuals from the source DOM/CSS/JS as exactly as practical: custom players, canvas visualizers, timelines, playheads, stems, expanding circles, hover states, and other interactive details should survive the conversion.
|
||||||
- Audit the source for atypical page movement, especially behavior driven by scroll, wheel, touch, hash state, resize, or a requestAnimationFrame loop. Treat fixed viewports with translated/scaled "world" layers, parallax, pinned panels, horizontal scrollers, scroll-scrubbed timelines, section snapping, and zoom-to-element cameras as source behavior. Scroll is often the source's transition trigger, so preserve the transition by extracting its progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or a reusable player/harness hook. Do not simulate a literal page-scroll-down transition inside the slide; the viewer should feel camera travel/zoom from one focal point to another, not see a webpage being scrolled. Keep each slide-to-slide camera move continuous: avoid intermediate route stops that reverse x/y direction or zoom unless the source visibly does that at the same boundary. A transition that darts around before landing is worse than a simpler direct focal move.
|
- Treat native `<video>` / `<audio>` elements as the source of truth for any custom media chrome, canvas visualizer, waveform, beat grid, or playhead. Wire the source's media events (`play`, `pause`, `timeupdate`, `seeking`, `seeked`, `ended`, `ratechange`, `volumechange`) and derive visual state from `media.currentTime`; do not run a separate timer that can drift away from actual playback.
|
||||||
|
- Every copied `<video>` or `<audio>` with `src` must have HyperFrames timing attributes before lint: `data-start` and `data-duration`, plus `data-has-audio="true"` when audible native audio should be preserved. Use the scene's time range for slide-specific media; for user-controlled evidence videos that may be played from multiple focused slides, use a deck-wide range. Do not leave `preload="none"` on media; use `metadata` or `auto`.
|
||||||
|
- Resolve source font tokens before validation. If preserving custom source fonts, add `@font-face` rules for local/captured font files. If using system fallbacks, replace tokenized declarations such as `font-family: var(--f-body)` with concrete render-safe stacks such as `system-ui, sans-serif` or `ui-monospace, monospace`; do not leave `var(...)` as the font family value.
|
||||||
|
- Audit the source for atypical page movement, especially behavior driven by scroll, wheel, touch, hash state, resize, or a requestAnimationFrame loop. Treat fixed viewports with translated/scaled "world" layers, parallax, pinned panels, horizontal scrollers, scroll-scrubbed timelines, section snapping, and zoom-to-element cameras as source behavior. Scroll is often the source's transition trigger, so preserve the transition by extracting its progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or a reusable player/harness hook. Standalone wrappers that jump to slide hold-points still need an explicit navigation-camera transition hook; computing per-slide camera transforms is not enough. Do not simulate a literal page-scroll-down transition inside the slide; the viewer should feel camera travel/zoom from one focal point to another, not see a webpage being scrolled. Keep each slide-to-slide camera move continuous: avoid intermediate route stops that reverse x/y direction or zoom unless the source visibly does that at the same boundary. A transition that darts around before landing is worse than a simpler direct focal move.
|
||||||
- Preserve the source's media crop semantics. Treat screenshots, tweets/social posts, product UI captures, charts, docs, code, leaderboards, and any image with readable text as content evidence, not decorative media: use the source aspect ratio (`height: auto`) or `object-fit: contain` inside a stable frame. Use `object-fit: cover` only when the source did, or for intentionally decorative/background/cinematic thumbnails. After fitting these captures into a slide, inspect all four edges for truncated text, logos, controls, or captions; a visible crop on meaningful content is a bug unless the source itself cropped it.
|
- Preserve the source's media crop semantics. Treat screenshots, tweets/social posts, product UI captures, charts, docs, code, leaderboards, and any image with readable text as content evidence, not decorative media: use the source aspect ratio (`height: auto`) or `object-fit: contain` inside a stable frame. Use `object-fit: cover` only when the source did, or for intentionally decorative/background/cinematic thumbnails. After fitting these captures into a slide, inspect all four edges for truncated text, logos, controls, or captions; a visible crop on meaningful content is a bug unless the source itself cropped it.
|
||||||
- If a behavior is generic to slideshows, put it in the player/controller or in a reusable skill snippet. Do not solve it with one-off deck scripts.
|
- If a behavior is generic to slideshows, put it in the player/controller or in a reusable skill snippet. Do not solve it with one-off deck scripts.
|
||||||
- Stacked scene frames must never block interaction on the active slide. Hidden frames need both visual hiding and event gating:
|
- Stacked scene frames must never block interaction on the active slide. Hidden frames need both visual hiding and event gating:
|
||||||
@@ -379,13 +401,49 @@ Wrap the composition in `<hyperframes-slideshow>` around `<hyperframes-player>`
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<hyperframes-slideshow>
|
<hyperframes-slideshow>
|
||||||
<hyperframes-player src="deck.html"></hyperframes-player>
|
<hyperframes-player interactive src="deck.html"></hyperframes-player>
|
||||||
</hyperframes-slideshow>
|
</hyperframes-slideshow>
|
||||||
```
|
```
|
||||||
|
|
||||||
`<hyperframes-slideshow>` provides the navigation chrome (Prev / Next buttons, progress dots, breadcrumb, counter), keyboard handling (← / → and Space / Backspace), touch swipe, and hotspot overlays.
|
`<hyperframes-slideshow>` provides the navigation chrome (Present, Prev / Next, counter, global mute when `sound` is present, fullscreen), keyboard handling (← / →, Space / Backspace, and P for Present), touch swipe, and hotspot overlays.
|
||||||
|
|
||||||
**Presenter mode:** the Present button calls `window.open('?mode=audience')` for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via `BroadcastChannel('hf-slideshow')`.
|
Use the `interactive` attribute whenever the source page contains clickable controls, links, native media controls, or custom players. Without it, `<hyperframes-player>` intentionally blocks iframe pointer events; media controls inside the composition cannot be clicked, and clicks on the player host can toggle timeline playback instead of interacting with the slide content.
|
||||||
|
|
||||||
|
**Presenter mode:** use the built-in Present icon button in the slideshow nav capsule, or press P. It calls `window.open('?mode=audience')` for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via `BroadcastChannel('hf-slideshow:' + location.pathname)`. Do not add a custom wrapper-level Present button; the shared component owns its placement, icon, styling, and audience-mode hiding.
|
||||||
|
|
||||||
|
Presenter-driven media playback has an autoplay-policy constraint: `BroadcastChannel` can sync intent, time, and state, but it cannot transfer the presenter's user activation to the audience window. The shared slideshow player mirrors native media events and starts remote audience playback muted first; only fall back to the standalone harness's audience unlock behavior if muted `media.play()` is rejected or if the deck specifically requires audible audience playback. Do not keep applying remote `timeupdate` messages after a rejected play, or the audience will silently seek through the video without playback.
|
||||||
|
|
||||||
|
Presenter notes are editable in the presenter view. Edits are stored in `localStorage` per deck and slide, layered over the manifest notes without rewriting the composition file. Do not add one-off note-editing scripts to decks; rely on the shared slideshow player behavior. If a standalone/custom wrapper truly needs to implement this outside the shared player, use the deterministic storage snippet in `skills/slideshow/references/standalone-harness.md`.
|
||||||
|
|
||||||
|
### Media cleanup on slide exit
|
||||||
|
|
||||||
|
The slideshow controller owns slide-exit media cleanup. When navigation changes slide or sequence, it calls `hyperframes-player.stopMedia()` before entering the next slide. That command:
|
||||||
|
|
||||||
|
- posts `stop-media` to the iframe runtime, which stops WebAudio and pauses native `<video>` / `<audio>` elements;
|
||||||
|
- pauses same-origin iframe media directly as a fallback; and
|
||||||
|
- pauses parent-frame proxies adopted from iframe media.
|
||||||
|
|
||||||
|
Same-slide fragment navigation does **not** stop media. Global/deck-level parent audio, such as a background track wired through `audio-src`, is not treated as slide media.
|
||||||
|
|
||||||
|
Do not add per-slide cleanup scripts for normal media players. Keep slide video/audio as normal media in the composition; use `data-has-audio="true"` only when the player should preserve audible native video audio instead of treating it as silent visual media.
|
||||||
|
|
||||||
|
If the source page has custom controls or visualizations attached to media, those controls must listen to the same native element the slideshow player stops and mutes. A pause caused by slide exit, presenter sync, native controls, custom controls, or the global mute button should all update the visible custom UI through media events, not through parallel state.
|
||||||
|
|
||||||
|
When implementing direct iframe fallback cleanup, treat iframe media as cross-realm DOM. Do not test iframe nodes with the parent page's `el instanceof HTMLMediaElement`; that returns false in real browsers. Use `el.ownerDocument.defaultView.HTMLMediaElement` (or an equivalent tag/duck-type guard) before setting `muted` or calling `pause()`.
|
||||||
|
|
||||||
|
### Global nav mute
|
||||||
|
|
||||||
|
When `<hyperframes-slideshow sound>` renders the nav mute button, that button is the global mute control for the page. It must mute:
|
||||||
|
|
||||||
|
- child `<hyperframes-player>` instances, including same-origin iframe media;
|
||||||
|
- top-level page `<audio>` / `<video>` elements; and
|
||||||
|
- wrapper-owned SFX/global `Audio` objects via the `hf-sound` event.
|
||||||
|
|
||||||
|
Do not add a second mute button inside the composition. If a wrapper script creates `new Audio(...)` objects that are not attached to the DOM, it must listen for `hf-sound` and set `clip.muted = detail.muted` on each object, not merely skip future plays.
|
||||||
|
|
||||||
|
The same cross-realm rule applies here: global mute must reach iframe `<video>` / `<audio>` elements through the child frame's DOM realm. A passing unit test in a single DOM realm is not enough; verify in a browser that the actual iframe media elements report `muted: true` after clicking the nav mute button.
|
||||||
|
|
||||||
|
`hyperframes present` serves built bundles from `packages/player/dist`. After changing player or slideshow chrome behavior, run `bun run build` in `packages/player` and restart the present server before testing in a browser.
|
||||||
|
|
||||||
Presenter notes are editable in the presenter view. Edits are stored in `localStorage` per deck and slide, layered over the manifest notes without rewriting the composition file. Do not add one-off note-editing scripts to decks; rely on the shared slideshow player behavior. If a standalone/custom wrapper truly needs to implement this outside the shared player, use the deterministic storage snippet in `skills/slideshow/references/standalone-harness.md`.
|
Presenter notes are editable in the presenter view. Edits are stored in `localStorage` per deck and slide, layered over the manifest notes without rewriting the composition file. Do not add one-off note-editing scripts to decks; rely on the shared slideshow player behavior. If a standalone/custom wrapper truly needs to implement this outside the shared player, use the deterministic storage snippet in `skills/slideshow/references/standalone-harness.md`.
|
||||||
|
|
||||||
@@ -431,6 +489,33 @@ skills/slideshow/references/standalone-harness.md
|
|||||||
|
|
||||||
Do not treat the patterns there as the blessed model — they exist only to bridge the gap until the engine-hosted path lands.
|
Do not treat the patterns there as the blessed model — they exist only to bridge the gap until the engine-hosted path lands.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
For a public or user-facing slideshow project, the root `index.html` should be a runnable slideshow entrypoint. Opening it in a browser should show slideshow navigation and respond to Next/Prev; it should not expose only the raw composition and require the user to know about Studio or an internal wrapper file. If the raw HyperFrames composition must remain separate for CLI compatibility, put it in a subdirectory such as `composition/index.html` and point scripts/commands at that directory.
|
||||||
|
|
||||||
|
The direct-open wrapper must rely on the built-in Present icon button rendered by `<hyperframes-slideshow>`. Do not add a bespoke `#present-btn`, fixed-position button, or wrapper-specific Present styling. The shared component owns the control bar, hides Present in `?mode=audience`, and supports P as a keyboard shortcut.
|
||||||
|
|
||||||
|
Validate the direct-open path before handoff. If `file://` browser restrictions break iframe media, local scripts, or same-origin player access, use a self-contained wrapper or make the handoff command start a local server and open the working URL; do not leave `index.html` in a broken or ambiguous state.
|
||||||
|
|
||||||
|
For a completed slideshow deck, the primary user-facing next step is presenter mode, not Studio. Run or provide:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx hyperframes present <project-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
Studio/`preview` is useful for editing a composition, but it is not a clear final destination for a slideshow user. If you create a `package.json` for a slideshow project where the raw composition lives in `composition/`, make the default runnable script start presenter mode:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx hyperframes present ./composition",
|
||||||
|
"studio": "npx hyperframes preview ./composition"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At handoff, include the local presenter URL printed by the command and the minimal instruction: "Click Present, or press P, to open the audience window." Keep the server running if the user asked you to start it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
@@ -441,6 +526,14 @@ After authoring or editing a slideshow composition, run:
|
|||||||
npx hyperframes lint
|
npx hyperframes lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then run runtime validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx hyperframes validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat lint errors and validation `StaticGuard` contract messages as blockers even if a command exits successfully. Fix the file and rerun until lint reports `0 error(s)` and validation reports no runtime errors.
|
||||||
|
|
||||||
The slideshow lint rule checks:
|
The slideshow lint rule checks:
|
||||||
|
|
||||||
- Every `slide.sceneId` resolves to an existing scene (by `data-composition-id`).
|
- Every `slide.sceneId` resolves to an existing scene (by `data-composition-id`).
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ Do not treat these as the blessed authoring model. When the engine-hosted path s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. The parent wrapper (demo.html)
|
## 2. The parent wrapper (`index.html` for deliverables, `demo.html` in examples)
|
||||||
|
|
||||||
The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
|
The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
|
||||||
|
|
||||||
|
For public or user-facing generated projects, make this wrapper the root `index.html` so opening the project in a browser runs the slideshow. Put the raw HyperFrames composition in a separate path such as `composition/index.html`. In repo examples you may still see this file called `demo.html`; that name is a reference pattern, not the preferred handoff for a standalone deck.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -67,8 +69,9 @@ The parent page hosts the two dist bundles, wraps the components, duplicates the
|
|||||||
style="display: block; position: relative; width: 100vw; height: 100vh"
|
style="display: block; position: relative; width: 100vw; height: 100vh"
|
||||||
>
|
>
|
||||||
<hyperframes-player
|
<hyperframes-player
|
||||||
|
interactive
|
||||||
style="position: absolute; inset: 0"
|
style="position: absolute; inset: 0"
|
||||||
src="index.html"
|
src="composition/index.html"
|
||||||
></hyperframes-player>
|
></hyperframes-player>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -97,12 +100,265 @@ The parent page hosts the two dist bundles, wraps the components, duplicates the
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</hyperframes-slideshow>
|
</hyperframes-slideshow>
|
||||||
|
<!-- The built-in slideshow nav capsule renders Present; do not add a wrapper-level button. -->
|
||||||
|
|
||||||
<!-- Audio player lives here — see Section 6 -->
|
<!-- Audio player lives here — see Section 6 -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`interactive` is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction.
|
||||||
|
|
||||||
|
### Presenter media bridge for interactive media
|
||||||
|
|
||||||
|
Presenter/audience mode syncs slide position through a deck-scoped `BroadcastChannel`. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state.
|
||||||
|
|
||||||
|
Audible playback has one extra browser constraint: a `BroadcastChannel` message does not carry the presenter's user activation into the audience window. The audience window should try presenter-driven playback muted first, because browsers usually allow muted autoplay; it may still reject `media.play()` even while it accepts remote `currentTime` updates. If that happens, do not keep chasing presenter `timeupdate` messages; show an audience-side unlock control, store the latest play intent, and retry playback from that intent after the audience window receives a click/key gesture.
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
if (typeof BroadcastChannel === "undefined") return;
|
||||||
|
|
||||||
|
var sender =
|
||||||
|
new URLSearchParams(location.search).get("mode") === "audience" ? "audience" : "presenter";
|
||||||
|
var channel = new BroadcastChannel("hf-slideshow:" + location.pathname);
|
||||||
|
var applyingRemote = false;
|
||||||
|
var lastTimeBroadcast = 0;
|
||||||
|
var pendingPlayByKey = {};
|
||||||
|
var blockedPlayByKey = {};
|
||||||
|
var mutedPlaybackByKey = {};
|
||||||
|
var unlockButton = null;
|
||||||
|
|
||||||
|
function frameDocument() {
|
||||||
|
var player = document.querySelector("hyperframes-player");
|
||||||
|
var frame = player && player.iframeElement;
|
||||||
|
try {
|
||||||
|
return frame && frame.contentDocument ? frame.contentDocument : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function mediaNodes() {
|
||||||
|
var doc = frameDocument();
|
||||||
|
return doc ? Array.from(doc.querySelectorAll("video,audio")) : [];
|
||||||
|
}
|
||||||
|
function mediaKey(el, index) {
|
||||||
|
return el.id ? "id:" + el.id : el.tagName.toLowerCase() + ":" + index;
|
||||||
|
}
|
||||||
|
function findMedia(key) {
|
||||||
|
return mediaNodes().find(function (el, index) {
|
||||||
|
return mediaKey(el, index) === key;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function syncMediaState(el, msg, allowTimeSync) {
|
||||||
|
if (typeof msg.playbackRate === "number") el.playbackRate = msg.playbackRate;
|
||||||
|
if (typeof msg.volume === "number") el.volume = Math.max(0, Math.min(1, msg.volume));
|
||||||
|
if (sender === "audience" && mutedPlaybackByKey[msg.key]) {
|
||||||
|
el.muted = true;
|
||||||
|
} else if (typeof msg.muted === "boolean") {
|
||||||
|
el.muted = msg.muted;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
allowTimeSync &&
|
||||||
|
typeof msg.currentTime === "number" &&
|
||||||
|
Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35
|
||||||
|
) {
|
||||||
|
el.currentTime = Math.max(0, msg.currentTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hasBlockedPlay() {
|
||||||
|
return Object.keys(blockedPlayByKey).length > 0;
|
||||||
|
}
|
||||||
|
function hideAudienceUnlockIfClear() {
|
||||||
|
if (hasBlockedPlay() || !unlockButton) return;
|
||||||
|
unlockButton.remove();
|
||||||
|
unlockButton = null;
|
||||||
|
}
|
||||||
|
function showAudienceUnlock() {
|
||||||
|
if (sender !== "audience" || unlockButton) return;
|
||||||
|
unlockButton = document.createElement("button");
|
||||||
|
unlockButton.type = "button";
|
||||||
|
unlockButton.textContent = "Enable audience media";
|
||||||
|
unlockButton.style.cssText =
|
||||||
|
"position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;";
|
||||||
|
unlockButton.addEventListener("click", retryBlockedPlays);
|
||||||
|
document.body.appendChild(unlockButton);
|
||||||
|
}
|
||||||
|
function rememberBlockedPlay(msg) {
|
||||||
|
pendingPlayByKey[msg.key] = msg;
|
||||||
|
blockedPlayByKey[msg.key] = true;
|
||||||
|
showAudienceUnlock();
|
||||||
|
}
|
||||||
|
function clearBlockedPlay(key) {
|
||||||
|
delete blockedPlayByKey[key];
|
||||||
|
hideAudienceUnlockIfClear();
|
||||||
|
}
|
||||||
|
function tryPlay(el, msg) {
|
||||||
|
if (sender === "audience") mutedPlaybackByKey[msg.key] = true;
|
||||||
|
syncMediaState(el, msg, true);
|
||||||
|
if (sender === "audience") el.muted = true;
|
||||||
|
try {
|
||||||
|
var playResult = el.play();
|
||||||
|
if (playResult && typeof playResult.then === "function") {
|
||||||
|
playResult
|
||||||
|
.then(function () {
|
||||||
|
clearBlockedPlay(msg.key);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
rememberBlockedPlay(msg);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearBlockedPlay(msg.key);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
rememberBlockedPlay(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function retryBlockedPlays() {
|
||||||
|
wireMedia();
|
||||||
|
applyingRemote = true;
|
||||||
|
try {
|
||||||
|
Object.keys(pendingPlayByKey).forEach(function (key) {
|
||||||
|
var msg = pendingPlayByKey[key];
|
||||||
|
var el = findMedia(key);
|
||||||
|
if (el && msg) tryPlay(el, msg);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTimeout(function () {
|
||||||
|
applyingRemote = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function publish(el, index, action) {
|
||||||
|
if (sender !== "presenter") return;
|
||||||
|
if (applyingRemote) return;
|
||||||
|
if (action === "timeupdate") {
|
||||||
|
var now = performance.now();
|
||||||
|
if (now - lastTimeBroadcast < 450 && !el.paused) return;
|
||||||
|
lastTimeBroadcast = now;
|
||||||
|
}
|
||||||
|
channel.postMessage({
|
||||||
|
type: "media",
|
||||||
|
sender,
|
||||||
|
key: mediaKey(el, index),
|
||||||
|
action,
|
||||||
|
currentTime: el.currentTime || 0,
|
||||||
|
paused: el.paused,
|
||||||
|
ended: el.ended,
|
||||||
|
muted: el.muted,
|
||||||
|
volume: el.volume,
|
||||||
|
playbackRate: el.playbackRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function wireMedia() {
|
||||||
|
mediaNodes().forEach(function (el, index) {
|
||||||
|
if (el.dataset.hfPresenterMediaSync === "1") return;
|
||||||
|
el.dataset.hfPresenterMediaSync = "1";
|
||||||
|
[
|
||||||
|
"play",
|
||||||
|
"pause",
|
||||||
|
"seeking",
|
||||||
|
"seeked",
|
||||||
|
"ratechange",
|
||||||
|
"volumechange",
|
||||||
|
"ended",
|
||||||
|
"timeupdate",
|
||||||
|
].forEach(function (name) {
|
||||||
|
el.addEventListener(name, function () {
|
||||||
|
publish(el, index, name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
channel.addEventListener("message", function (event) {
|
||||||
|
var msg = event.data;
|
||||||
|
if (!msg || msg.type !== "media" || msg.sender === sender) return;
|
||||||
|
if (sender === "audience" && blockedPlayByKey[msg.key] && msg.action === "timeupdate") {
|
||||||
|
pendingPlayByKey[msg.key] = msg;
|
||||||
|
showAudienceUnlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var el = findMedia(msg.key);
|
||||||
|
if (!el) return;
|
||||||
|
applyingRemote = true;
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
msg.action === "play" ||
|
||||||
|
(sender === "audience" && msg.action === "timeupdate" && msg.paused === false && el.paused)
|
||||||
|
) {
|
||||||
|
pendingPlayByKey[msg.key] = msg;
|
||||||
|
tryPlay(el, msg);
|
||||||
|
} else {
|
||||||
|
syncMediaState(el, msg, true);
|
||||||
|
}
|
||||||
|
if (msg.action === "pause" || msg.action === "ended") {
|
||||||
|
delete pendingPlayByKey[msg.key];
|
||||||
|
delete mutedPlaybackByKey[msg.key];
|
||||||
|
clearBlockedPlay(msg.key);
|
||||||
|
el.pause();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setTimeout(function () {
|
||||||
|
applyingRemote = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wireMedia();
|
||||||
|
window.addEventListener("load", wireMedia);
|
||||||
|
window.addEventListener("keydown", retryBlockedPlays, true);
|
||||||
|
window.addEventListener("pointerdown", retryBlockedPlays, true);
|
||||||
|
setInterval(wireMedia, 1000);
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom media visualizers
|
||||||
|
|
||||||
|
For waveform, beat-grid, canvas, or timeline players, wire visual state to the native media element. This keeps native controls, custom controls, presenter sync, slide-exit cleanup, and global mute in one event path.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function wireMediaDrivenVisualizer(media, renderFrame, fireCrossedEvents) {
|
||||||
|
var mediaFrame = 0;
|
||||||
|
var lastTime = media.currentTime || 0;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var time = media.currentTime || 0;
|
||||||
|
if (Math.abs(time - lastTime) < 1.5 && time >= lastTime) {
|
||||||
|
fireCrossedEvents(lastTime, time);
|
||||||
|
}
|
||||||
|
lastTime = time;
|
||||||
|
renderFrame(time, media);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!media.requestVideoFrameCallback || mediaFrame) return;
|
||||||
|
mediaFrame = media.requestVideoFrameCallback(function () {
|
||||||
|
mediaFrame = 0;
|
||||||
|
update();
|
||||||
|
if (!media.paused && !media.ended) start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
media.addEventListener("play", start);
|
||||||
|
media.addEventListener("playing", start);
|
||||||
|
media.addEventListener("pause", update);
|
||||||
|
media.addEventListener("ended", update);
|
||||||
|
media.addEventListener("timeupdate", update);
|
||||||
|
media.addEventListener("seeking", function () {
|
||||||
|
lastTime = media.currentTime || 0;
|
||||||
|
renderFrame(lastTime, media);
|
||||||
|
});
|
||||||
|
media.addEventListener("seeked", update);
|
||||||
|
media.addEventListener("ratechange", update);
|
||||||
|
media.addEventListener("volumechange", update);
|
||||||
|
renderFrame(media.currentTime || 0, media);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `requestAnimationFrame` inside compositions for media sync; composition lint rejects wall-clock loops. Prefer `HTMLVideoElement.requestVideoFrameCallback()` for smooth video-tied updates and rely on native `timeupdate`/seek events as the fallback.
|
||||||
|
|
||||||
|
Use a dedicated wiring marker such as `data-media-sync-wired`. Do not reuse a marker like `data-wired` for both "timeline DOM already rendered" and "media event listeners attached"; pre-rendered timeline HTML will otherwise skip listener setup.
|
||||||
|
|
||||||
### Editable presenter notes
|
### Editable presenter notes
|
||||||
|
|
||||||
The shared `<hyperframes-slideshow>` presenter already renders speaker notes as an editable textarea and stores edits in `localStorage`. Do not add deck-specific note editors when the shared player is available.
|
The shared `<hyperframes-slideshow>` presenter already renders speaker notes as an editable textarea and stores edits in `localStorage`. Do not add deck-specific note editors when the shared player is available.
|
||||||
@@ -165,7 +421,70 @@ Without the engine, scenes are driven by a `root` GSAP timeline that the composi
|
|||||||
|
|
||||||
The key insight: scene backgrounds must be `transparent` (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline `background` set the visual fill.
|
The key insight: scene backgrounds must be `transparent` (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline `background` set the visual fill.
|
||||||
|
|
||||||
For converted source pages, port source-specific widgets exactly where practical. Custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.
|
For converted source pages, preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. Port source-specific widgets exactly where practical: custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.
|
||||||
|
|
||||||
|
### Navigation camera transitions for converted pages
|
||||||
|
|
||||||
|
When a source page uses scroll to move a translated/scaled world, slideshow navigation usually seeks directly to each slide's hold frame. That seek bypasses any in-timeline interpolation near the scene boundary, so a deck can compute the right camera positions and still appear to jump. Add an explicit standalone navigation transition for manual slide changes, while keeping normal HyperFrames timeline seeks static and deterministic.
|
||||||
|
|
||||||
|
Use this pattern only for direct-open/presenter slideshow UI. Do not depend on CSS transitions for rendered video output; rendered compositions must still be correct when seeking a single frame.
|
||||||
|
|
||||||
|
```css
|
||||||
|
#world {
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
transition:
|
||||||
|
transform 760ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world.hf-camera-static {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
var currentCamera = null;
|
||||||
|
var currentSlideIndex = null;
|
||||||
|
|
||||||
|
function cameraTransform(cam) {
|
||||||
|
return "translate(" + cam.tx + "px," + cam.ty + "px) scale(" + cam.s + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWorldCamera(world, cam, animate) {
|
||||||
|
if (!cam) return;
|
||||||
|
if (!animate) world.classList.add("hf-camera-static");
|
||||||
|
world.style.transform = cameraTransform(cam);
|
||||||
|
world.style.opacity = "1";
|
||||||
|
if (!animate) {
|
||||||
|
world.getBoundingClientRect();
|
||||||
|
world.classList.remove("hf-camera-static");
|
||||||
|
}
|
||||||
|
currentCamera = cam;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideIndexAtTime(t, slideDuration, slideCount) {
|
||||||
|
return Math.max(0, Math.min(slideCount - 1, Math.floor(t / slideDuration)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCameraForTime(t, opts) {
|
||||||
|
var nextSlideIndex = slideIndexAtTime(t, SLIDE_DURATION, SLIDES.length);
|
||||||
|
var jumpedBetweenSlides =
|
||||||
|
currentSlideIndex !== null &&
|
||||||
|
nextSlideIndex !== currentSlideIndex &&
|
||||||
|
Math.abs(t - lastTime) > 1.2;
|
||||||
|
var animateCamera = Boolean(
|
||||||
|
window.__hfCameraTransitionsEnabled && jumpedBetweenSlides && !(opts && opts.staticCamera),
|
||||||
|
);
|
||||||
|
var cam = cameraAtTime(t);
|
||||||
|
setWorldCamera(world, cam, animateCamera);
|
||||||
|
currentSlideIndex = nextSlideIndex;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
During measurement, temporarily remove the transform with `hf-camera-static`, compute all element union rects, then restore `currentCamera` without animation. On initial load, resize, and validation-style seeks, call `updateCameraForTime(t, { staticCamera: true })`. In the standalone wrapper, set `iframe.contentWindow.__hfCameraTransitionsEnabled = true` after the player iframe is available. That keeps the exported composition seekable while letting presenter navigation glide between focal points.
|
||||||
|
|
||||||
|
Before validation, resolve source font variables. HyperFrames lint accepts concrete generic stacks such as `system-ui, sans-serif` and `ui-monospace, monospace`, or real `@font-face` declarations pointing at local font files. It does not accept `font-family: var(--f-body)` / `var(--f-mono)` as a render-safe family.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- In index.html (composition) -->
|
<!-- In index.html (composition) -->
|
||||||
@@ -395,6 +714,22 @@ Wrapper-owned SFX should live in the parent page. Browsers enforce user-activati
|
|||||||
|
|
||||||
Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling `hyperframes-player.stopMedia()`, which pauses iframe `<video>` / `<audio>`, runtime WebAudio, and parent proxies adopted from iframe media. Same-slide fragment reveals do not stop media, and global/deck-level parent audio such as `audio-src` is left alone. Do not hand-roll per-slide cleanup scripts for regular video/audio players.
|
Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling `hyperframes-player.stopMedia()`, which pauses iframe `<video>` / `<audio>`, runtime WebAudio, and parent proxies adopted from iframe media. Same-slide fragment reveals do not stop media, and global/deck-level parent audio such as `audio-src` is left alone. Do not hand-roll per-slide cleanup scripts for regular video/audio players.
|
||||||
|
|
||||||
|
Every copied `<video>` / `<audio>` with a `src` must be timed for HyperFrames ownership:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<video
|
||||||
|
src="assets/demo.mp4"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="96"
|
||||||
|
data-has-audio="true"
|
||||||
|
></video>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `data-has-audio="true"` only for audible media. Muted autoplay loops can omit it. Do not leave `preload="none"` in converted compositions.
|
||||||
|
|
||||||
Implementation detail: iframe media elements belong to the iframe's DOM realm. Fallback code in the parent page/player must not use the parent page's `el instanceof HTMLMediaElement` check for iframe nodes; in real browsers that fails and leaves videos audible. Use `el.ownerDocument.defaultView.HTMLMediaElement` or a tag/duck-type guard before setting `muted` or calling `pause()`.
|
Implementation detail: iframe media elements belong to the iframe's DOM realm. Fallback code in the parent page/player must not use the parent page's `el instanceof HTMLMediaElement` check for iframe nodes; in real browsers that fails and leaves videos audible. Use `el.ownerDocument.defaultView.HTMLMediaElement` or a tag/duck-type guard before setting `muted` or calling `pause()`.
|
||||||
|
|
||||||
### Mute toggle — built-in chrome control
|
### Mute toggle — built-in chrome control
|
||||||
@@ -650,14 +985,22 @@ if (!renderer) {
|
|||||||
|
|
||||||
## 8. Foot-gun checklist
|
## 8. Foot-gun checklist
|
||||||
|
|
||||||
| Failure | Symptom | One-line fix |
|
| Failure | Symptom | One-line fix |
|
||||||
| ----------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
|
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
|
||||||
| Wrapper SFX in the iframe | Click/transition sounds silent | Move SFX Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
|
| Wrapper SFX in the iframe | Click/transition sounds silent | Move SFX Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
|
||||||
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
|
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
|
||||||
| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
|
| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
|
||||||
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
|
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
|
||||||
| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
|
| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
|
||||||
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
|
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
|
||||||
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
|
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
|
||||||
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
|
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
|
||||||
|
| Custom media visualizer uses its own timer | Canvas/playhead drifts from the actual video or native controls | Drive visual state from media events and `media.currentTime`; do not use an independent `setTimeout` clock |
|
||||||
|
| One `data-wired` flag means two different things | Pre-rendered timeline HTML skips media listener setup | Use separate markers such as `data-timeline-rendered` and `data-media-sync-wired` |
|
||||||
|
| Presenter media events are not bridged | Audience follows slides but not play/pause/seek/mute | Mirror native media events over `BroadcastChannel("hf-slideshow:" + location.pathname)` in standalone wrappers with interactive media |
|
||||||
|
| Remote play is blocked in the audience window | Audience media time jumps but video never plays | Try muted playback first; if `media.play()` rejects, show an audience unlock button and ignore live `timeupdate` chasing until playback succeeds |
|
||||||
|
| Audience muted autoplay publishes back to presenter | Presenter audio starts, then mutes or cuts out | Publish media events only from presenter mode; audience mute is a local browser-autoplay workaround, not shared media state |
|
||||||
|
| Copied media lacks HyperFrames timing | Lint errors on untimed media; preview/render diverge | Add `data-start`, `data-duration`, and `data-has-audio="true"` when audible; avoid `preload="none"` |
|
||||||
|
| Source font CSS variables kept as font-family values | StaticGuard font-family contract errors | Replace with concrete render-safe stacks or add local `@font-face` declarations |
|
||||||
|
| Converted scroll/camera source jumps between slides | Per-slide focal points are correct but manual navigation snaps | Add a standalone navigation-camera transition hook; disable it for measurement, initial load, resize, and render/validation seeks |
|
||||||
|
|||||||
Reference in New Issue
Block a user