mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user