feat: add Studio NLE playback controls

This commit is contained in:
Miguel Ángel
2026-04-28 17:51:38 -04:00
parent a18f66db18
commit a45f900af7
8 changed files with 435 additions and 33 deletions
@@ -3,8 +3,15 @@ import {
buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer";
function mockTargetMatching(selectorNeedle: string): EventTarget {
return {
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
} as unknown as EventTarget;
}
describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => {
expect(
@@ -94,3 +101,17 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
});
});
describe("shouldIgnorePlaybackShortcutTarget", () => {
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
});
it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
});
it("allows non-interactive preview targets to use playback shortcuts", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
});
});
@@ -1,6 +1,7 @@
import { useRef, useCallback } from "react";
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
interface PlaybackAdapter {
play: () => void;
@@ -105,6 +106,37 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
}
}
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
"input",
"textarea",
"select",
"button",
"a[href]",
"[contenteditable='true']",
"[role='button']",
"[role='checkbox']",
"[role='combobox']",
"[role='menuitem']",
"[role='radio']",
"[role='slider']",
"[role='spinbutton']",
"[role='switch']",
"[role='textbox']",
].join(",");
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
if (!target || typeof target !== "object") return false;
const candidate = target as { closest?: unknown };
if (typeof candidate.closest !== "function") return false;
return (
(candidate.closest as (selector: string) => Element | null).call(
target,
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
) !== null
);
}
/**
* Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -406,6 +438,13 @@ export function useTimelinePlayer() {
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const pendingSeekRef = useRef<number | null>(null);
const isRefreshingRef = useRef(false);
const reverseRafRef = useRef<number>(0);
const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
const shuttleSpeedIndexRef = useRef(0);
const pressedCodesRef = useRef(new Set<string>());
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
// ZERO store subscriptions — this hook never causes re-renders.
// All reads use getState() (point-in-time), all writes use the stable setters.
@@ -464,6 +503,10 @@ export function useTimelinePlayer() {
}
}, []);
const stopReverseLoop = useCallback(() => {
cancelAnimationFrame(reverseRafRef.current);
}, []);
const startRAFLoop = useCallback(() => {
const tick = () => {
const adapter = getAdapter();
@@ -472,6 +515,14 @@ export function useTimelinePlayer() {
const dur = adapter.getDuration();
liveTime.notify(time); // direct DOM updates, no React re-render
if (time >= dur && !adapter.isPlaying()) {
if (usePlayerStore.getState().loopEnabled && dur > 0) {
adapter.seek(0);
liveTime.notify(0);
adapter.play();
setIsPlaying(true);
rafRef.current = requestAnimationFrame(tick);
return;
}
setCurrentTime(time); // sync Zustand once at end
setIsPlaying(false);
cancelAnimationFrame(rafRef.current);
@@ -514,6 +565,8 @@ export function useTimelinePlayer() {
}, []);
const play = useCallback(() => {
stopRAFLoop();
stopReverseLoop();
const adapter = getAdapter();
if (!adapter) return;
if (adapter.getTime() >= adapter.getDuration()) {
@@ -522,18 +575,68 @@ export function useTimelinePlayer() {
unmutePreviewMedia(iframeRef.current);
applyPlaybackRate(usePlayerStore.getState().playbackRate);
adapter.play();
shuttleDirectionRef.current = "forward";
setIsPlaying(true);
startRAFLoop();
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, 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);
unmutePreviewMedia(iframeRef.current);
const speed = Math.max(0.1, Math.min(4, rate));
let startTime = initialTime;
let startedAt = performance.now();
const tick = (now: number) => {
const elapsed = ((now - startedAt) / 1000) * speed;
let nextTime = startTime - elapsed;
if (nextTime <= 0) {
if (usePlayerStore.getState().loopEnabled && duration > 0) {
startTime = duration;
startedAt = now;
nextTime = duration;
} else {
adapter.seek(0);
liveTime.notify(0);
setCurrentTime(0);
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, 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]);
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
const togglePlay = useCallback(() => {
if (usePlayerStore.getState().isPlaying) {
@@ -545,18 +648,128 @@ export function useTimelinePlayer() {
const seek = useCallback(
(time: number) => {
stopReverseLoop();
const adapter = getAdapter();
if (!adapter) return;
adapter.seek(time);
liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) — no re-render
setCurrentTime(time); // sync store so Split/Delete have accurate time
const duration = Math.max(0, adapter.getDuration());
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
adapter.seek(nextTime);
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
stopRAFLoop();
// Only update store if state actually changes (avoids unnecessary re-renders)
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
shuttleDirectionRef.current = null;
shuttleSpeedIndexRef.current = 0;
},
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
);
const stepFrames = useCallback(
(deltaFrames: number) => {
const adapter = getAdapter();
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
},
[getAdapter, seek],
);
const shuttle = useCallback(
(direction: "forward" | "backward") => {
if (shuttleDirectionRef.current === direction) {
shuttleSpeedIndexRef.current = Math.min(
shuttleSpeedIndexRef.current + 1,
SHUTTLE_SPEEDS.length - 1,
);
} else {
shuttleSpeedIndexRef.current = 0;
}
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
usePlayerStore.getState().setPlaybackRate(speed);
if (direction === "forward") {
play();
} else {
playBackward(speed);
}
},
[play, playBackward],
);
const handlePlaybackKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (shouldIgnorePlaybackShortcutTarget(e.target)) return;
pressedCodesRef.current.add(e.code);
if (e.code === "Space") {
e.preventDefault();
togglePlay();
return;
}
if (e.code === "ArrowLeft") {
e.preventDefault();
stepFrames(e.shiftKey ? -10 : -1);
return;
}
if (e.code === "ArrowRight") {
e.preventDefault();
stepFrames(e.shiftKey ? 10 : 1);
return;
}
if (e.repeat) return;
if (e.code === "KeyK") {
e.preventDefault();
pause();
return;
}
if (e.code === "KeyJ") {
e.preventDefault();
if (pressedCodesRef.current.has("KeyK")) {
stepFrames(-1);
return;
}
shuttle("backward");
return;
}
if (e.code === "KeyL") {
e.preventDefault();
if (pressedCodesRef.current.has("KeyK")) {
stepFrames(1);
return;
}
shuttle("forward");
}
},
[pause, shuttle, stepFrames, togglePlay],
);
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
pressedCodesRef.current.delete(e.code);
}, []);
playbackKeyDownRef.current = handlePlaybackKeyDown;
playbackKeyUpRef.current = handlePlaybackKeyUp;
const attachIframeShortcutListeners = useCallback(() => {
iframeShortcutCleanupRef.current?.();
iframeShortcutCleanupRef.current = null;
const iframeWin = iframeRef.current?.contentWindow;
const iframeDoc = iframeRef.current?.contentDocument;
if (!iframeWin && !iframeDoc) return;
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
iframeShortcutCleanupRef.current = () => {
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
};
}, []);
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback(
(data: {
@@ -865,6 +1078,7 @@ export function useTimelinePlayer() {
if (doc && iframeWin) {
normalizePreviewViewport(doc, iframeWin);
autoHealMissingCompositionIds(doc);
attachIframeShortcutListeners();
}
// Try reading __clipManifest if already available (fast path)
@@ -931,6 +1145,7 @@ export function useTimelinePlayer() {
processTimelineMessage,
enrichMissingCompositions,
syncTimelineElements,
attachIframeShortcutListeners,
]);
/** Save the current playback time so the next onIframeLoad restores it. */
@@ -941,8 +1156,9 @@ export function useTimelinePlayer() {
: (usePlayerStore.getState().currentTime ?? 0);
isRefreshingRef.current = true;
stopRAFLoop();
stopReverseLoop();
setIsPlaying(false);
}, [getAdapter, stopRAFLoop, setIsPlaying]);
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
const refreshPlayer = useCallback(() => {
const iframe = iframeRef.current;
@@ -956,8 +1172,6 @@ export function useTimelinePlayer() {
iframe.src = url.toString();
}, [saveSeekPosition]);
const togglePlayRef = useRef(togglePlay);
togglePlayRef.current = togglePlay;
const getAdapterRef = useRef(getAdapter);
getAdapterRef.current = getAdapter;
const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -966,12 +1180,8 @@ export function useTimelinePlayer() {
enrichMissingCompositionsRef.current = enrichMissingCompositions;
useMountEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && e.target === document.body) {
e.preventDefault();
togglePlayRef.current();
}
};
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,
@@ -1044,14 +1254,19 @@ export function useTimelinePlayer() {
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keydown", handleWindowKeyDown, true);
window.addEventListener("keyup", handleWindowKeyUp, true);
window.addEventListener("message", handleMessage);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("keydown", handleKeyDown);
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);
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
// to prevent blink. New data will replace old when the iframe reloads.
@@ -1061,9 +1276,10 @@ export function useTimelinePlayer() {
/** 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]);
}, [stopRAFLoop, stopReverseLoop]);
return {
iframeRef,