/** * Playback adapter utilities: factory for the static-seek adapter used when a * composition exposes only a `renderSeek` / `seek` API (no native play/pause * support), plus a thin wrapper that normalises GSAP-style `TimelineLike` * objects to the `PlaybackAdapter` interface. */ import type { PlaybackAdapter, RuntimePlaybackAdapter, StaticSeekPlaybackClock, TimelineLike, } from "./playbackTypes"; // --------------------------------------------------------------------------- // Pure numeric helpers // --------------------------------------------------------------------------- export function isFinitePositive(value: number): boolean { return Number.isFinite(value) && value > 0; } function clampTime(time: number, duration: number): number { const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0); const safeTime = Math.max(0, Number.isFinite(time) ? time : 0); return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime; } export function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number { if (!adapter) return 0; try { const duration = Number(adapter.getDuration()); return isFinitePositive(duration) ? duration : 0; } catch { return 0; } } // --------------------------------------------------------------------------- // Clock factory // --------------------------------------------------------------------------- export function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock { return { now: () => win.performance.now(), requestAnimationFrame: (callback) => win.requestAnimationFrame(callback), cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle), }; } // --------------------------------------------------------------------------- // Static-seek adapter // --------------------------------------------------------------------------- /** * Wraps a render-only player (exposes `renderSeek`/`seek` but no native * play/pause) and drives playback via `requestAnimationFrame`. */ export function createStaticSeekPlaybackAdapter( player: Pick & Partial>, duration: number, clock: StaticSeekPlaybackClock, getPlaybackRate: () => number = () => 1, ): PlaybackAdapter { const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0); let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration); let playing = false; let rafId = 0; let playStartTime = currentTime; let playStartNow = clock.now(); const renderSeek = (time: number) => { currentTime = clampTime(time, safeDuration); if (typeof player.renderSeek === "function") { player.renderSeek(currentTime); return; } player.seek?.(currentTime); }; const stopTicker = () => { if (rafId) { clock.cancelAnimationFrame(rafId); rafId = 0; } }; const tick: FrameRequestCallback = (now) => { if (!playing) return; const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1); const elapsed = ((now - playStartNow) / 1000) * playbackRate; renderSeek(playStartTime + elapsed); if (currentTime >= safeDuration) { playing = false; rafId = 0; return; } rafId = clock.requestAnimationFrame(tick); }; return { play: () => { if (playing || safeDuration <= 0) return; if (currentTime >= safeDuration) renderSeek(0); playing = true; playStartTime = currentTime; playStartNow = clock.now(); stopTicker(); rafId = clock.requestAnimationFrame(tick); }, pause: () => { playing = false; stopTicker(); }, seek: (time, options) => { renderSeek(time); if (options?.keepPlaying) { if (playing) { playStartTime = currentTime; playStartNow = clock.now(); } return; } // Default seek aligns with wrapTimeline: stop the RAF ticker so the // adapter's `playing` flag matches the public seek contract instead of // silently driving renderSeek in the background. playing = false; stopTicker(); }, getTime: () => currentTime, getDuration: () => safeDuration, isPlaying: () => playing, }; } // --------------------------------------------------------------------------- // Static-seek fallback cache // --------------------------------------------------------------------------- export type StaticSeekCacheEntry = { player: RuntimePlaybackAdapter | PlaybackAdapter; duration: number; adapter: PlaybackAdapter; }; type StaticSeekCacheRef = { current: StaticSeekCacheEntry | null }; type WarnedRef = { current: boolean }; /** * Pause and drop the cached static-seek adapter. Must be called whenever * adapter selection switches to a native adapter — a cached static-seek * adapter that was mid-play keeps its private rAF loop seeking the player * forever otherwise, fighting the native transport. Also re-arms the * downgrade warning so a later re-downgrade is surfaced again. */ export function releaseStaticSeekCache(cache: StaticSeekCacheRef, warned: WarnedRef): void { cache.current?.adapter.pause(); cache.current = null; warned.current = false; } /** * Resolve (with caching) the seek-driven fallback adapter. Warns once per * downgrade streak: seek-driven playback never starts media elements or * WebAudio, so without the warning the downgrade silently loses audio. */ export function resolveStaticSeekFallback(opts: { cache: StaticSeekCacheRef; warned: WarnedRef; bestAdapter: RuntimePlaybackAdapter | PlaybackAdapter; effectiveDuration: number; docDuration: number; clock: StaticSeekPlaybackClock; getPlaybackRate: () => number; }): PlaybackAdapter { const { cache, warned, bestAdapter, effectiveDuration, docDuration } = opts; const cached = cache.current; if (cached?.player === bestAdapter && cached.duration === effectiveDuration) { return cached.adapter; } cached?.adapter.pause(); if (!warned.current) { warned.current = true; console.warn( `[useTimelinePlayer] Selected adapter duration (${getAdapterDuration(bestAdapter)}s) does not cover the document duration (${docDuration}s); falling back to seek-driven playback, which never starts media elements or WebAudio. Audio will not play in preview — extend the GSAP timeline to cover the declared data-duration.`, ); } const adapter = createStaticSeekPlaybackAdapter( bestAdapter, effectiveDuration, opts.clock, opts.getPlaybackRate, ); cache.current = { player: bestAdapter, duration: effectiveDuration, adapter }; return adapter; } // --------------------------------------------------------------------------- // GSAP timeline wrapper // --------------------------------------------------------------------------- export function wrapTimeline(tl: TimelineLike): PlaybackAdapter { return { play: () => tl.play(), pause: () => tl.pause(), seek: (t, options) => { const shouldPause = !options?.keepPlaying; if (shouldPause) tl.pause(); tl.seek(t); if (shouldPause) tl.pause(); }, getTime: () => tl.time(), getDuration: () => tl.duration(), isPlaying: () => tl.isActive(), }; }