import { parseSlideshowManifest, resolveSlideshow, type ResolvedSlideshow, } from "@hyperframes/core/slideshow"; import { SlideshowController, type PlayerPort } from "./SlideshowController"; import { SlideshowChannel, buildPresenterLayout, formatElapsed, type PresenterMediaAction, type PresenterMediaMessage, } from "./slideshowPresenter"; interface Hotspot { id: string; label: string; target: string; region?: { x: number; y: number; w: number; h: number }; } interface ControllerLike { next(): void; prev(): void; onChange(cb: () => void): () => void; readonly counter: { index: number; total: number }; readonly breadcrumb: { id: string; label: string }[]; readonly currentSlide: { hotspots: Hotspot[]; notes?: string; sceneId?: string } | undefined; readonly nextSlide: { sceneId: string; notes?: string } | null; readonly position: { sequenceId: string; slideIndex: number; fragmentIndex: number }; readonly canPrev?: boolean; readonly canNext?: boolean; goToSlide?(index: number): void; syncTo?(sequenceId: string, slideIndex: number, fragmentIndex: number): void; enterBranch?(id: string): void; back?(): void; backToMain?(): void; dispose?(): void; } interface SlideNotesTarget { sceneId?: string; } type SlideshowManifest = NonNullable>; // Autoplay re-assert poll (see playSceneDocumentMedia): the player drives clips // during bootstrap and on enter, so a single play() loses the race; we poll // briefly until the clip is advancing. const AUTOPLAY_STEP_MS = 150; const AUTOPLAY_MAX_MS = 6000; interface AutoplayPollState { started: boolean; lastTime: number; advancingTicks: number; waited: number; warned: boolean; } type PlayerElement = HTMLElement & { seek(t: number): void; play(): void; pause(): void; stopMedia?(): void; muted?: boolean; readonly iframeElement?: HTMLIFrameElement; readonly currentTime: number; readonly ready: boolean; }; type SlideshowMediaElement = HTMLMediaElement & { dataset: DOMStringMap; }; /** True when the keydown originated in a text-entry control (typing must never * navigate the deck). Duck-typed so it works for events from the composition * iframe's realm, where instanceof this realm's element classes always fails. */ function isTextEntryTarget(target: EventTarget | null): boolean { if (!target || typeof (target as HTMLElement).tagName !== "string") return false; const el = target as HTMLElement; const tag = el.tagName; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true; } function isPlayerElement(el: HTMLElement): el is PlayerElement { return ( typeof (el as PlayerElement).seek === "function" && typeof (el as PlayerElement).play === "function" && typeof (el as PlayerElement).pause === "function" ); } const PRESENTER_NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:"; // Injected once per document to avoid duplicating @keyframes across multiple elements. let _keyframesInjected = false; function injectKeyframesOnce(): void { if (_keyframesInjected) return; _keyframesInjected = true; const style = document.createElement("style"); style.textContent = ` @keyframes hf-hotspot-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.35), 0 4px 16px rgba(0,0,0,0.35); } 50% { box-shadow: 0 0 0 8px rgba(255,255,255,0), 0 4px 20px rgba(0,0,0,0.45); } } @keyframes hf-nav-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .hf-hotspot-pill, .hf-nav-spinner { 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; } [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 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 = ``; const EXIT_FS_SVG = ``; const PRESENT_SVG = ``; const COUNTER_FONT_FAMILY = "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; export class HyperframesSlideshow extends HTMLElement { private controller: ControllerLike | null = null; private offChange: (() => void) | null = null; private chrome: HTMLDivElement | null = null; private touchStartX = 0; private touchStartY = 0; private channel: SlideshowChannel | null = null; private presenterStartMs: number | null = null; private presenterInterval: ReturnType | null = null; private presenterPositionTimers: ReturnType[] = []; private disconnected = false; private initTimer: ReturnType | null = null; private initInFlight = false; private initGeneration = 0; private keyForwardFrame: HTMLIFrameElement | null = null; private detachIframeKeys: (() => void) | null = null; private warnedIframeKeyForwardingUnavailable = false; private _muted = false; private mediaWireInterval: ReturnType | null = null; private playerObserver: MutationObserver | null = null; private applyingRemoteMedia = false; private lastMediaTimeBroadcastMs = 0; private audienceMutedPlaybackKeys = new Set(); private blockedAudienceMedia = new Map(); private audienceMediaUnlockButton: HTMLButtonElement | null = null; // Bumped whenever autoplay starts or media is stopped (slide change), so a // pending re-assert from a previous autoplay can't replay a clip we've left. private autoplayToken = 0; /** Whether audio is currently muted. Reflects `data-hf-muted` attribute. */ get muted(): boolean { return this._muted; } /** Mode resolves from the `mode` attribute, falling back to the URL query * (?mode=audience) so the audience tab 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; } } // Observe the attributes the component reads so runtime toggles take effect. static get observedAttributes(): string[] { return ["sound", "mode"]; } attributeChangedCallback(): void { // Re-render once bound so a flipped `sound`/`mode` is reflected (mute button, // audience-vs-presenter chrome). No-op before the controller binds. if (this.controller) this.render(); } connectedCallback(): void { this.disconnected = false; this.initInFlight = false; this.initGeneration += 1; this.tabIndex = 0; // Keydowns with focus inside the player iframe don't reach this window // listener — attachIframeKeyForwarding() (wired in init) covers that path. window.addEventListener("keydown", this.onKey); this.addEventListener("touchstart", this.onTouchStart, { passive: true }); this.addEventListener("touchend", this.onTouchEnd); window.addEventListener("message", this.onMessage); document.addEventListener("fullscreenchange", this.onFsChange); this.initChannel(); this.observeInteractivePlayers(); // Defer player-dependent init to a macrotask so that child elements are // parsed before we query for . This matters when the // bundle is loaded synchronously (e.g.