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
@@ -33,7 +33,11 @@ export const NLEPreview = memo(function NLEPreview({
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0"> <div
className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
tabIndex={0}
aria-label="Composition preview"
>
<Player <Player
key={playerKey} key={playerKey}
ref={iframeRef} ref={iframeRef}
@@ -4,11 +4,18 @@ import {
TIMELINE_TOGGLE_SHORTCUT_LABEL, TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle, getTimelineToggleTitle,
} from "../../utils/timelineDiscovery"; } from "../../utils/timelineDiscovery";
import { formatTime } from "../lib/time"; import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore"; import { usePlayerStore, liveTime } from "../store/playerStore";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8; const SEEK_EDGE_SNAP_PX = 8;
type TimeDisplayMode = "time" | "frame";
const SHORTCUT_HINTS = [
{ key: "J", label: "Play backward" },
{ key: "K", label: "Stop playback" },
{ key: "L", label: "Play forward" },
{ key: "←/→", label: "Step one frame backward or forward" },
] as const;
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
@@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
const duration = usePlayerStore((s) => s.duration); const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady); const timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate); const playbackRate = usePlayerStore((s) => s.playbackRate);
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate; const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const [showSpeedMenu, setShowSpeedMenu] = useState(false); const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const [jumpFrame, setJumpFrame] = useState("");
const progressFillRef = useRef<HTMLDivElement>(null); const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null); const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
const speedMenuContainerRef = useRef<HTMLDivElement>(null); const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0); const currentTimeRef = useRef(0);
const timeDisplayModeRef = useRef(timeDisplayMode);
timeDisplayModeRef.current = timeDisplayMode;
const durationRef = useRef(duration); const durationRef = useRef(duration);
durationRef.current = duration; durationRef.current = duration;
@@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0; const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t); if (timeDisplayRef.current) {
timeDisplayRef.current.textContent =
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
}
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t))); if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
}; };
const unsub = liveTime.subscribe(updateProgress); const unsub = liveTime.subscribe(updateProgress);
@@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
}; };
}); });
useEffect(() => {
if (!timeDisplayRef.current) return;
const t = currentTimeRef.current;
timeDisplayRef.current.textContent =
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
}, [duration, timeDisplayMode]);
useEffect(() => { useEffect(() => {
if (!showSpeedMenu) return; if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (!timelineReady || duration <= 0) return; if (!timelineReady || duration <= 0) return;
const step = e.shiftKey ? 5 : 1; const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft") { if (e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
onSeek(Math.max(0, currentTimeRef.current - step)); onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
} else if (e.key === "ArrowRight") { } else if (e.key === "ArrowRight") {
e.preventDefault(); e.preventDefault();
onSeek(Math.min(duration, currentTimeRef.current + step)); onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
} }
}, },
[timelineReady, duration, onSeek], [timelineReady, duration, onSeek],
); );
const commitJumpFrame = useCallback(() => {
const frame = Number.parseInt(jumpFrame, 10);
if (!Number.isFinite(frame) || duration <= 0) return;
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
}, [duration, jumpFrame, onSeek]);
const handleJumpSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
commitJumpFrame();
},
[commitJumpFrame],
);
const handleJumpKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
e.preventDefault();
commitJumpFrame();
},
[commitJumpFrame],
);
return ( return (
<div <div
className="px-4 py-2 flex items-center gap-3" className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
style={{ style={{
borderTop: "1px solid rgba(255,255,255,0.04)", borderTop: "1px solid rgba(255,255,255,0.04)",
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({
{/* Time display */} {/* Time display */}
<span <span
className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]" className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
style={{ color: "#A1A1AA" }} style={{ color: "#A1A1AA" }}
> >
<span ref={timeDisplayRef}>{formatTime(0)}</span> <span ref={timeDisplayRef}>{formatTime(0)}</span>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span> {timeDisplayMode === "time" ? (
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span> <>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
</>
) : null}
</span> </span>
{/* Seek bar — teal progress fill */} {/* Seek bar — teal progress fill */}
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={Math.round(duration)} aria-valuemax={Math.round(duration)}
aria-valuenow={0} aria-valuenow={0}
className="flex-1 h-6 flex items-center cursor-pointer group" className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
// `touch-action: none` tells the browser we're handling every // `touch-action: none` tells the browser we're handling every
// pointer gesture on this element ourselves. Without it, iOS // pointer gesture on this element ourselves. Without it, iOS
// Safari consumes horizontal swipes for its own swipe-back-to- // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
<button <button
type="button" type="button"
onClick={() => setShowSpeedMenu((v) => !v)} onClick={() => setShowSpeedMenu((v) => !v)}
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors" className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }} style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
> >
{playbackRate === 1 ? "1x" : `${playbackRate}x`} {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
)} )}
</div> </div>
<button
type="button"
onClick={() => setLoopEnabled(!loopEnabled)}
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
loopEnabled
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
}`}
title="Loop playback"
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
aria-pressed={loopEnabled}
>
Loop
</button>
<button
type="button"
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
title="Toggle time/frame display"
aria-label="Toggle time and frame display"
>
{timeDisplayMode === "time" ? "m:ss" : "frames"}
</button>
<form
onSubmit={handleJumpSubmit}
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
>
<input
value={jumpFrame}
onChange={(e) => setJumpFrame(e.target.value)}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Jump to frame"
placeholder="frame"
className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
onKeyDown={handleJumpKeyDown}
onBlur={commitJumpFrame}
/>
</form>
<div
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
>
{SHORTCUT_HINTS.map((shortcut) => (
<span
key={shortcut.key}
className="group relative rounded border border-neutral-800 px-1 py-0.5"
>
{shortcut.key}
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
{shortcut.label}
</span>
</span>
))}
</div>
{/* Timeline toggle */} {/* Timeline toggle */}
{onToggleTimeline !== undefined && ( {onToggleTimeline !== undefined && (
<button <button
@@ -3,8 +3,15 @@ import {
buildStandaloneRootTimelineElement, buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades, mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc, resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer"; } from "./useTimelinePlayer";
function mockTargetMatching(selectorNeedle: string): EventTarget {
return {
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
} as unknown as EventTarget;
}
describe("buildStandaloneRootTimelineElement", () => { describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => { it("includes selector and source metadata for standalone composition fallback clips", () => {
expect( expect(
@@ -94,3 +101,17 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]); ).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 { useRef, useCallback } from "react";
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect"; import { useMountEffect } from "../../hooks/useMountEffect";
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
interface PlaybackAdapter { interface PlaybackAdapter {
play: () => void; 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[]. * Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions. * 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 probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const pendingSeekRef = useRef<number | null>(null); const pendingSeekRef = useRef<number | null>(null);
const isRefreshingRef = useRef(false); 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. // ZERO store subscriptions — this hook never causes re-renders.
// All reads use getState() (point-in-time), all writes use the stable setters. // 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 startRAFLoop = useCallback(() => {
const tick = () => { const tick = () => {
const adapter = getAdapter(); const adapter = getAdapter();
@@ -472,6 +515,14 @@ export function useTimelinePlayer() {
const dur = adapter.getDuration(); const dur = adapter.getDuration();
liveTime.notify(time); // direct DOM updates, no React re-render liveTime.notify(time); // direct DOM updates, no React re-render
if (time >= dur && !adapter.isPlaying()) { 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 setCurrentTime(time); // sync Zustand once at end
setIsPlaying(false); setIsPlaying(false);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
@@ -514,6 +565,8 @@ export function useTimelinePlayer() {
}, []); }, []);
const play = useCallback(() => { const play = useCallback(() => {
stopRAFLoop();
stopReverseLoop();
const adapter = getAdapter(); const adapter = getAdapter();
if (!adapter) return; if (!adapter) return;
if (adapter.getTime() >= adapter.getDuration()) { if (adapter.getTime() >= adapter.getDuration()) {
@@ -522,18 +575,68 @@ export function useTimelinePlayer() {
unmutePreviewMedia(iframeRef.current); unmutePreviewMedia(iframeRef.current);
applyPlaybackRate(usePlayerStore.getState().playbackRate); applyPlaybackRate(usePlayerStore.getState().playbackRate);
adapter.play(); adapter.play();
shuttleDirectionRef.current = "forward";
setIsPlaying(true); setIsPlaying(true);
startRAFLoop(); 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(() => { const pause = useCallback(() => {
stopReverseLoop();
const adapter = getAdapter(); const adapter = getAdapter();
if (!adapter) return; if (!adapter) return;
adapter.pause(); adapter.pause();
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
setIsPlaying(false); setIsPlaying(false);
shuttleDirectionRef.current = null;
shuttleSpeedIndexRef.current = 0;
stopRAFLoop(); stopRAFLoop();
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]); }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
if (usePlayerStore.getState().isPlaying) { if (usePlayerStore.getState().isPlaying) {
@@ -545,18 +648,128 @@ export function useTimelinePlayer() {
const seek = useCallback( const seek = useCallback(
(time: number) => { (time: number) => {
stopReverseLoop();
const adapter = getAdapter(); const adapter = getAdapter();
if (!adapter) return; if (!adapter) return;
adapter.seek(time); const duration = Math.max(0, adapter.getDuration());
liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) — no re-render const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
setCurrentTime(time); // sync store so Split/Delete have accurate 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(); stopRAFLoop();
// Only update store if state actually changes (avoids unnecessary re-renders) // Only update store if state actually changes (avoids unnecessary re-renders)
if (usePlayerStore.getState().isPlaying) setIsPlaying(false); 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 // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback( const processTimelineMessage = useCallback(
(data: { (data: {
@@ -865,6 +1078,7 @@ export function useTimelinePlayer() {
if (doc && iframeWin) { if (doc && iframeWin) {
normalizePreviewViewport(doc, iframeWin); normalizePreviewViewport(doc, iframeWin);
autoHealMissingCompositionIds(doc); autoHealMissingCompositionIds(doc);
attachIframeShortcutListeners();
} }
// Try reading __clipManifest if already available (fast path) // Try reading __clipManifest if already available (fast path)
@@ -931,6 +1145,7 @@ export function useTimelinePlayer() {
processTimelineMessage, processTimelineMessage,
enrichMissingCompositions, enrichMissingCompositions,
syncTimelineElements, syncTimelineElements,
attachIframeShortcutListeners,
]); ]);
/** Save the current playback time so the next onIframeLoad restores it. */ /** Save the current playback time so the next onIframeLoad restores it. */
@@ -941,8 +1156,9 @@ export function useTimelinePlayer() {
: (usePlayerStore.getState().currentTime ?? 0); : (usePlayerStore.getState().currentTime ?? 0);
isRefreshingRef.current = true; isRefreshingRef.current = true;
stopRAFLoop(); stopRAFLoop();
stopReverseLoop();
setIsPlaying(false); setIsPlaying(false);
}, [getAdapter, stopRAFLoop, setIsPlaying]); }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
const refreshPlayer = useCallback(() => { const refreshPlayer = useCallback(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
@@ -956,8 +1172,6 @@ export function useTimelinePlayer() {
iframe.src = url.toString(); iframe.src = url.toString();
}, [saveSeekPosition]); }, [saveSeekPosition]);
const togglePlayRef = useRef(togglePlay);
togglePlayRef.current = togglePlay;
const getAdapterRef = useRef(getAdapter); const getAdapterRef = useRef(getAdapter);
getAdapterRef.current = getAdapter; getAdapterRef.current = getAdapter;
const processTimelineMessageRef = useRef(processTimelineMessage); const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -966,12 +1180,8 @@ export function useTimelinePlayer() {
enrichMissingCompositionsRef.current = enrichMissingCompositions; enrichMissingCompositionsRef.current = enrichMissingCompositions;
useMountEffect(() => { useMountEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
if (e.code === "Space" && e.target === document.body) { const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
e.preventDefault();
togglePlayRef.current();
}
};
// Listen for timeline messages from the iframe runtime. // Listen for timeline messages from the iframe runtime.
// The runtime sends this AFTER all external compositions load, // 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); window.addEventListener("message", handleMessage);
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleWindowKeyDown, true);
window.removeEventListener("keyup", handleWindowKeyUp, true);
iframeShortcutCleanupRef.current?.();
iframeShortcutCleanupRef.current = null;
window.removeEventListener("message", handleMessage); window.removeEventListener("message", handleMessage);
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
stopRAFLoop(); stopRAFLoop();
stopReverseLoop();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current); if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
// to prevent blink. New data will replace old when the iframe reloads. // 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. */ /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
const resetPlayer = useCallback(() => { const resetPlayer = useCallback(() => {
stopRAFLoop(); stopRAFLoop();
stopReverseLoop();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current); if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
usePlayerStore.getState().reset(); usePlayerStore.getState().reset();
}, [stopRAFLoop]); }, [stopRAFLoop, stopReverseLoop]);
return { return {
iframeRef, iframeRef,
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { formatTime } from "./time"; import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
describe("formatTime", () => { describe("formatTime", () => {
it("formats zero seconds", () => { it("formats zero seconds", () => {
@@ -55,3 +55,21 @@ describe("formatTime", () => {
expect(formatTime(Infinity)).toBe("0:00"); expect(formatTime(Infinity)).toBe("0:00");
}); });
}); });
describe("frame helpers", () => {
it("converts seconds to frames at the Studio preview rate", () => {
expect(secondsToFrame(0)).toBe(0);
expect(secondsToFrame(1)).toBe(30);
expect(secondsToFrame(1.5)).toBe(45);
});
it("converts frames to seconds at the Studio preview rate", () => {
expect(frameToSeconds(0)).toBe(0);
expect(frameToSeconds(30)).toBe(1);
expect(frameToSeconds(45)).toBe(1.5);
});
it("formats current and total frame display", () => {
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
});
});
+20
View File
@@ -1,6 +1,26 @@
export const STUDIO_PREVIEW_FPS = 30;
export function formatTime(time: number): string { export function formatTime(time: number): string {
if (!Number.isFinite(time) || time < 0) return "0:00"; if (!Number.isFinite(time) || time < 0) return "0:00";
const mins = Math.floor(time / 60); const mins = Math.floor(time / 60);
const secs = Math.floor(time % 60); const secs = Math.floor(time % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
} }
export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
if (!Number.isFinite(time) || time <= 0) return 0;
if (!Number.isFinite(fps) || fps <= 0) return 0;
return Math.round(time * fps);
}
export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
if (!Number.isFinite(frame) || frame <= 0) return 0;
if (!Number.isFinite(fps) || fps <= 0) return 0;
return frame / fps;
}
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
const currentFrame = secondsToFrame(time, fps);
const totalFrames = secondsToFrame(duration, fps);
return `${currentFrame}f / ${totalFrames}f`;
}
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
expect(state.elements).toEqual([]); expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull(); expect(state.selectedElementId).toBeNull();
expect(state.playbackRate).toBe(1); expect(state.playbackRate).toBe(1);
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit"); expect(state.zoomMode).toBe("fit");
expect(state.manualZoomPercent).toBe(100); expect(state.manualZoomPercent).toBe(100);
}); });
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
}); });
}); });
describe("setLoopEnabled", () => {
it("updates loopEnabled", () => {
usePlayerStore.getState().setLoopEnabled(true);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
});
});
describe("setTimelineReady", () => { describe("setTimelineReady", () => {
it("updates timelineReady", () => { it("updates timelineReady", () => {
usePlayerStore.getState().setTimelineReady(true); usePlayerStore.getState().setTimelineReady(true);
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
expect(state.selectedElementId).toBeNull(); expect(state.selectedElementId).toBeNull();
}); });
it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => { it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
store.setPlaybackRate(2); store.setPlaybackRate(2);
store.setLoopEnabled(true);
store.setZoomMode("manual"); store.setZoomMode("manual");
store.setManualZoomPercent(200); store.setManualZoomPercent(200);
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
const state = usePlayerStore.getState(); const state = usePlayerStore.getState();
// reset() only resets the fields explicitly listed in the reset function // reset() only resets the fields explicitly listed in the reset function
expect(state.playbackRate).toBe(2); expect(state.playbackRate).toBe(2);
expect(state.loopEnabled).toBe(true);
expect(state.zoomMode).toBe("manual"); expect(state.zoomMode).toBe("manual");
expect(state.manualZoomPercent).toBe(200); expect(state.manualZoomPercent).toBe(200);
}); });
@@ -34,6 +34,7 @@ interface PlayerState {
elements: TimelineElement[]; elements: TimelineElement[];
selectedElementId: string | null; selectedElementId: string | null;
playbackRate: number; playbackRate: number;
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */ /** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode; zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */ /** Timeline zoom percent relative to the fit width when in manual mode */
@@ -43,6 +44,7 @@ interface PlayerState {
setCurrentTime: (time: number) => void; setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void; setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void; setPlaybackRate: (rate: number) => void;
setLoopEnabled: (enabled: boolean) => void;
setTimelineReady: (ready: boolean) => void; setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void; setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null) => void; setSelectedElementId: (id: string | null) => void;
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
elements: [], elements: [],
selectedElementId: null, selectedElementId: null,
playbackRate: 1, playbackRate: 1,
loopEnabled: false,
zoomMode: "fit", zoomMode: "fit",
manualZoomPercent: 100, manualZoomPercent: 100,
setIsPlaying: (playing) => set({ isPlaying: playing }), setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => set({ playbackRate: rate }), setPlaybackRate: (rate) => set({ playbackRate: rate }),
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }), setZoomMode: (mode) => set({ zoomMode: mode }),
setManualZoomPercent: (percent) => setManualZoomPercent: (percent) =>
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }), set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
@@ -96,7 +100,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
), ),
})), })),
// Resets project-specific state when switching compositions. // Resets project-specific state when switching compositions.
// playbackRate, zoomMode, and manualZoomPercent are intentionally preserved // playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
// because they are user preferences that should survive project switches. // because they are user preferences that should survive project switches.
reset: () => reset: () =>
set({ set({