export interface PresenterPosition { sequenceId: string; slideIndex: 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([ "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 { type: "goto"; sequenceId: string; slideIndex: number; fragmentIndex: number; } 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 { 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; return ( d["type"] === "goto" && typeof d["sequenceId"] === "string" && typeof d["slideIndex"] === "number" && typeof d["fragmentIndex"] === "number" ); } 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): boolean { return MEDIA_NUMBER_FIELDS.every((field) => typeof data[field] === "number"); } function hasMediaBooleanFields(data: Record): 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. * Presenter (default) mode: posts position updates to the channel. * Audience mode: listens for goto messages and calls the provided handler. */ /** * Per-deck channel name. The presenter and its audience window load the same URL * (path), so keying on pathname keeps them paired while isolating other decks * presenting on the same origin (which would otherwise cross-talk on a fixed name). */ export function slideshowChannelName(): string { const path = typeof location !== "undefined" ? location.pathname : ""; return `hf-slideshow:${path}`; } export class SlideshowChannel { private channel: BroadcastChannel | null = null; constructor( private readonly mode: "presenter" | "audience", private readonly onGoto: (msg: GotoMessage) => void, private readonly onMedia: (msg: PresenterMediaMessage) => void = () => {}, ) { try { this.channel = new BroadcastChannel(slideshowChannelName()); } catch { // BroadcastChannel unavailable (e.g. unsupported env); degrade silently. return; } this.channel.onmessage = (e: MessageEvent) => { if (isGotoMessage(e.data)) { if (mode === "audience") { this.onGoto(e.data); } return; } if (isMediaMessage(e.data) && e.data.sender !== mode) { this.onMedia(e.data); } }; } postPosition(pos: PresenterPosition): void { if (this.mode !== "presenter" || !this.channel) return; const msg: GotoMessage = { type: "goto", ...pos }; this.channel.postMessage(msg); } postMedia(msg: Omit): void { if (!this.channel) return; this.channel.postMessage({ type: "media", sender: this.mode, ...msg }); } destroy(): void { if (this.channel) { this.channel.onmessage = null; this.channel.close(); this.channel = null; } } } /** * Builds the presenter-mode bottom panel: speaker notes + up-next + counter + * elapsed. The live slide is shown ABOVE this panel (the component confines the * player to the top region). Returns the panel HTML only — the component appends * the nav controls separately. */ export function buildPresenterLayout(opts: { notes: string; notesStorageKey: string | null; nextText: string; counterText: string; elapsedText: string; hotspots: { id: string; label: string; target: string }[]; }): string { const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const escAttr = (s: string) => esc(s).replace(/"/g, """); const notes = esc(opts.notes); // Branch entries for the current slide — the presenter clicks these to enter a // branch (the audience follows). The component wires [data-hotspot-id] to // enterBranch(); positioned pills don't align with the letterboxed slide, so // they live in the console as a list. const branches = opts.hotspots.length ? `
Branches
${opts.hotspots .map( (h) => ``, ) .join("")}
` : ""; return `
Up next
${esc(opts.nextText)}
${branches}
Slide
${esc(opts.counterText)}
Elapsed
${esc(opts.elapsedText)}
`.trim(); } /** Format elapsed seconds as mm:ss */ export function formatElapsed(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; }