mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: add Studio NLE playback controls (#530)
## Problem HyperFrames Studio made frame-accurate playback review slower than expected for editor-style workflows. Issue #527 called out missing loop playback, frame display/jump controls, preview-focused Space handling, frame stepping, and NLE-style J/K/L shuttle controls. ## What this fixes - Adds a persistent Studio loop toggle and makes the playback loop restart when enabled. - Adds a time/frame display toggle plus a jump-to-frame input in the player controls. - Adds frame math helpers and frame-step behavior at the Studio preview frame rate. - Expands keyboard handling so preview-focused Space toggles playback, ArrowLeft/ArrowRight step frames, Shift+Arrow steps 10 frames, and J/K/L shuttle controls work from the preview/timeline surface while ignoring form/button/slider targets. - Adds J/K/L shuttle behavior: J plays backward, K pauses, L plays forward, repeated J/L ramps 1x -> 2x -> 4x, and K-held J/L frame-steps. - Makes the preview wrapper focusable so keyboard playback shortcuts work after focusing the preview area. ## Root cause The Studio playback layer only exposed mouse scrubbing, basic play/pause, a seconds-based readout, and slider-local arrow-key nudges. The global Space shortcut was also gated to `document.body`, so it stopped working once the actual preview/editor surface had focus. Studio needed a single playback-control layer above the runtime adapter that could translate editor keyboard intent into deterministic seek/play/pause operations. ## Verification ### Local checks - `bun install` - `bun run --filter @hyperframes/core build:hyperframes-runtime` - `bunx oxfmt --check packages/studio/src/player/lib/time.ts packages/studio/src/player/lib/time.test.ts packages/studio/src/player/store/playerStore.ts packages/studio/src/player/store/playerStore.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/components/PlayerControls.tsx packages/studio/src/player/components/PlayerControls.test.ts packages/studio/src/components/nle/NLEPreview.tsx` - `bunx oxlint packages/studio/src/player/lib/time.ts packages/studio/src/player/lib/time.test.ts packages/studio/src/player/store/playerStore.ts packages/studio/src/player/store/playerStore.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/components/PlayerControls.tsx packages/studio/src/player/components/PlayerControls.test.ts packages/studio/src/components/nle/NLEPreview.tsx` - `bun run --filter @hyperframes/studio test -- src/player/lib/time.test.ts src/player/store/playerStore.test.ts src/player/components/PlayerControls.test.ts src/player/hooks/useTimelinePlayer.test.ts` -> 4 files passed, 52 tests passed - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/studio build` - `git diff --check` - Lefthook during commit -> lint, format, typecheck, commitlint pass ### Browser verification - Created a temp project at `/tmp/hf-studio-nle-controls` with an animated 10s GSAP timeline. - Started local Studio preview via `bun run --filter @hyperframes/cli dev -- preview /tmp/hf-studio-nle-controls` at `http://localhost:5194`. - Used `agent-browser` to verify: - loop toggle changes to active state - frame display shows `current / total` frames - jump-to-frame input moves the seek position to frame 45 / frame 150 - focused preview accepts Space play/pause - ArrowRight advances one frame from preview focus - J plays backward from frame 150 to a lower frame, then K stops - agent-browser-driven recording of the tested flow completed ## Notes - Local proof artifacts are intentionally not committed: - `qa-artifacts/studio-nle-controls/frame-controls.png` - `qa-artifacts/studio-nle-controls/playback-controls.webm` - Closes #527.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { useCaptionStore } from "../store";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { shouldHandleCaptionNudgeKey } from "../keyboard";
|
||||
|
||||
interface CaptionOverlayProps {
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
@@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
|
||||
if (sel.size === 0 || !m) return;
|
||||
const arrow = e.key;
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
|
||||
if (!shouldHandleCaptionNudgeKey(e)) return;
|
||||
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleCaptionNudgeKey } from "./keyboard";
|
||||
|
||||
function mockKeyboardEvent(
|
||||
key: string,
|
||||
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
|
||||
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
|
||||
return {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
key,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("shouldHandleCaptionNudgeKey", () => {
|
||||
it("handles plain and Shift-modified arrow keys for caption nudging", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores browser and app shortcut chords", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-arrow keys", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
||||
|
||||
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
|
||||
|
||||
export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
return CAPTION_NUDGE_KEYS.has(event.key);
|
||||
}
|
||||
@@ -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,30 @@ import {
|
||||
buildStandaloneRootTimelineElement,
|
||||
mergeTimelineElementsPreservingDowngrades,
|
||||
resolveStandaloneRootCompositionSrc,
|
||||
shouldIgnorePlaybackShortcutEvent,
|
||||
shouldIgnorePlaybackShortcutTarget,
|
||||
} from "./useTimelinePlayer";
|
||||
|
||||
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
||||
return {
|
||||
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
||||
} as unknown as EventTarget;
|
||||
}
|
||||
|
||||
function mockKeyboardEvent(
|
||||
code: string,
|
||||
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
||||
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
||||
return {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
code,
|
||||
target: mockTargetMatching("[data-missing]"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildStandaloneRootTimelineElement", () => {
|
||||
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
||||
expect(
|
||||
@@ -94,3 +116,60 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIgnorePlaybackShortcutEvent", () => {
|
||||
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
|
||||
).toBe(true);
|
||||
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
|
||||
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
||||
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
|
||||
).toBe(true);
|
||||
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
||||
isCaptionEditMode: true,
|
||||
selectedCaptionSegmentCount: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
||||
isCaptionEditMode: false,
|
||||
selectedCaptionSegmentCount: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
import { useCaptionStore } from "../../captions/store";
|
||||
|
||||
interface PlaybackAdapter {
|
||||
play: () => void;
|
||||
@@ -105,6 +107,64 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
||||
}
|
||||
}
|
||||
|
||||
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
||||
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaybackShortcutCaptionState {
|
||||
isCaptionEditMode: boolean;
|
||||
selectedCaptionSegmentCount: number;
|
||||
}
|
||||
|
||||
type PlaybackShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
||||
>;
|
||||
|
||||
export function shouldIgnorePlaybackShortcutEvent(
|
||||
event: PlaybackShortcutEvent,
|
||||
captionState: PlaybackShortcutCaptionState = {
|
||||
isCaptionEditMode: false,
|
||||
selectedCaptionSegmentCount: 0,
|
||||
},
|
||||
): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
||||
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
||||
return (
|
||||
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
||||
captionState.isCaptionEditMode &&
|
||||
captionState.selectedCaptionSegmentCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse [data-start] elements from a Document into TimelineElement[].
|
||||
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
||||
@@ -406,6 +466,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 +531,10 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopReverseLoop = useCallback(() => {
|
||||
cancelAnimationFrame(reverseRafRef.current);
|
||||
}, []);
|
||||
|
||||
const startRAFLoop = useCallback(() => {
|
||||
const tick = () => {
|
||||
const adapter = getAdapter();
|
||||
@@ -472,6 +543,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 +593,8 @@ export function useTimelinePlayer() {
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
stopReverseLoop();
|
||||
const adapter = getAdapter();
|
||||
if (!adapter) return;
|
||||
if (adapter.getTime() >= adapter.getDuration()) {
|
||||
@@ -522,18 +603,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 +676,136 @@ 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;
|
||||
const captionState = useCaptionStore.getState();
|
||||
if (
|
||||
shouldIgnorePlaybackShortcutEvent(e, {
|
||||
isCaptionEditMode: captionState.isEditMode,
|
||||
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
||||
})
|
||||
) {
|
||||
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 +1114,7 @@ export function useTimelinePlayer() {
|
||||
if (doc && iframeWin) {
|
||||
normalizePreviewViewport(doc, iframeWin);
|
||||
autoHealMissingCompositionIds(doc);
|
||||
attachIframeShortcutListeners();
|
||||
}
|
||||
|
||||
// Try reading __clipManifest if already available (fast path)
|
||||
@@ -931,6 +1181,7 @@ export function useTimelinePlayer() {
|
||||
processTimelineMessage,
|
||||
enrichMissingCompositions,
|
||||
syncTimelineElements,
|
||||
attachIframeShortcutListeners,
|
||||
]);
|
||||
|
||||
/** Save the current playback time so the next onIframeLoad restores it. */
|
||||
@@ -941,8 +1192,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 +1208,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 +1216,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 +1290,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 +1312,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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user