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 (
<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
key={playerKey}
ref={iframeRef}
@@ -4,11 +4,18 @@ import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
import { formatTime } from "../lib/time";
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
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 {
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 timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate);
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const [jumpFrame, setJumpFrame] = useState("");
const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
const timeDisplayModeRef = useRef(timeDisplayMode);
timeDisplayModeRef.current = timeDisplayMode;
const durationRef = useRef(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;
if (progressFillRef.current) progressFillRef.current.style.width = `${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)));
};
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(() => {
if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!timelineReady || duration <= 0) return;
const step = e.shiftKey ? 5 : 1;
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft") {
e.preventDefault();
onSeek(Math.max(0, currentTimeRef.current - step));
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
} else if (e.key === "ArrowRight") {
e.preventDefault();
onSeek(Math.min(duration, currentTimeRef.current + step));
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
}
},
[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 (
<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={{
borderTop: "1px solid rgba(255,255,255,0.04)",
// 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 */}
<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" }}
>
<span ref={timeDisplayRef}>{formatTime(0)}</span>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
{timeDisplayMode === "time" ? (
<>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
</>
) : null}
</span>
{/* Seek bar — teal progress fill */}
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
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
// pointer gesture on this element ourselves. Without it, iOS
// Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
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)" }}
>
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
)}
</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 */}
{onToggleTimeline !== undefined && (
<button
@@ -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,
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { formatTime } from "./time";
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
describe("formatTime", () => {
it("formats zero seconds", () => {
@@ -55,3 +55,21 @@ describe("formatTime", () => {
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 {
if (!Number.isFinite(time) || time < 0) return "0:00";
const mins = Math.floor(time / 60);
const secs = Math.floor(time % 60);
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.selectedElementId).toBeNull();
expect(state.playbackRate).toBe(1);
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit");
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", () => {
it("updates timelineReady", () => {
usePlayerStore.getState().setTimelineReady(true);
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
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();
store.setPlaybackRate(2);
store.setLoopEnabled(true);
store.setZoomMode("manual");
store.setManualZoomPercent(200);
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
const state = usePlayerStore.getState();
// reset() only resets the fields explicitly listed in the reset function
expect(state.playbackRate).toBe(2);
expect(state.loopEnabled).toBe(true);
expect(state.zoomMode).toBe("manual");
expect(state.manualZoomPercent).toBe(200);
});
@@ -34,6 +34,7 @@ interface PlayerState {
elements: TimelineElement[];
selectedElementId: string | null;
playbackRate: number;
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
@@ -43,6 +44,7 @@ interface PlayerState {
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setLoopEnabled: (enabled: boolean) => void;
setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null) => void;
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
elements: [],
selectedElementId: null,
playbackRate: 1,
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => set({ playbackRate: rate }),
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
setManualZoomPercent: (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.
// 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.
reset: () =>
set({