mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
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<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 {
|
|
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<string, unknown> {
|
|
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>;
|
|
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<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.
|
|
* 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<PresenterMediaMessage, "type" | "sender">): 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, "<").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
|
|
? `<div style="display:flex;flex-direction:column;gap:6px;">
|
|
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Branches</div>
|
|
${opts.hotspots
|
|
.map(
|
|
(h) =>
|
|
`<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("")}
|
|
</div>`
|
|
: "";
|
|
return `
|
|
<div data-hf-presenter style="position:absolute;left:0;right:0;bottom:0;height:32%;display:flex;background:#11151f;color:#fff;border-top:2px solid rgba(255,255,255,0.12);box-sizing:border-box;font-family:sans-serif;pointer-events:auto;">
|
|
<textarea data-hf-presenter-notes data-hf-presenter-notes-key="${escAttr(opts.notesStorageKey ?? "")}" aria-label="Speaker notes" placeholder="No notes for this slide" spellcheck="true" style="flex:1;min-width:0;padding:24px 36px;overflow:auto;font:inherit;font-size:21px;line-height:1.55;color:#fff;background:transparent;border:0;outline:none;resize:none;white-space:pre-wrap;pointer-events:auto;">${notes}</textarea>
|
|
<div style="width:380px;flex-shrink:0;border-left:1px solid rgba(255,255,255,0.12);padding:24px 28px;display:flex;flex-direction:column;gap:10px;">
|
|
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Up next</div>
|
|
<div data-hf-presenter-next style="font-size:17px;opacity:.9;line-height:1.4;">${esc(opts.nextText)}</div>
|
|
${branches}
|
|
<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-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-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>`.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")}`;
|
|
}
|