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:
Miguel Ángel
2026-04-13 17:53:01 +02:00
committed by GitHub
parent 6629865fc6
commit 1149602bc9
7 changed files with 197 additions and 12 deletions
@@ -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());
+1 -1
View File
@@ -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";