import type { RuntimePlayer, RuntimeTimelineLike } from "./types"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { swallow } from "./diagnostics"; /** * Safely read a numeric value from a timeline property that may be either a * function (conformant GSAP) or a bare number (user-authored timeline-like). */ function safeNum(obj: unknown, prop: string, fallback: number): number { const val = (obj as Record)?.[prop]; if (typeof val === "function") return Number(val.call(obj)) || fallback; if (typeof val === "number" && Number.isFinite(val)) return val; if (val !== undefined && val !== null) { swallow("runtime.player.nonConformantNum", { prop, actual: typeof val }); } return fallback; } /** * Safely invoke a void method on a timeline. If the method is not a function * (missing or overwritten with a non-callable value), silently skip. */ function safeVoid(obj: unknown, method: string): void { const fn = (obj as Record)?.[method]; if (typeof fn === "function") { fn.call(obj); return; } if (fn !== undefined) { swallow("runtime.player.nonConformantVoid", { method, actual: typeof fn }); } } type PlayerDeps = { getTimeline: () => RuntimeTimelineLike | null; setTimeline: (timeline: RuntimeTimelineLike | null) => void; getIsPlaying: () => boolean; setIsPlaying: (playing: boolean) => void; getPlaybackRate: () => number; setPlaybackRate: (rate: number) => void; getCanonicalFps: () => number; onSyncMedia: (timeSeconds: number, playing: boolean) => void; onStatePost: (force: boolean) => void; onDeterministicSeek: (timeSeconds: number) => void; onDeterministicPause: () => void; onDeterministicPlay: () => void; onRenderFrameSeek: (timeSeconds: number) => void; onShowNativeVideos: () => void; getSafeDuration?: () => number; /** * Optional registry of sibling timelines (typically `window.__timelines`). * Provided so that play/pause propagate to sub-scene timelines registered * alongside the master — e.g. a nested-composition master with per-scene * timelines like `scene1-logo-intro`, `scene2-4-canvas`. Without this, * pausing the master would leave scene timelines free-running and * animations would continue to advance visually past the paused time. */ getTimelineRegistry?: () => Record; }; function forEachSiblingTimeline( registry: Record | undefined | null, master: RuntimeTimelineLike, fn: (tl: RuntimeTimelineLike) => void, ): void { if (!registry) return; for (const tl of Object.values(registry)) { if (!tl || tl === master) continue; try { fn(tl); } catch (err) { // ignore sibling failures — one broken timeline shouldn't poison play/pause swallow("runtime.player.site1", err); } } } function seekTimelineDeterministically( timeline: RuntimeTimelineLike, timeSeconds: number, canonicalFps: number, ): number { const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps); safeVoid(timeline, "pause"); if (typeof timeline.totalTime === "function") { timeline.totalTime(quantized, false); } else { if (typeof timeline.seek === "function") timeline.seek(quantized, false); } return quantized; } function seekMasterAndSiblingTimelinesDeterministically( registry: Record | undefined | null, master: RuntimeTimelineLike, timeSeconds: number, canonicalFps: number, ): number { const rearmedSiblings: RuntimeTimelineLike[] = []; forEachSiblingTimeline(registry, master, (tl) => { safeVoid(tl, "play"); rearmedSiblings.push(tl); }); try { return seekTimelineDeterministically(master, timeSeconds, canonicalFps); } finally { for (const tl of rearmedSiblings) { try { safeVoid(tl, "pause"); } catch (err) { // ignore sibling failures — one broken timeline shouldn't poison seek swallow("runtime.player.site2", err); } } } } function activateSiblingTimelines( registry: Record | undefined | null, master: RuntimeTimelineLike, ): void { forEachSiblingTimeline(registry, master, (tl) => { safeVoid(tl, "play"); }); } export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { return { _timeline: null, play: () => { const timeline = deps.getTimeline(); if (!timeline || deps.getIsPlaying()) return; const safeDuration = Math.max( 0, Number(deps.getSafeDuration?.() ?? safeNum(timeline, "duration", 0)) || 0, ); if (safeDuration > 0) { const currentTime = Math.max(0, safeNum(timeline, "time", 0)); if (currentTime >= safeDuration) { safeVoid(timeline, "pause"); if (typeof timeline.seek === "function") timeline.seek(0, false); deps.onDeterministicSeek(0); deps.setIsPlaying(false); deps.onSyncMedia(0, false); deps.onRenderFrameSeek(0); } } if (typeof timeline.timeScale === "function") { timeline.timeScale(deps.getPlaybackRate()); } safeVoid(timeline, "play"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate()); safeVoid(tl, "play"); }); deps.onDeterministicPlay(); deps.setIsPlaying(true); deps.onShowNativeVideos(); deps.onStatePost(true); }, pause: () => { const timeline = deps.getTimeline(); if (!timeline) return; safeVoid(timeline, "pause"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { safeVoid(tl, "pause"); }); const time = Math.max(0, safeNum(timeline, "time", 0)); deps.onDeterministicSeek(time); deps.onDeterministicPause(); deps.setIsPlaying(false); deps.onSyncMedia(time, false); deps.onRenderFrameSeek(time); deps.onStatePost(true); }, seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => { const timeline = deps.getTimeline(); if (!timeline) return; const safeTime = Math.max(0, Number(timeSeconds) || 0); const wasPlaying = deps.getIsPlaying(); const quantized = seekMasterAndSiblingTimelinesDeterministically( deps.getTimelineRegistry?.(), timeline, safeTime, deps.getCanonicalFps(), ); deps.onDeterministicSeek(quantized); if (options?.keepPlaying && wasPlaying) { // The deterministic seek helper pauses the master and rearmed siblings. // Resume them so the caller's playback state survives the seek. if (typeof timeline.timeScale === "function") { timeline.timeScale(deps.getPlaybackRate()); } safeVoid(timeline, "play"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate()); safeVoid(tl, "play"); }); deps.onDeterministicPlay(); deps.onShowNativeVideos(); deps.onSyncMedia(quantized, true); } else { deps.setIsPlaying(false); deps.onSyncMedia(quantized, false); } deps.onRenderFrameSeek(quantized); deps.onStatePost(true); }, renderSeek: (timeSeconds: number) => { const timeline = deps.getTimeline(); const canonicalFps = deps.getCanonicalFps(); // When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie / // Three.js adapters driving the animation), still seek the adapters so // their animations advance. Without this, non-GSAP compositions freeze // on their initial frame. const quantized = timeline ? (() => { // Export seeks run frame-by-frame through the resolved root timeline. // If nested siblings stay paused, GSAP collapses the root back to the // authored master duration and later frames clamp incorrectly. activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline); return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps); })() : quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps); deps.onDeterministicSeek(quantized); deps.setIsPlaying(false); deps.onSyncMedia(quantized, false); deps.onRenderFrameSeek(quantized); deps.onStatePost(true); }, getTime: () => safeNum(deps.getTimeline(), "time", 0), getDuration: () => safeNum(deps.getTimeline(), "duration", 0), isPlaying: () => deps.getIsPlaying(), setPlaybackRate: (rate: number) => deps.setPlaybackRate(rate), getPlaybackRate: () => deps.getPlaybackRate(), }; }