import { useRef, useCallback, useEffect } from "react"; import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; import { usePlaybackKeyboard } from "./usePlaybackKeyboard"; import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks"; // Re-export public API consumed by tests and external modules. // All of these were previously defined in this file; they now live in focused // sub-modules but are re-exported here so existing import sites don't change. export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes"; export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter"; export { getTimelineElementSelector, readTimelineDurationFromDocument, parseTimelineFromDOM, createTimelineElementFromManifestClip, findTimelineDomNodeForClip, buildStandaloneRootTimelineElement, mergeTimelineElementsPreservingDowngrades, resolveStandaloneRootCompositionSrc, resolveIframe, } from "../lib/timelineDOM"; export { shouldIgnorePlaybackShortcutEvent, shouldIgnorePlaybackShortcutTarget, } from "../lib/playbackShortcuts"; import type { PlaybackAdapter, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes"; import { getAdapterDuration, wrapTimeline, createStaticSeekPlaybackAdapter, getDefaultStaticSeekPlaybackClock, } from "../lib/playbackAdapter"; import { readTimelineDurationFromDocument, mergeTimelineElementsPreservingDowngrades, parseTimelineFromDOM, } from "../lib/timelineDOM"; import { setPreviewMediaMuted, setPreviewPlaybackRate, shouldMutePreviewAudio, } from "../lib/timelineIframeHelpers"; // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- export function useTimelinePlayer() { const iframeRef = useRef(null); const rafRef = useRef(0); const probeIntervalRef = useRef | undefined>(undefined); const pendingSeekRef = useRef(null); const isRefreshingRef = useRef(false); const reverseRafRef = useRef(0); const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null); const shuttleSpeedIndexRef = useRef(0); const iframeShortcutCleanupRef = useRef<(() => void) | null>(null); const lastTimelineMessageRef = useRef(0); const staticSeekAdapterRef = useRef<{ player: RuntimePlaybackAdapter; duration: number; adapter: PlaybackAdapter; } | null>(null); // ZERO store subscriptions — this hook never causes re-renders. // All reads use getState() (point-in-time), all writes use the stable setters. const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } = usePlayerStore.getState(); const syncTimelineElements = useCallback( (elements: TimelineElement[], nextDuration?: number) => { const state = usePlayerStore.getState(); const resolvedDuration = nextDuration ?? state.duration; const mergedElements = mergeTimelineElementsPreservingDowngrades( state.elements, elements, state.duration, resolvedDuration, ); const elementsChanged = mergedElements.length !== state.elements.length || mergedElements.some((el, i) => { const prev = state.elements[i]; return ( !prev || el.id !== prev.id || el.start !== prev.start || el.duration !== prev.duration || el.track !== prev.track ); }); if (elementsChanged) { setElements(mergedElements); } if ( Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0 && (nextDuration ?? 0) > state.duration ) { setDuration(nextDuration ?? 0); } if (!state.timelineReady) { setTimelineReady(true); } }, [setElements, setTimelineReady, setDuration], ); const getAdapter = useCallback((): PlaybackAdapter | null => { try { const iframe = iframeRef.current; const win = iframe?.contentWindow as IframeWindow | null; if (!iframe || !win) return null; const playerAdapter = win.__player && typeof win.__player.play === "function" ? win.__player : null; if (getAdapterDuration(playerAdapter) > 0) { return playerAdapter; } if (win.__timeline) { const adapter = wrapTimeline(win.__timeline); if (getAdapterDuration(adapter) > 0) return adapter; } if (win.__timelines) { const keys = Object.keys(win.__timelines); if (keys.length > 0) { // Resolve the root composition id from the DOM — the outermost // `[data-composition-id]` element is the master. Without this, // Object.keys() order would let a sub-composition's timeline // hijack play/pause/seek and the duration readout. const rootId = iframe?.contentDocument ?.querySelector("[data-composition-id]") ?.getAttribute("data-composition-id"); const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1]; const adapter = wrapTimeline(win.__timelines[key]); if (getAdapterDuration(adapter) > 0) return adapter; } } const fallbackDuration = Math.max( usePlayerStore.getState().duration, readTimelineDurationFromDocument(iframe.contentDocument), ); if ( playerAdapter && fallbackDuration > 0 && (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function") ) { const cached = staticSeekAdapterRef.current; if (cached?.player === playerAdapter && cached.duration === fallbackDuration) { return cached.adapter; } cached?.adapter.pause(); const adapter = createStaticSeekPlaybackAdapter( playerAdapter, fallbackDuration, getDefaultStaticSeekPlaybackClock(win), () => usePlayerStore.getState().playbackRate, ); staticSeekAdapterRef.current = { player: playerAdapter, duration: fallbackDuration, adapter, }; return adapter; } return playerAdapter; } catch (err) { console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err); return null; } }, []); const stopReverseLoop = useCallback(() => { cancelAnimationFrame(reverseRafRef.current); }, []); const startRAFLoop = useCallback(() => { const tick = () => { const adapter = getAdapter(); if (adapter) { const time = adapter.getTime(); const dur = adapter.getDuration(); liveTime.notify(time); // direct DOM updates, no React re-render const { inPoint, outPoint } = usePlayerStore.getState(); const rawLoopEnd = outPoint !== null ? outPoint : dur; const rawLoopStart = inPoint !== null ? inPoint : 0; const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur; const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0; if (time >= loopEnd) { if (usePlayerStore.getState().loopEnabled && dur > 0) { adapter.seek(loopStart); liveTime.notify(loopStart); adapter.play(); setIsPlaying(true); rafRef.current = requestAnimationFrame(tick); return; } if (adapter.isPlaying()) adapter.pause(); setCurrentTime(time); // sync Zustand once at end setIsPlaying(false); cancelAnimationFrame(rafRef.current); return; } } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); }, [getAdapter, setCurrentTime, setIsPlaying]); const stopRAFLoop = useCallback(() => { cancelAnimationFrame(rafRef.current); }, []); const applyPlaybackRate = useCallback((rate: number) => { const iframe = iframeRef.current; if (!iframe) return; setPreviewPlaybackRate(iframe, rate); // Also set directly on GSAP timeline if accessible try { const win = iframe.contentWindow as IframeWindow | null; if (win?.__timelines) { for (const tl of Object.values(win.__timelines)) { if ( tl && typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function" ) { (tl as unknown as { timeScale: (v: number) => void }).timeScale(rate); } } } } catch (err) { console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err); } }, []); const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => { const { audioMuted, playbackRate } = usePlayerStore.getState(); const effectivePlaybackRate = playbackRateOverride ?? playbackRate; setPreviewMediaMuted( iframeRef.current, shouldMutePreviewAudio(audioMuted, effectivePlaybackRate), ); }, []); const play = useCallback(() => { stopRAFLoop(); stopReverseLoop(); const adapter = getAdapter(); if (!adapter) return; if (adapter.getTime() >= adapter.getDuration()) { adapter.seek(usePlayerStore.getState().inPoint ?? 0); } applyPlaybackRate(usePlayerStore.getState().playbackRate); applyPreviewAudioState(); adapter.play(); shuttleDirectionRef.current = "forward"; setIsPlaying(true); startRAFLoop(); }, [ getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, applyPreviewAudioState, stopRAFLoop, stopReverseLoop, ]); const playBackward = useCallback( (rate: number) => { stopRAFLoop(); stopReverseLoop(); const adapter = getAdapter(); if (!adapter) return; const duration = Math.max(0, adapter.getDuration()); const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime(); adapter.pause(); if (initialTime !== adapter.getTime()) adapter.seek(initialTime); const speed = Math.max(0.1, Math.min(4, rate)); applyPlaybackRate(speed); applyPreviewAudioState(speed); let startTime = initialTime; let startedAt = performance.now(); const tick = (now: number) => { const elapsed = ((now - startedAt) / 1000) * speed; let nextTime = startTime - elapsed; const { inPoint, outPoint } = usePlayerStore.getState(); const rawLoopEnd = outPoint !== null ? outPoint : duration; const rawLoopStart = inPoint !== null ? inPoint : 0; const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration; const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0; if (nextTime <= loopStart) { if (usePlayerStore.getState().loopEnabled && duration > 0) { startTime = loopEnd; startedAt = now; nextTime = loopEnd; } else { adapter.seek(loopStart); liveTime.notify(loopStart); setCurrentTime(loopStart); setIsPlaying(false); shuttleDirectionRef.current = null; reverseRafRef.current = 0; return; } } adapter.seek(Math.max(0, nextTime)); liveTime.notify(Math.max(0, nextTime)); setIsPlaying(true); reverseRafRef.current = requestAnimationFrame(tick); }; setIsPlaying(true); shuttleDirectionRef.current = "backward"; reverseRafRef.current = requestAnimationFrame(tick); }, [ getAdapter, setCurrentTime, setIsPlaying, applyPlaybackRate, applyPreviewAudioState, stopRAFLoop, stopReverseLoop, ], ); const pause = useCallback(() => { stopReverseLoop(); const adapter = getAdapter(); if (!adapter) return; adapter.pause(); setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time setIsPlaying(false); shuttleDirectionRef.current = null; shuttleSpeedIndexRef.current = 0; stopRAFLoop(); }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]); const seek = useCallback( (time: number, options?: { keepPlaying?: boolean }) => { // Reverse shuttle is always stopped: the RAF reverse tick can't survive // a seek anyway, so `keepPlaying` only preserves forward playback. const wasReverseShuttle = shuttleDirectionRef.current === "backward"; stopReverseLoop(); const adapter = getAdapter(); if (!adapter) { pendingSeekRef.current = Math.max(0, time); return false; } const adapterDur = adapter.getDuration(); const state = usePlayerStore.getState(); const maxEnd = state.elements.length > 0 ? Math.max(...state.elements.map((el) => el.start + el.duration)) : 0; const duration = Math.max(0, adapterDur, maxEnd); const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time); adapter.seek(nextTime, options); liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render setCurrentTime(nextTime); // sync store so Split/Delete have accurate time if (!options?.keepPlaying || wasReverseShuttle) { stopRAFLoop(); if (usePlayerStore.getState().isPlaying) setIsPlaying(false); shuttleDirectionRef.current = null; shuttleSpeedIndexRef.current = 0; } return true; }, [ getAdapter, pendingSeekRef, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop, shuttleDirectionRef, shuttleSpeedIndexRef, ], ); // Handle seek requests from outside the player loop (e.g. LayersPanel). useEffect(() => { return usePlayerStore.subscribe((state, prev) => { if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) { seek(state.requestedSeekTime); usePlayerStore.getState().clearSeekRequest(); } }); }, [seek]); const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } = usePlaybackKeyboard({ iframeRef, shuttleDirectionRef, shuttleSpeedIndexRef, iframeShortcutCleanupRef, getAdapter, play, playBackward, pause, seek, }); const { processTimelineMessageRef, enrichMissingCompositionsRef, onIframeLoad } = useTimelineSyncCallbacks({ iframeRef, probeIntervalRef, pendingSeekRef, isRefreshingRef, getAdapter, syncTimelineElements, setDuration, setCurrentTime, setTimelineReady, setIsPlaying, attachIframeShortcutListeners, applyPreviewAudioState, }); const saveSeekPosition = useCallback(() => { const adapter = getAdapter(); pendingSeekRef.current = adapter ? adapter.getTime() : (usePlayerStore.getState().currentTime ?? 0); isRefreshingRef.current = true; stopRAFLoop(); stopReverseLoop(); setIsPlaying(false); }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]); const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; saveSeekPosition(); const src = iframe.src; const url = new URL(src, window.location.origin); url.searchParams.set("_t", String(Date.now())); iframe.src = url.toString(); }, [saveSeekPosition]); const getAdapterRef = useRef(getAdapter); getAdapterRef.current = getAdapter; useMountEffect(() => { const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e); const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e); // Listen for timeline messages from the iframe runtime. // The runtime sends this AFTER all external compositions load, // so we get the complete clip list (not just the first few). const handleMessage = (e: MessageEvent) => { const data = e.data; // Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes const ourIframe = iframeRef.current; if (e.source && ourIframe && e.source !== ourIframe.contentWindow) { return; } if (data?.source === "hf-preview" && data?.type === "state") { try { if (usePlayerStore.getState().elements.length === 0) { const iframeWin = ourIframe?.contentWindow as IframeWindow | null; const manifest = iframeWin?.__clipManifest; if (manifest && manifest.clips.length > 0) { processTimelineMessageRef.current(manifest); } } // Enrich only when the timeline has settled — skip during the window // right after a "timeline" message to avoid the enrichment adding // elements that fight with the manifest's authoritative element list, // causing duration oscillation. const msSinceTimeline = Date.now() - lastTimelineMessageRef.current; if (msSinceTimeline > 500) { enrichMissingCompositionsRef.current(); } } catch (err) { console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err); } } if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) { lastTimelineMessageRef.current = Date.now(); processTimelineMessageRef.current(data); enrichMissingCompositionsRef.current(); if (usePlayerStore.getState().elements.length === 0) { try { const doc = ourIframe?.contentDocument; const adapter = getAdapter(); if (doc && adapter) { const els = parseTimelineFromDOM(doc, adapter.getDuration()); if (els.length > 0) { syncTimelineElements(els); } } } catch (err) { console.warn( "[useTimelinePlayer] Could not read timeline elements on navigate (cross-origin)", err, ); } } } }; // Pause video when tab loses focus const handleVisibilityChange = () => { if (document.hidden && usePlayerStore.getState().isPlaying) { const adapter = getAdapterRef.current?.(); if (adapter) { adapter.pause(); setIsPlaying(false); stopRAFLoop(); } } }; window.addEventListener("keydown", handleWindowKeyDown, true); window.addEventListener("keyup", handleWindowKeyUp, true); window.addEventListener("message", handleMessage); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { window.removeEventListener("keydown", handleWindowKeyDown, true); window.removeEventListener("keyup", handleWindowKeyUp, true); iframeShortcutCleanupRef.current?.(); iframeShortcutCleanupRef.current = null; window.removeEventListener("message", handleMessage); document.removeEventListener("visibilitychange", handleVisibilityChange); stopRAFLoop(); stopReverseLoop(); if (probeIntervalRef.current) clearInterval(probeIntervalRef.current); }; }); /** Reset the player store (elements, duration, etc.) — call when switching sessions. */ const resetPlayer = useCallback(() => { stopRAFLoop(); stopReverseLoop(); if (probeIntervalRef.current) clearInterval(probeIntervalRef.current); usePlayerStore.getState().reset(); }, [stopRAFLoop, stopReverseLoop]); useEffect(() => { return usePlayerStore.subscribe((state, prev) => { const playbackRateChanged = state.playbackRate !== prev.playbackRate; const audioMutedChanged = state.audioMuted !== prev.audioMuted; if (!playbackRateChanged && !audioMutedChanged) return; if (playbackRateChanged) { applyPlaybackRate(state.playbackRate); } applyPreviewAudioState(); }); }, [applyPlaybackRate, applyPreviewAudioState]); return { iframeRef, play, pause, togglePlay, seek, onIframeLoad, refreshPlayer, saveSeekPosition, resetPlayer, }; }