mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: allow clip animation + ship <hyperframes-player> web component (#209)
## Summary Two independent initiatives that improve agent DX and expand HyperFrames' reach. ### Initiative 1: Fix the Clip Animation Footgun - `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element - All other properties (opacity, transform, x, y, scale, etc.) are allowed silently - This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1) ### Initiative 2: `<hyperframes-player>` Web Component - New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped - Iframe-based web component with Shadow DOM for perfect isolation - Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events - Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide - Full docs page at `docs/packages/player.mdx` ## Before / After ### Clip animation lint **Before (10/10 agents hit this):** ``` ✗ gsap_animates_clip_element: GSAP animation targets a clip element. Selector "#title" resolves to element <div id="title" class="clip">. The framework manages clip visibility — animate an inner wrapper instead. Fix: Wrap content in a child <div> and target that with GSAP. ``` **After (only errors on actual conflicts):** ``` # This passes lint — no error: tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0); # This still errors — actual conflict with runtime: tl.to("#title", { visibility: "hidden" }, 3); ✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element. Fix: Remove the visibility/display tween. Use opacity for fade effects. ``` ### Embeddable player **Before:** No way to embed a composition in a web page. **After:** ```html <script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script> <hyperframes-player src="./composition/index.html" controls></hyperframes-player> ``` ```js const player = document.querySelector('hyperframes-player'); player.play(); player.pause(); player.seek(2.5); player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration)); ``` ## Test plan - [x] 427 core tests pass (20 GSAP lint tests with smart detection) - [x] 7 player tests pass (formatTime + element registration) - [x] TypeScript compiles cleanly (core + player) - [x] Lint: GSAP animating clip with safe props → 0 errors - [x] Lint: GSAP animating clip with `visibility` → 1 error (correct) - [x] Player builds to 3.3KB gzipped ESM - [x] Lockfile updated for CI - [x] Docs page added at `docs/packages/player.mdx`
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.2.2",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/player"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/hyperframes-player.js",
|
||||
"types": "./dist/hyperframes-player.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/hyperframes-player.js",
|
||||
"require": "./dist/hyperframes-player.cjs",
|
||||
"script": "./dist/hyperframes-player.global.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { PLAY_ICON, PAUSE_ICON } from "./styles.js";
|
||||
|
||||
export interface ControlsCallbacks {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (fraction: number) => void;
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function createControls(
|
||||
parent: ShadowRoot | HTMLElement,
|
||||
callbacks: ControlsCallbacks,
|
||||
): {
|
||||
updateTime: (current: number, duration: number) => void;
|
||||
updatePlaying: (playing: boolean) => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "hfp-controls";
|
||||
// Keep overlay interactions from falling through to the host-level click toggle.
|
||||
controls.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
playBtn.className = "hfp-play-btn";
|
||||
playBtn.type = "button";
|
||||
playBtn.innerHTML = PLAY_ICON;
|
||||
playBtn.setAttribute("aria-label", "Play");
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
scrubber.className = "hfp-scrubber";
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hfp-progress";
|
||||
progress.style.width = "0%";
|
||||
scrubber.appendChild(progress);
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "hfp-time";
|
||||
time.textContent = "0:00 / 0:00";
|
||||
|
||||
controls.appendChild(playBtn);
|
||||
controls.appendChild(scrubber);
|
||||
controls.appendChild(time);
|
||||
parent.appendChild(controls);
|
||||
|
||||
let isPlaying = false;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
playBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (isPlaying) callbacks.onPause();
|
||||
else callbacks.onPlay();
|
||||
});
|
||||
|
||||
const handleScrubAt = (clientX: number) => {
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
callbacks.onSeek(fraction);
|
||||
};
|
||||
|
||||
let scrubbing = false;
|
||||
|
||||
scrubber.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
scrubbing = true;
|
||||
handleScrubAt(e.clientX);
|
||||
});
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (scrubbing) handleScrubAt(e.clientX);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
scrubbing = false;
|
||||
};
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
scrubber.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
scrubbing = true;
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleScrubAt(touch.clientX);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (scrubbing) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleScrubAt(touch.clientX);
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
scrubbing = false;
|
||||
};
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
document.addEventListener("touchend", onTouchEnd);
|
||||
|
||||
const startHideTimer = () => {
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (isPlaying) controls.classList.add("hfp-hidden");
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
|
||||
host.addEventListener("mousemove", () => {
|
||||
controls.classList.remove("hfp-hidden");
|
||||
startHideTimer();
|
||||
});
|
||||
host.addEventListener("mouseleave", () => {
|
||||
if (isPlaying) controls.classList.add("hfp-hidden");
|
||||
});
|
||||
|
||||
return {
|
||||
updateTime(current: number, duration: number) {
|
||||
const pct = duration > 0 ? (current / duration) * 100 : 0;
|
||||
progress.style.width = `${pct}%`;
|
||||
time.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
|
||||
},
|
||||
updatePlaying(playing: boolean) {
|
||||
isPlaying = playing;
|
||||
playBtn.innerHTML = playing ? PAUSE_ICON : PLAY_ICON;
|
||||
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
|
||||
if (playing) startHideTimer();
|
||||
else controls.classList.remove("hfp-hidden");
|
||||
},
|
||||
show() {
|
||||
controls.style.display = "";
|
||||
},
|
||||
hide() {
|
||||
controls.style.display = "none";
|
||||
},
|
||||
destroy() {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
document.removeEventListener("touchend", onTouchEnd);
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatTime } from "./controls.js";
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats 0 seconds", () => {
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("formats seconds under a minute", () => {
|
||||
expect(formatTime(45)).toBe("0:45");
|
||||
});
|
||||
|
||||
it("formats exact minutes", () => {
|
||||
expect(formatTime(120)).toBe("2:00");
|
||||
});
|
||||
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(formatTime(95)).toBe("1:35");
|
||||
});
|
||||
|
||||
it("pads seconds with leading zero", () => {
|
||||
expect(formatTime(61)).toBe("1:01");
|
||||
});
|
||||
|
||||
it("floors fractional seconds", () => {
|
||||
expect(formatTime(3.7)).toBe("0:03");
|
||||
});
|
||||
|
||||
it("handles negative input", () => {
|
||||
expect(formatTime(-5)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createControls, type ControlsCallbacks } from "./controls.js";
|
||||
import { PLAYER_STYLES } from "./styles.js";
|
||||
|
||||
const DEFAULT_FPS = 30;
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
|
||||
class HyperframesPlayer extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["src", "width", "height", "controls", "muted", "poster", "playback-rate"];
|
||||
}
|
||||
|
||||
private shadow: ShadowRoot;
|
||||
private container: HTMLDivElement;
|
||||
private iframe: HTMLIFrameElement;
|
||||
private posterEl: HTMLImageElement | null = null;
|
||||
private controlsApi: ReturnType<typeof createControls> | null = null;
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
private _ready = false;
|
||||
private _duration = 0;
|
||||
private _currentTime = 0;
|
||||
private _paused = true;
|
||||
private _compositionWidth = 1920;
|
||||
private _compositionHeight = 1080;
|
||||
private _probeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private _lastUpdateMs = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = PLAYER_STYLES;
|
||||
this.shadow.appendChild(style);
|
||||
|
||||
this.container = document.createElement("div");
|
||||
this.container.className = "hfp-container";
|
||||
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.className = "hfp-iframe";
|
||||
this.iframe.sandbox.add("allow-scripts", "allow-same-origin");
|
||||
this.iframe.allow = "autoplay; fullscreen";
|
||||
this.iframe.referrerPolicy = "no-referrer";
|
||||
this.iframe.title = "HyperFrames Composition";
|
||||
|
||||
this.container.appendChild(this.iframe);
|
||||
this.shadow.appendChild(this.container);
|
||||
|
||||
// Clicking the bare player surface toggles play/pause.
|
||||
// Ignore shadow-DOM control interactions so overlay clicks don't double-handle.
|
||||
this.addEventListener("click", (event) => {
|
||||
if (this._isControlsClick(event)) return;
|
||||
if (this._paused) this.play();
|
||||
else this.pause();
|
||||
});
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this._updateScale());
|
||||
|
||||
this._onMessage = this._onMessage.bind(this);
|
||||
this._onIframeLoad = this._onIframeLoad.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.resizeObserver.observe(this);
|
||||
window.addEventListener("message", this._onMessage);
|
||||
this.iframe.addEventListener("load", this._onIframeLoad);
|
||||
|
||||
if (this.hasAttribute("controls")) this._setupControls();
|
||||
if (this.hasAttribute("poster")) this._setupPoster();
|
||||
if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.resizeObserver.disconnect();
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
this.controlsApi?.destroy();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
switch (name) {
|
||||
case "src":
|
||||
if (val) {
|
||||
this._ready = false;
|
||||
this.iframe.src = val;
|
||||
}
|
||||
break;
|
||||
case "width":
|
||||
this._compositionWidth = parseInt(val || "1920", 10);
|
||||
this._updateScale();
|
||||
break;
|
||||
case "height":
|
||||
this._compositionHeight = parseInt(val || "1080", 10);
|
||||
this._updateScale();
|
||||
break;
|
||||
case "controls":
|
||||
if (val !== null) this._setupControls();
|
||||
else {
|
||||
this.controlsApi?.destroy();
|
||||
this.controlsApi = null;
|
||||
}
|
||||
break;
|
||||
case "poster":
|
||||
this._setupPoster();
|
||||
break;
|
||||
case "playback-rate":
|
||||
this._sendControl("set-playback-rate", { playbackRate: parseFloat(val || "1") });
|
||||
break;
|
||||
case "muted":
|
||||
this._sendControl("set-muted", { muted: val !== null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
play() {
|
||||
this._hidePoster();
|
||||
this._sendControl("play");
|
||||
this._paused = false;
|
||||
this.controlsApi?.updatePlaying(true);
|
||||
this.dispatchEvent(new Event("play"));
|
||||
}
|
||||
|
||||
pause() {
|
||||
this._sendControl("pause");
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
}
|
||||
|
||||
seek(timeInSeconds: number) {
|
||||
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
|
||||
this._sendControl("seek", { frame });
|
||||
this._currentTime = timeInSeconds;
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
return this._currentTime;
|
||||
}
|
||||
set currentTime(t: number) {
|
||||
this.seek(t);
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this._duration;
|
||||
}
|
||||
get paused() {
|
||||
return this._paused;
|
||||
}
|
||||
get ready() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
get playbackRate() {
|
||||
return parseFloat(this.getAttribute("playback-rate") || "1");
|
||||
}
|
||||
set playbackRate(r: number) {
|
||||
this.setAttribute("playback-rate", String(r));
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this.hasAttribute("muted");
|
||||
}
|
||||
set muted(m: boolean) {
|
||||
if (m) this.setAttribute("muted", "");
|
||||
else this.removeAttribute("muted");
|
||||
}
|
||||
|
||||
get loop() {
|
||||
return this.hasAttribute("loop");
|
||||
}
|
||||
set loop(l: boolean) {
|
||||
if (l) this.setAttribute("loop", "");
|
||||
else this.removeAttribute("loop");
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
|
||||
try {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action, ...extra },
|
||||
"*",
|
||||
);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
|
||||
private _isControlsClick(event: Event) {
|
||||
return event
|
||||
.composedPath()
|
||||
.some((target) => target instanceof HTMLElement && target.classList.contains("hfp-controls"));
|
||||
}
|
||||
|
||||
private _onMessage(e: MessageEvent) {
|
||||
if (e.source !== this.iframe.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (!data || data.source !== "hf-preview") return;
|
||||
|
||||
if (data.type === "state") {
|
||||
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
|
||||
const wasPlaying = !this._paused;
|
||||
this._paused = !data.isPlaying;
|
||||
|
||||
// Throttle UI updates and event dispatch to ~10fps to avoid excessive re-renders
|
||||
const now = performance.now();
|
||||
if (now - this._lastUpdateMs > 100 || this._paused !== wasPlaying) {
|
||||
this._lastUpdateMs = now;
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
this.controlsApi?.updatePlaying(!this._paused);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("timeupdate", { detail: { currentTime: this._currentTime } }),
|
||||
);
|
||||
}
|
||||
|
||||
if (this._currentTime >= this._duration && !this._paused) {
|
||||
if (this.loop) {
|
||||
this.seek(0);
|
||||
this.play();
|
||||
} else {
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.dispatchEvent(new Event("ended"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "timeline" && data.durationInFrames > 0) {
|
||||
this._duration = data.durationInFrames / DEFAULT_FPS;
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
}
|
||||
|
||||
if (data.type === "stage-size" && data.width > 0 && data.height > 0) {
|
||||
this._compositionWidth = data.width;
|
||||
this._compositionHeight = data.height;
|
||||
this._updateScale();
|
||||
}
|
||||
}
|
||||
|
||||
private _runtimeInjected = false;
|
||||
|
||||
private _onIframeLoad() {
|
||||
let attempts = 0;
|
||||
this._runtimeInjected = false;
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
|
||||
this._probeInterval = setInterval(() => {
|
||||
attempts++;
|
||||
try {
|
||||
const win = this.iframe.contentWindow as Window & {
|
||||
__player?: { getDuration: () => number };
|
||||
__timelines?: Record<string, { duration: () => number }>;
|
||||
__hf?: unknown;
|
||||
};
|
||||
if (!win) return;
|
||||
|
||||
// Check if the runtime bridge is active (__hf or __player from the runtime)
|
||||
const hasRuntime = !!(win.__hf || win.__player);
|
||||
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
|
||||
|
||||
// Auto-inject runtime if GSAP timelines exist but no runtime bridge
|
||||
if (!hasRuntime && hasTimelines && !this._runtimeInjected && attempts >= 5) {
|
||||
this._injectRuntime();
|
||||
return; // Wait for runtime to load and initialize
|
||||
}
|
||||
|
||||
const getAdapter = () => {
|
||||
if (win.__player && typeof win.__player.getDuration === "function") return win.__player;
|
||||
if (win.__timelines) {
|
||||
const keys = Object.keys(win.__timelines);
|
||||
if (keys.length > 0) {
|
||||
const tl = win.__timelines[keys[keys.length - 1]];
|
||||
return { getDuration: () => tl.duration() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const adapter = getAdapter();
|
||||
if (adapter && adapter.getDuration() > 0) {
|
||||
clearInterval(this._probeInterval!);
|
||||
this._duration = adapter.getDuration();
|
||||
this._ready = true;
|
||||
this.controlsApi?.updateTime(0, this._duration);
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { duration: this._duration } }));
|
||||
|
||||
// Auto-detect dimensions from composition
|
||||
const doc = this.iframe.contentDocument;
|
||||
const root = doc?.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const w = parseInt(root.getAttribute("data-width") || "0", 10);
|
||||
const h = parseInt(root.getAttribute("data-height") || "0", 10);
|
||||
if (w > 0 && h > 0) {
|
||||
this._compositionWidth = w;
|
||||
this._compositionHeight = h;
|
||||
this._updateScale();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasAttribute("autoplay")) {
|
||||
this.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
|
||||
if (attempts >= 40) {
|
||||
clearInterval(this._probeInterval!);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("error", {
|
||||
detail: { message: "Composition timeline not found after 8s" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Inject the HyperFrames runtime into the iframe if not already present. */
|
||||
private _injectRuntime() {
|
||||
this._runtimeInjected = true;
|
||||
try {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
const script = doc.createElement("script");
|
||||
script.src = RUNTIME_CDN_URL;
|
||||
script.onload = () => {
|
||||
// Runtime loaded — the probe interval will pick up __hf on next tick
|
||||
};
|
||||
script.onerror = () => {
|
||||
// CDN failed — the probe will continue and eventually timeout
|
||||
};
|
||||
(doc.head || doc.documentElement).appendChild(script);
|
||||
} catch {
|
||||
/* cross-origin — can't inject */
|
||||
}
|
||||
}
|
||||
|
||||
private _updateScale() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const scale = Math.min(
|
||||
rect.width / this._compositionWidth,
|
||||
rect.height / this._compositionHeight,
|
||||
);
|
||||
this.iframe.style.width = `${this._compositionWidth}px`;
|
||||
this.iframe.style.height = `${this._compositionHeight}px`;
|
||||
this.iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
}
|
||||
|
||||
private _setupControls() {
|
||||
if (this.controlsApi) return;
|
||||
const callbacks: ControlsCallbacks = {
|
||||
onPlay: () => this.play(),
|
||||
onPause: () => this.pause(),
|
||||
onSeek: (fraction) => this.seek(fraction * this._duration),
|
||||
};
|
||||
this.controlsApi = createControls(this.shadow, callbacks);
|
||||
}
|
||||
|
||||
private _setupPoster() {
|
||||
const url = this.getAttribute("poster");
|
||||
if (!url) {
|
||||
this.posterEl?.remove();
|
||||
this.posterEl = null;
|
||||
return;
|
||||
}
|
||||
if (!this.posterEl) {
|
||||
this.posterEl = document.createElement("img");
|
||||
this.posterEl.className = "hfp-poster";
|
||||
this.shadow.appendChild(this.posterEl);
|
||||
}
|
||||
this.posterEl.src = url;
|
||||
}
|
||||
|
||||
private _hidePoster() {
|
||||
this.posterEl?.remove();
|
||||
this.posterEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("hyperframes-player")) {
|
||||
customElements.define("hyperframes-player", HyperframesPlayer);
|
||||
}
|
||||
|
||||
export { HyperframesPlayer };
|
||||
export { formatTime } from "./controls.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
export const PLAYER_STYLES = /* css */ `
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.hfp-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.hfp-iframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
color: #fff;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hfp-controls.hfp-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-play-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hfp-play-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hfp-play-btn svg,
|
||||
.hfp-play-btn svg * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-scrubber {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hfp-scrubber:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.hfp-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
export const PLAY_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><polygon points="4,2 16,9 4,16"/></svg>`;
|
||||
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/hyperframes-player.ts"],
|
||||
format: ["esm", "cjs", "iife"],
|
||||
globalName: "HyperframesPlayer",
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
Reference in New Issue
Block a user