From b32a62d4d1548edffd87023103fbcfd187ea29a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 18:15:33 -0400 Subject: [PATCH] fix(studio): seek slider respects full document timeline duration getAdapter() returned the runtime player or GSAP timeline adapter directly when its duration was > 0, even when the document's timeline (computed from sub-composition data-start + data-hf-authored-duration attributes) extended beyond that duration. This capped the seek slider, seek clamping, and sub-composition visibility at the adapter's shorter value. Now each adapter path checks whether the document duration exceeds the adapter's own duration. When it does, the adapter falls through to createStaticSeekPlaybackAdapter which wraps the runtime player with the correct effective duration, allowing seeking and preview across the full timeline range. --- packages/studio/src/App.tsx | 4 ++ .../src/player/components/PlayerControls.tsx | 6 +- .../src/player/hooks/useTimelinePlayer.ts | 72 ++++++------------- .../player/hooks/useTimelineSyncCallbacks.ts | 3 +- .../studio/src/player/lib/playbackTypes.ts | 1 - 5 files changed, 28 insertions(+), 58 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1c6fd8e79..1cf24b2c5 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -235,9 +235,11 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), }); + domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; + useCaptionDetection({ projectId, activeCompPath, @@ -269,8 +271,10 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); + const [globalDragOver, setGlobalDragOver] = useState(false); const dragCounterRef = useRef(0); + const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys; const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index ef57ca457..c528cffb8 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -55,11 +55,7 @@ export const PlayerControls = memo(function PlayerControls({ }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); - const duration = usePlayerStore((s) => { - if (s.elements.length === 0) return s.duration; - const maxEnd = Math.max(...s.elements.map((el) => el.start + el.duration)); - return Math.max(s.duration, maxEnd); - }); + const duration = usePlayerStore((s) => s.duration); const timelineReady = usePlayerStore((s) => s.timelineReady); const playbackRate = usePlayerStore((s) => s.playbackRate); const audioMuted = usePlayerStore((s) => s.audioMuted); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index ecd923389..5ab8b87e7 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -43,27 +43,6 @@ import { shouldMutePreviewAudio, } from "../lib/timelineIframeHelpers"; -function patchRuntimeClockDuration(win: IframeWindow, targetDuration: number): void { - try { - const rootEl = win.document?.querySelector("[data-composition-id]"); - const rootId = rootEl?.getAttribute("data-composition-id"); - const tl = rootId && win.__timelines?.[rootId]; - if (tl && typeof tl.duration === "function" && tl.duration() < targetDuration) { - const tlWithTo = tl as typeof tl & { - to?: (t: object, v: { duration: number }, p: number) => void; - }; - if (typeof tlWithTo.to === "function") { - tlWithTo.to({}, { duration: 0 }, targetDuration); - } - } - if (typeof win.__hfForceTimelineRebind === "function") { - win.__hfForceTimelineRebind(); - } - } catch { - // cross-origin or missing runtime — non-fatal - } -} - // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -120,7 +99,7 @@ export function useTimelinePlayer() { if ( Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0 && - (nextDuration ?? 0) > state.duration + nextDuration !== state.duration ) { setDuration(nextDuration ?? 0); } @@ -139,58 +118,57 @@ export function useTimelinePlayer() { const playerAdapter = win.__player && typeof win.__player.play === "function" ? win.__player : null; - if (getAdapterDuration(playerAdapter) > 0) { - const docDur = readTimelineDurationFromDocument(iframe?.contentDocument); - if (docDur > 0 && docDur > playerAdapter!.getDuration()) { - patchRuntimeClockDuration(win, docDur); - } + const docDuration = readTimelineDurationFromDocument(iframe.contentDocument); + const adapterDur = getAdapterDuration(playerAdapter); + + if (adapterDur > 0 && docDuration <= adapterDur) { return playerAdapter; } if (win.__timeline) { const adapter = wrapTimeline(win.__timeline); - if (getAdapterDuration(adapter) > 0) return adapter; + const dur = getAdapterDuration(adapter); + if (dur > 0 && docDuration <= dur) 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 dur = getAdapterDuration(adapter); + if (dur > 0 && docDuration <= dur) return adapter; } } - const fallbackDuration = Math.max( + const effectiveDuration = Math.max( usePlayerStore.getState().duration, - readTimelineDurationFromDocument(iframe.contentDocument), + docDuration, + adapterDur, ); + const baseAdapter = playerAdapter; if ( - playerAdapter && - fallbackDuration > 0 && - (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function") + baseAdapter && + effectiveDuration > 0 && + (typeof baseAdapter.renderSeek === "function" || typeof baseAdapter.seek === "function") ) { const cached = staticSeekAdapterRef.current; - if (cached?.player === playerAdapter && cached.duration === fallbackDuration) { + if (cached?.player === baseAdapter && cached.duration === effectiveDuration) { return cached.adapter; } cached?.adapter.pause(); const adapter = createStaticSeekPlaybackAdapter( - playerAdapter, - fallbackDuration, + baseAdapter, + effectiveDuration, getDefaultStaticSeekPlaybackClock(win), () => usePlayerStore.getState().playbackRate, ); staticSeekAdapterRef.current = { - player: playerAdapter, - duration: fallbackDuration, + player: baseAdapter, + duration: effectiveDuration, adapter, }; return adapter; @@ -382,13 +360,7 @@ export function useTimelinePlayer() { 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 duration = Math.max(0, adapter.getDuration()); 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 diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 7a55ec01d..54c28732f 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -163,12 +163,11 @@ export function useTimelineSyncCallbacks({ // with the initial adapter seek on iframe load. liveTime.notify(startTime); const adapterDur = adapter.getDuration(); - const storeDur = usePlayerStore.getState().duration; if ( Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200 && - adapterDur > storeDur + adapterDur !== usePlayerStore.getState().duration ) { setDuration(adapterDur); } diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index 540933465..e86b67667 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -57,5 +57,4 @@ export type IframeWindow = Window & { __timeline?: TimelineLike; __timelines?: Record; __clipManifest?: ClipManifest; - __hfForceTimelineRebind?: () => void; };