mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): support web-component refs in useTimelinePlayer (#245)
* fix(studio): support web-component refs in useTimelinePlayer The studio's `useTimelinePlayer` hook returns an `iframeRef` that consumers attach to an `<iframe>` element. When consumers wrap the iframe in a custom element (e.g. `<hyperframes-player>`) that puts the iframe inside its shadow DOM, every `iframeRef.current.contentWindow` access returned `null` and `getAdapter()` silently failed — meaning timeline seek, play, pause, and `refreshPlayer` all became no-ops. Changes: - Add `resolveIframe(el)` helper that returns the underlying iframe whether the host is the iframe itself, a custom element with a shadow-DOM iframe, or a wrapper with a descendant iframe. - Export `resolveIframe` from the studio so consumers can pre-resolve the iframe before assigning it to `iframeRef`. - Internal `useTimelinePlayer` keeps the strict `HTMLIFrameElement` ref type, so existing consumers attaching directly to an `<iframe>` are unaffected. Also adds: - JSDoc on the player's `iframeElement` getter. - "Advanced: iframe access" docs section in `packages/player/README.md` and `docs/packages/player.mdx`. - Type-safety lint rules in `.oxlintrc.json` and a "Type-safety conventions" section in `CONTRIBUTING.md`. Backward compatible — App.tsx and NLELayout.tsx continue to work unchanged. * chore(lint): defer no-explicit-any rule; it broke existing codebase The new rules added 37 errors across 32 existing files — mostly legitimate `window as any` casts at browser-global and test-mock boundaries. Enabling them without fixing all violations breaks CI. Revert the `.oxlintrc.json` additions and soften the CONTRIBUTING.md wording to describe the convention without claiming lint enforcement (that enforcement will come in a follow-up PR that fixes all sites).
This commit is contained in:
@@ -186,8 +186,34 @@ function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the underlying iframe from any host element. Supports:
|
||||
* - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
|
||||
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
|
||||
* - Wrapper elements whose light DOM contains a descendant iframe
|
||||
*
|
||||
* Exported so web-component consumers can pre-resolve the iframe before
|
||||
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
|
||||
* when the element has no associated iframe yet.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { iframeRef } = useTimelinePlayer();
|
||||
* const playerElRef = useRef<HyperframesPlayer>(null);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* iframeRef.current = resolveIframe(playerElRef.current);
|
||||
* }, [iframeRef]);
|
||||
* ```
|
||||
*/
|
||||
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
||||
if (!el) return null;
|
||||
if (el instanceof HTMLIFrameElement) return el;
|
||||
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
||||
}
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
@@ -200,7 +226,8 @@ export function useTimelinePlayer() {
|
||||
|
||||
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
||||
try {
|
||||
const win = iframeRef.current?.contentWindow as IframeWindow | null;
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow as IframeWindow | null;
|
||||
if (!win) return null;
|
||||
|
||||
if (win.__player && typeof win.__player.play === "function") {
|
||||
@@ -554,8 +581,9 @@ export function useTimelinePlayer() {
|
||||
setIsPlaying(false);
|
||||
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
const iframeWin = iframeRef.current?.contentWindow as IframeWindow | null;
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
if (doc && iframeWin) {
|
||||
normalizePreviewViewport(doc, iframeWin);
|
||||
autoHealMissingCompositionIds(doc);
|
||||
@@ -591,7 +619,7 @@ export function useTimelinePlayer() {
|
||||
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
|
||||
// Derive compositionSrc from the iframe URL for thumbnail rendering.
|
||||
// URL pattern: /api/projects/{id}/preview/comp/{path}
|
||||
const iframeSrc = iframeRef.current?.src || "";
|
||||
const iframeSrc = iframe?.src || "";
|
||||
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
||||
const compositionSrc = compPathMatch
|
||||
? decodeURIComponent(compPathMatch[1])
|
||||
@@ -682,7 +710,8 @@ export function useTimelinePlayer() {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
|
||||
if (e.source && iframeRef.current && e.source !== iframeRef.current.contentWindow) {
|
||||
const ourIframe = iframeRef.current;
|
||||
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
// Also handle the runtime's state message which includes timeline data
|
||||
@@ -690,8 +719,7 @@ export function useTimelinePlayer() {
|
||||
// State message means the runtime is alive — check for elements
|
||||
try {
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
|
||||
const manifest = iframeWin?.__clipManifest;
|
||||
if (manifest && manifest.clips.length > 0) {
|
||||
processTimelineMessageRef.current(manifest);
|
||||
@@ -717,8 +745,7 @@ export function useTimelinePlayer() {
|
||||
// If manifest produced 0 elements after filtering, try DOM fallback
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const doc = ourIframe?.contentDocument;
|
||||
const adapter = getAdapter();
|
||||
if (doc && adapter) {
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
|
||||
@@ -6,7 +6,7 @@ export { VideoThumbnail } from "./components/VideoThumbnail";
|
||||
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
// Hooks
|
||||
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
||||
export { useTimelinePlayer, resolveIframe } from "./hooks/useTimelinePlayer";
|
||||
|
||||
// Store
|
||||
export { usePlayerStore, liveTime } from "./store/playerStore";
|
||||
|
||||
Reference in New Issue
Block a user