mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
fix(slideshow): address code-review findings #1580-1584 (#1585)
* fix(slideshow): address code-review findings #1580-1584
- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow fullscreen + presenter-view rework
- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
a bare paused seek does not repaint some compositions. resumeSlide now plays a
brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): slideshow no auto-progress + presenter slide fits/pins
- navigation jumps to a static frame instead of auto-playing the timeline:
playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
region above the notes panel, so the player CONTAINS the composition — the
full slide stays visible (letterboxed) at any width and re-fits on resize;
its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons
Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): stop presenter nav buttons flickering / dropping clicks
The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version
Addresses review feedback on the split stack:
- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
to the core schema so future schema changes can migrate older islands.
These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(ci): fix format + fallow gates for slideshow stack
- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
(duplicates.ignore). File-level config, not inline comments — inline shifts line
numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address PR review + CodeQL findings
- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
presenter-test.html — a generated test harness (postMessage w/o origin check,
proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
[start,end], not 'strictly inside'.
IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.
* feat(cli): add 'present' command — serve a deck in presenter mode
hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.
- New utils/compositionServer.ts factors the server scaffolding shared with
'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
wiring) and the per-command startup/logging block from the complexity /
duplication gates.
Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).
* fix(cli): present renders the deck (player sizing + self-driving serve)
Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
zero size — the (absolutely-positioned) chrome showed but the composition
didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
its timelines engine-paused (blank). Slideshow decks self-drive their own
timelines (like demo.html / the standalone harness), so serve them raw.
Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.
* fix(cli): present plays slideshow sound effects
The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.
Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.
* feat(examples): softer mellow slideshow sfx for airbnb-deck
Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.
* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck
Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)
* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)
- Going backward a slide now plays the reverse whoosh (back), not advance —
the sfx logic detects nav direction by scene order instead of firing advance
for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.
Verified: next then prev fires [advance, fragment, back]; no page errors.
* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)
Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
presenter-test.html — add an origin check (same-origin composition iframe)
and an own-property guard so a 'name' like __proto__ can't resolve to and
mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
higher-specificity [data-hf-muted] [data-hf-mute]:hover override.
Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).
* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness
- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray 54a4460 git add -A had re-added the deleted
harness (reviving CodeQL #639/#640); remove it again.
106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae40498433
commit
cc2220e59e
@@ -25,6 +25,7 @@ interface ControllerLike {
|
||||
readonly canPrev?: boolean;
|
||||
readonly canNext?: boolean;
|
||||
goToSlide?(index: number): void;
|
||||
syncTo?(sequenceId: string, slideIndex: number, fragmentIndex: number): void;
|
||||
enterBranch?(id: string): void;
|
||||
back?(): void;
|
||||
backToMain?(): void;
|
||||
@@ -61,10 +62,26 @@ function injectKeyframesOnce(): void {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-hotspot-pill { animation: none !important; }
|
||||
}
|
||||
/* Nav-button hover (replaces inline onmouseover/onmouseout — CSP-safe).
|
||||
!important beats the inline base color set on each button. */
|
||||
[data-hf-nav-cluster] button:hover {
|
||||
background: rgba(255,255,255,0.12) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* When muted, the speaker button stays dimmed on hover so the mute-state
|
||||
affordance isn't erased (higher specificity than the rule above). */
|
||||
[data-hf-muted] [data-hf-mute]:hover {
|
||||
color: rgba(255,255,255,0.6) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Fullscreen glyphs (enter = expand corners, exit = collapse corners). Module-level
|
||||
// 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 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>`;
|
||||
|
||||
export class HyperframesSlideshow extends HTMLElement {
|
||||
private controller: ControllerLike | null = null;
|
||||
private offChange: (() => void) | null = null;
|
||||
@@ -85,6 +102,18 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/** Mode resolves from the `mode` attribute, falling back to the URL query
|
||||
* (?mode=audience) so the audience window opened by present() is detected. */
|
||||
private resolveMode(): string | null {
|
||||
const attr = this.getAttribute("mode");
|
||||
if (attr) return attr;
|
||||
try {
|
||||
return new URLSearchParams(location.search).get("mode");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.disconnected = false;
|
||||
this.initInFlight = false;
|
||||
@@ -97,6 +126,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.addEventListener("touchstart", this.onTouchStart, { passive: true });
|
||||
this.addEventListener("touchend", this.onTouchEnd);
|
||||
window.addEventListener("message", this.onMessage);
|
||||
document.addEventListener("fullscreenchange", this.onFsChange);
|
||||
this.initChannel();
|
||||
// Defer player-dependent init to a macrotask so that child elements are
|
||||
// parsed before we query for <hyperframes-player>. This matters when the
|
||||
@@ -123,6 +153,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.removeEventListener("touchstart", this.onTouchStart);
|
||||
this.removeEventListener("touchend", this.onTouchEnd);
|
||||
window.removeEventListener("message", this.onMessage);
|
||||
document.removeEventListener("fullscreenchange", this.onFsChange);
|
||||
this.offChange?.();
|
||||
this.offChange = null;
|
||||
this.controller?.dispose?.();
|
||||
@@ -151,18 +182,30 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.setAttribute("data-hf-presenting", "true");
|
||||
this.presenterStartMs = Date.now();
|
||||
if (this.presenterInterval === null) {
|
||||
this.presenterInterval = setInterval(() => this.render(), 1000);
|
||||
this.presenterInterval = setInterval(() => this.updateElapsed(), 1000);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the elapsed readout. Re-rendering the whole chrome every second
|
||||
* (the old behavior) rebuilt the nav buttons' DOM on each tick — they
|
||||
* flickered and clicks landing mid-rebuild were dropped.
|
||||
*/
|
||||
private updateElapsed(): void {
|
||||
if (this.presenterStartMs === null) return;
|
||||
const el = this.chrome?.querySelector("[data-hf-presenter-elapsed]");
|
||||
if (el) {
|
||||
el.textContent = formatElapsed(Math.floor((Date.now() - this.presenterStartMs) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
private initChannel(): void {
|
||||
const mode = this.getAttribute("mode");
|
||||
const mode = this.resolveMode();
|
||||
if (mode === "audience") {
|
||||
this.channel = new SlideshowChannel("audience", (msg) => {
|
||||
if (!this.controller) return;
|
||||
if (msg.sequenceId !== "main") return; // V1: non-main branch gracefully ignored
|
||||
this.controller.goToSlide?.(msg.slideIndex);
|
||||
this.controller.syncTo?.(msg.sequenceId, msg.slideIndex, msg.fragmentIndex);
|
||||
});
|
||||
} else {
|
||||
this.channel = new SlideshowChannel("presenter", () => {
|
||||
@@ -210,6 +253,12 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
console.warn("[hyperframes-slideshow] manifest errors:", errors);
|
||||
}
|
||||
const cleaned = dropInvalidSlides(resolved);
|
||||
if (cleaned.slides.length === 0 && manifest.slides.length > 0) {
|
||||
console.error(
|
||||
"[hyperframes-slideshow] no main-line slides resolved — the scene timeline may not have loaded in time, or sceneIds/timing are invalid:",
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
const port: PlayerPort = {
|
||||
seek: (t) => playerEl.seek(t),
|
||||
@@ -240,13 +289,13 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.controller = c;
|
||||
this.offChange = c.onChange(() => {
|
||||
// Presenter posts position to channel on every change
|
||||
if (this.getAttribute("mode") !== "audience" && this.channel) {
|
||||
if (this.resolveMode() !== "audience" && this.channel) {
|
||||
this.channel.postPosition(c.position);
|
||||
}
|
||||
this.render();
|
||||
});
|
||||
// Post initial position if presenter
|
||||
if (this.getAttribute("mode") !== "audience" && this.channel) {
|
||||
if (this.resolveMode() !== "audience" && this.channel) {
|
||||
this.channel.postPosition(c.position);
|
||||
}
|
||||
this.render();
|
||||
@@ -264,19 +313,39 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowRight" || e.key === " ") {
|
||||
const active = document.activeElement;
|
||||
const focused = active === this || this.contains(active);
|
||||
// Arrows act even when nothing is focused (active === body/null) so a freshly
|
||||
// loaded deck responds without a click; Space/Backspace have strong page-level
|
||||
// defaults (scroll / history) so they only act when the deck actually has focus.
|
||||
const ambient = focused || active === document.body || active === null;
|
||||
if (e.key === "ArrowRight") {
|
||||
if (!ambient) return;
|
||||
this.controller.next();
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "Backspace") {
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
if (!ambient) return;
|
||||
this.controller.prev();
|
||||
e.preventDefault();
|
||||
} else if (e.key === " ") {
|
||||
if (!focused) return;
|
||||
this.controller.next();
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Backspace") {
|
||||
if (!focused) return;
|
||||
this.controller.prev();
|
||||
e.preventDefault();
|
||||
} else if ((e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (!focused) return;
|
||||
this.toggleFullscreen();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private onMessage = (e: MessageEvent): void => {
|
||||
// Audience mode is driven by BroadcastChannel; ignore embed postMessage nav.
|
||||
if (this.getAttribute("mode") === "audience") return;
|
||||
if (this.resolveMode() === "audience") return;
|
||||
const data = e.data as { type?: unknown; slideIndex?: unknown } | null;
|
||||
if (!data || !this.controller) return;
|
||||
if (data.type === "next") {
|
||||
@@ -318,6 +387,14 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
private render(): void {
|
||||
if (!this.controller) return;
|
||||
|
||||
if (this.resolveMode() === "audience") {
|
||||
// Audience (viewer) window: no nav controls — but keep a fullscreen toggle
|
||||
// so the presentation can fill the display.
|
||||
const { counter } = this.controller;
|
||||
this.paintChrome(this.buildNavCluster(counter, "28px", "fs-only"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getAttribute("data-hf-presenting") === "true") {
|
||||
this.renderPresenter();
|
||||
return;
|
||||
@@ -326,16 +403,6 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
const { counter, currentSlide } = this.controller;
|
||||
if (!currentSlide) return;
|
||||
|
||||
if (!this.chrome) {
|
||||
this.chrome = document.createElement("div");
|
||||
this.chrome.setAttribute("data-hf-chrome", "");
|
||||
this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:10;";
|
||||
this.appendChild(this.chrome);
|
||||
}
|
||||
|
||||
// Inject keyframes for hotspot pulse animation once per document.
|
||||
injectKeyframesOnce();
|
||||
|
||||
// Hotspot pills: compact floating buttons anchored to the region's top-left,
|
||||
// sized to content (not filling the region). The region x/y positions the pill;
|
||||
// w/h are ignored for sizing (pill is content-sized). XSS: escHtml guards all
|
||||
@@ -356,18 +423,38 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Single cohesive nav cluster: [mute?] [prev |] counter [| next] — bottom-right capsule.
|
||||
// Prev/next buttons are hidden when there is no destination in that direction:
|
||||
// - Main deck first slide → no prev (nothing before it)
|
||||
// - Main deck last slide → no next (nothing after it)
|
||||
// - Inside a branch → always both (branch-edge returns to parent)
|
||||
// The mute toggle is shown only when the `sound` boolean attribute is present.
|
||||
const showPrev = this.controller.canPrev !== false;
|
||||
const showNext = this.controller.canNext !== false;
|
||||
this.paintChrome(hotspotsHtml + this.buildNavCluster(counter, "28px"));
|
||||
}
|
||||
|
||||
/** Ensure the overlay chrome layer exists, set its content, and wire its buttons. */
|
||||
private paintChrome(html: string): void {
|
||||
injectKeyframesOnce(); // nav-button :hover + hotspot keyframes (CSP-safe, once per doc)
|
||||
if (!this.chrome) {
|
||||
this.chrome = document.createElement("div");
|
||||
this.chrome.setAttribute("data-hf-chrome", "");
|
||||
this.appendChild(this.chrome);
|
||||
}
|
||||
this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:10;";
|
||||
this.chrome.innerHTML = html;
|
||||
this.wireChromeButtons();
|
||||
}
|
||||
|
||||
// Builds the nav cluster ([mute?] [prev] counter [next] | [fullscreen]) as a
|
||||
// floating capsule. `bottomCss` positions it (normal view: "28px"; presenter
|
||||
// view: above the notes panel). Reused by render() and renderPresenter().
|
||||
// fallow-ignore-next-line complexity
|
||||
private buildNavCluster(
|
||||
counter: { index: number; total: number },
|
||||
bottomCss: string,
|
||||
variant: "full" | "fs-only" = "full",
|
||||
): string {
|
||||
const c = this.controller;
|
||||
if (!c) return "";
|
||||
const showPrev = c.canPrev !== false;
|
||||
const showNext = c.canNext !== false;
|
||||
const showSound = this.hasAttribute("sound");
|
||||
const btnStyle =
|
||||
"display:flex;align-items:center;justify-content:center;width:34px;height:34px;background:transparent;border:none;border-radius:999px;color:rgba(255,255,255,0.85);font-size:16px;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;";
|
||||
// Inline SVG glyphs for speaker and speaker-muted (no emoji — consistent across platforms)
|
||||
const speakerSvg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`;
|
||||
const speakerMutedSvg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
|
||||
const muteBtnHtml = showSound
|
||||
@@ -377,8 +464,6 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
aria-label="${this._muted ? "Unmute" : "Mute"}"
|
||||
aria-pressed="${this._muted ? "true" : "false"}"
|
||||
style="${btnStyle}${this._muted ? "color:rgba(255,255,255,0.45);" : ""}"
|
||||
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='${this._muted ? "rgba(255,255,255,0.6)" : "#fff"}';"
|
||||
onmouseout="this.style.background='transparent';this.style.color='${this._muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.85)"}';"
|
||||
>${this._muted ? speakerMutedSvg : speakerSvg}</button>`
|
||||
: "";
|
||||
const prevBtnHtml = showPrev
|
||||
@@ -386,28 +471,36 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
data-hf-prev
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
style="${btnStyle}"
|
||||
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='#fff';"
|
||||
onmouseout="this.style.background='transparent';this.style.color='rgba(255,255,255,0.85)';"
|
||||
>‹</button>`
|
||||
style="${btnStyle}" >‹</button>`
|
||||
: "";
|
||||
const nextBtnHtml = showNext
|
||||
? `<button
|
||||
data-hf-next
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
style="${btnStyle}"
|
||||
onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='#fff';"
|
||||
onmouseout="this.style.background='transparent';this.style.color='rgba(255,255,255,0.85)';"
|
||||
>›</button>`
|
||||
style="${btnStyle}" >›</button>`
|
||||
: "";
|
||||
// Counter padding adjusts so the pill looks centered when one button is absent.
|
||||
const counterPadLeft = showPrev ? "4px" : "10px";
|
||||
const counterPadRight = showNext ? "4px" : "10px";
|
||||
const navClusterHtml = `
|
||||
const isFs = document.fullscreenElement === this;
|
||||
const fsBtnHtml = `<button
|
||||
data-hf-fullscreen
|
||||
type="button"
|
||||
aria-label="${isFs ? "Exit full screen" : "Full screen"}"
|
||||
aria-pressed="${isFs ? "true" : "false"}"
|
||||
style="${btnStyle}" >${isFs ? EXIT_FS_SVG : ENTER_FS_SVG}</button>`;
|
||||
// Audience/viewer: only the fullscreen control (no navigation).
|
||||
if (variant === "fs-only") {
|
||||
return `
|
||||
<div
|
||||
data-hf-nav-cluster
|
||||
style="position:absolute;bottom:28px;right:32px;display:inline-flex;align-items:center;gap:2px;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
style="position:absolute;bottom:${bottomCss};right:32px;display:inline-flex;align-items:center;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
>${fsBtnHtml}</div>`;
|
||||
}
|
||||
const counterPadLeft = showPrev ? "4px" : "10px";
|
||||
const counterPadRight = showNext ? "4px" : "10px";
|
||||
return `
|
||||
<div
|
||||
data-hf-nav-cluster
|
||||
style="position:absolute;bottom:${bottomCss};right:32px;display:inline-flex;align-items:center;gap:2px;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
>
|
||||
${muteBtnHtml}
|
||||
${showSound ? `<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>` : ""}
|
||||
@@ -418,26 +511,47 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
style="min-width:46px;text-align:center;color:rgba(255,255,255,0.9);font-size:13px;font-weight:500;font-variant-numeric:tabular-nums;letter-spacing:0.02em;padding:0 ${counterPadRight} 0 ${counterPadLeft};user-select:none;"
|
||||
>${counter.index} / ${counter.total}</span>
|
||||
${nextBtnHtml}
|
||||
</div>
|
||||
`;
|
||||
<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>
|
||||
${fsBtnHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
this.chrome.innerHTML = hotspotsHtml + navClusterHtml;
|
||||
|
||||
const muteBtn = this.chrome.querySelector("[data-hf-mute]");
|
||||
const prevBtn = this.chrome.querySelector("[data-hf-prev]");
|
||||
const nextBtn = this.chrome.querySelector("[data-hf-next]");
|
||||
private wireChromeButtons(): void {
|
||||
const chrome = this.chrome;
|
||||
if (!chrome) return;
|
||||
const muteBtn = chrome.querySelector("[data-hf-mute]");
|
||||
const prevBtn = chrome.querySelector("[data-hf-prev]");
|
||||
const nextBtn = chrome.querySelector("[data-hf-next]");
|
||||
if (muteBtn) muteBtn.addEventListener("click", () => this.toggleMute());
|
||||
if (prevBtn) prevBtn.addEventListener("click", () => this.controller?.prev());
|
||||
if (nextBtn) nextBtn.addEventListener("click", () => this.controller?.next());
|
||||
|
||||
// Wire hotspot clicks after innerHTML is set. Read target from data-hotspot-target
|
||||
// so the handler does not close over stale loop state.
|
||||
for (const btn of this.chrome.querySelectorAll("[data-hotspot-id]")) {
|
||||
const fsBtn = chrome.querySelector("[data-hf-fullscreen]");
|
||||
if (fsBtn) fsBtn.addEventListener("click", () => this.toggleFullscreen());
|
||||
for (const btn of chrome.querySelectorAll("[data-hotspot-id]")) {
|
||||
const target = btn.getAttribute("data-hotspot-target") ?? "";
|
||||
btn.addEventListener("click", () => this.controller?.enterBranch?.(target));
|
||||
}
|
||||
}
|
||||
|
||||
private onFsChange = (): void => {
|
||||
// Swap only the fullscreen glyph + label — re-rendering the whole chrome here
|
||||
// would rebuild every nav button on each fullscreen toggle.
|
||||
const btn = this.chrome?.querySelector("[data-hf-fullscreen]");
|
||||
if (!btn) return;
|
||||
const isFs = document.fullscreenElement === this;
|
||||
btn.innerHTML = isFs ? EXIT_FS_SVG : ENTER_FS_SVG;
|
||||
btn.setAttribute("aria-label", isFs ? "Exit full screen" : "Full screen");
|
||||
btn.setAttribute("aria-pressed", isFs ? "true" : "false");
|
||||
};
|
||||
|
||||
private toggleFullscreen(): void {
|
||||
if (document.fullscreenElement === this) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
void this.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private toggleMute(): void {
|
||||
this._muted = !this._muted;
|
||||
if (this._muted) {
|
||||
@@ -464,30 +578,31 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
const elapsedSec =
|
||||
this.presenterStartMs !== null ? Math.floor((Date.now() - this.presenterStartMs) / 1000) : 0;
|
||||
|
||||
if (!this.chrome) {
|
||||
this.chrome = document.createElement("div");
|
||||
this.chrome.setAttribute("data-hf-chrome", "");
|
||||
this.chrome.style.cssText = "position:absolute;inset:0;z-index:10;";
|
||||
this.appendChild(this.chrome);
|
||||
// Pin the live slide to the TOP and reserve the bottom 32% for the notes
|
||||
// panel. The player contains the composition, so the FULL slide stays visible
|
||||
// (letterboxed) at any width — its bottom is never hidden behind the panel —
|
||||
// and it re-fits to the top region on window resize.
|
||||
const playerEl = this.querySelector("hyperframes-player");
|
||||
if (playerEl instanceof HTMLElement) {
|
||||
playerEl.style.top = "0";
|
||||
playerEl.style.bottom = "32%";
|
||||
playerEl.style.height = "auto";
|
||||
}
|
||||
|
||||
this.chrome.innerHTML = buildPresenterLayout({
|
||||
// TODO: live next-slide thumbnail/preview deferred (needs a second seeked player) — V1 shows text
|
||||
currentSlideHtml: currentPanelText(currentSlide),
|
||||
nextSlideHtml: nextPanelText(nextSlide),
|
||||
notes: currentSlide.notes ?? "",
|
||||
counterText: `${counter.index} / ${counter.total}`,
|
||||
elapsedText: formatElapsed(elapsedSec),
|
||||
});
|
||||
// Full-overlay chrome (pointer-events:none); the notes panel and nav cluster
|
||||
// are the only interactive children.
|
||||
this.paintChrome(
|
||||
buildPresenterLayout({
|
||||
notes: currentSlide.notes ?? "",
|
||||
nextText: nextPanelText(nextSlide),
|
||||
counterText: `${counter.index} / ${counter.total}`,
|
||||
elapsedText: formatElapsed(elapsedSec),
|
||||
hotspots: currentSlide.hotspots,
|
||||
}) + this.buildNavCluster(counter, "calc(32% + 18px)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function currentPanelText(slide: { notes?: string; sceneId?: string }): string {
|
||||
if (slide.notes != null && slide.notes.length > 0) return escHtml(slide.notes);
|
||||
if (slide.sceneId != null) return `Current: ${escHtml(slide.sceneId)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function nextPanelText(slide: { sceneId: string; notes?: string } | null): string {
|
||||
if (slide === null) return "End of sequence";
|
||||
const firstLine = slide.notes != null ? (slide.notes.split("\n")[0] ?? "") : "";
|
||||
@@ -539,31 +654,40 @@ function waitForScenes(
|
||||
timeoutMs: number,
|
||||
isCancelled: () => boolean = () => false,
|
||||
): Promise<{ id: string; start: number; duration: number }[]> {
|
||||
const scenes = readScenes(player);
|
||||
if (scenes.length > 0) return Promise.resolve(scenes);
|
||||
const initial = readScenes(player);
|
||||
if (initial.length > 0) return Promise.resolve(initial);
|
||||
|
||||
const maxIterations = Math.ceil(timeoutMs / 100);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let iterations = 0;
|
||||
const poll = (): void => {
|
||||
if (isCancelled()) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const current = readScenes(player);
|
||||
if (current.length > 0) {
|
||||
resolve(current);
|
||||
return;
|
||||
}
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 100);
|
||||
|
||||
const finish = (val: { id: string; start: number; duration: number }[]): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
player.removeEventListener("scenes", onScenes);
|
||||
resolve(val);
|
||||
};
|
||||
setTimeout(poll, 100);
|
||||
const onScenes = (): void => {
|
||||
if (isCancelled()) return finish([]);
|
||||
const s = readScenes(player);
|
||||
if (s.length > 0) finish(s);
|
||||
};
|
||||
const poll = (): void => {
|
||||
if (done) return;
|
||||
if (isCancelled()) return finish([]);
|
||||
const cur = readScenes(player);
|
||||
if (cur.length > 0) return finish(cur);
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) return finish([]);
|
||||
timer = setTimeout(poll, 100);
|
||||
};
|
||||
|
||||
player.addEventListener("scenes", onScenes);
|
||||
timer = setTimeout(poll, 100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user