mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: smooth scrubber end seeking (#386)
* fix: smooth scrubber end seeking * fix: stop timeline auto-scroll in fit mode * feat: use percentage-based timeline zoom * fix: sync timeline playhead on zoom changes * fix: reset timeline scroll when returning to fit * fix: keep timeline controls pinned
This commit is contained in:
@@ -262,6 +262,18 @@ describe("createRuntimePlayer", () => {
|
||||
player.seek(NaN);
|
||||
expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("seeks to the exact safe duration without snapping back a frame", () => {
|
||||
const timeline = createMockTimeline({ duration: 8 });
|
||||
const deps = createMockDeps(timeline);
|
||||
deps.getSafeDuration.mockReturnValue(8);
|
||||
const player = createRuntimePlayer(deps);
|
||||
player.seek(8);
|
||||
expect(timeline.pause).toHaveBeenCalled();
|
||||
expect(timeline.totalTime).toHaveBeenCalledWith(8, false);
|
||||
expect(deps.onDeterministicSeek).toHaveBeenCalledWith(8);
|
||||
expect(deps.onSyncMedia).toHaveBeenCalledWith(8, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSeek", () => {
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
buildTrackZIndexMap,
|
||||
formatTimelineAttributeNumber,
|
||||
} from "./player/components/timelineEditing";
|
||||
import {
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelineZoomPercent,
|
||||
} from "./player/components/timelineZoom";
|
||||
|
||||
interface EditingFile {
|
||||
path: string;
|
||||
@@ -204,9 +208,9 @@ export function StudioApp() {
|
||||
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
||||
: null;
|
||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||
const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond);
|
||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||
const setPixelsPerSecond = usePlayerStore((s) => s.setPixelsPerSecond);
|
||||
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
||||
const timelineElements = usePlayerStore((s) => s.elements);
|
||||
const timelineDuration = usePlayerStore((s) => s.duration);
|
||||
const effectiveTimelineDuration = useMemo(() => {
|
||||
@@ -216,6 +220,10 @@ export function StudioApp() {
|
||||
: 0;
|
||||
return Math.max(timelineDuration, maxEnd);
|
||||
}, [timelineDuration, timelineElements]);
|
||||
const displayedTimelineZoomPercent = useMemo(
|
||||
() => getTimelineZoomPercent(zoomMode, manualZoomPercent),
|
||||
[zoomMode, manualZoomPercent],
|
||||
);
|
||||
|
||||
const renderClipContent = useCallback(
|
||||
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
||||
@@ -336,7 +344,7 @@ export function StudioApp() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setPixelsPerSecond(Math.max(20, Math.round(pixelsPerSecond * 0.8)));
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom out"
|
||||
@@ -344,13 +352,13 @@ export function StudioApp() {
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
||||
{zoomMode === "fit" ? "Auto" : `${Math.round(pixelsPerSecond)} px/s`}
|
||||
{`${displayedTimelineZoomPercent}%`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setPixelsPerSecond(Math.min(2000, Math.round(pixelsPerSecond * 1.25)));
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom in"
|
||||
|
||||
@@ -377,7 +377,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
||||
{/* Timeline tracks */}
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
|
||||
className="flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
||||
onDoubleClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (compositionStack.length > 1) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSeekPercent } from "./PlayerControls";
|
||||
|
||||
describe("resolveSeekPercent", () => {
|
||||
it("returns 0 when the track width is invalid", () => {
|
||||
expect(resolveSeekPercent(100, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("snaps to the start within the edge threshold", () => {
|
||||
expect(resolveSeekPercent(105, 100, 200)).toBe(0);
|
||||
});
|
||||
|
||||
it("snaps to the end within the edge threshold", () => {
|
||||
expect(resolveSeekPercent(298, 100, 200)).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the true percent away from the edges", () => {
|
||||
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,17 @@ import { 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;
|
||||
|
||||
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
||||
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
||||
const rawPercent = (clientX - rectLeft) / rectWidth;
|
||||
const clamped = Math.max(0, Math.min(1, rawPercent));
|
||||
const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
|
||||
if (clamped <= snapThreshold) return 0;
|
||||
if (clamped >= 1 - snapThreshold) return 1;
|
||||
return clamped;
|
||||
}
|
||||
|
||||
interface PlayerControlsProps {
|
||||
onTogglePlay: () => void;
|
||||
@@ -88,7 +99,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
const bar = seekBarRef.current;
|
||||
if (!bar || duration <= 0) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const percent = resolveSeekPercent(clientX, rect.left, rect.width);
|
||||
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
|
||||
const pct = percent * 100;
|
||||
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateTicks } from "./Timeline";
|
||||
import {
|
||||
generateTicks,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./Timeline";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
describe("generateTicks", () => {
|
||||
@@ -108,3 +113,41 @@ describe("formatTime", () => {
|
||||
expect(formatTime(61)).toBe("1:01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoScrollTimeline", () => {
|
||||
it("never auto-scrolls in fit mode", () => {
|
||||
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not auto-scroll when there is no horizontal overflow", () => {
|
||||
expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false);
|
||||
expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-scrolls in manual mode when horizontal overflow exists", () => {
|
||||
expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineScrollLeftForZoomTransition", () => {
|
||||
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves the current scroll offset for other zoom transitions", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
|
||||
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
|
||||
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelinePlayheadLeft", () => {
|
||||
it("converts time to a pixel offset from the gutter", () => {
|
||||
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
||||
});
|
||||
|
||||
it("guards invalid input", () => {
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
||||
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
|
||||
import {
|
||||
usePlayerStore,
|
||||
liveTime,
|
||||
type TimelineElement,
|
||||
type ZoomMode,
|
||||
} from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
@@ -16,6 +21,7 @@ import {
|
||||
type TimelineTrackStyle,
|
||||
type TimelineTheme,
|
||||
} from "./timelineTheme";
|
||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
const GUTTER = 32;
|
||||
@@ -99,6 +105,30 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
export function shouldAutoScrollTimeline(
|
||||
zoomMode: ZoomMode,
|
||||
scrollWidth: number,
|
||||
clientWidth: number,
|
||||
): boolean {
|
||||
if (zoomMode === "fit") return false;
|
||||
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
|
||||
return scrollWidth - clientWidth > 1;
|
||||
}
|
||||
|
||||
export function getTimelineScrollLeftForZoomTransition(
|
||||
previousZoomMode: ZoomMode | null,
|
||||
nextZoomMode: ZoomMode,
|
||||
currentScrollLeft: number,
|
||||
): number {
|
||||
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
||||
return currentScrollLeft;
|
||||
}
|
||||
|
||||
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
||||
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
@@ -171,8 +201,9 @@ export const Timeline = memo(function Timeline({
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const updateElement = usePlayerStore((s) => s.updateElement);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
|
||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -288,24 +319,53 @@ export const Timeline = memo(function Timeline({
|
||||
viewportWidth > GUTTER && effectiveDuration > 0
|
||||
? (viewportWidth - GUTTER - 2) / effectiveDuration
|
||||
: 100;
|
||||
const pps = zoomMode === "fit" ? fitPps : manualPps;
|
||||
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
||||
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
||||
const zoomModeRef = useRef(zoomMode);
|
||||
zoomModeRef.current = zoomMode;
|
||||
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
||||
|
||||
const durationRef = useRef(effectiveDuration);
|
||||
durationRef.current = effectiveDuration;
|
||||
const ppsRef = useRef(pps);
|
||||
ppsRef.current = pps;
|
||||
const syncPlayheadPosition = useCallback((time: number) => {
|
||||
if (!playheadRef.current || durationRef.current <= 0) return;
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncPlayheadPosition(currentTime);
|
||||
}, [currentTime, pps, syncPlayheadPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) {
|
||||
previousZoomModeRef.current = zoomMode;
|
||||
return;
|
||||
}
|
||||
scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
|
||||
previousZoomModeRef.current,
|
||||
zoomMode,
|
||||
scroll.scrollLeft,
|
||||
);
|
||||
previousZoomModeRef.current = zoomMode;
|
||||
}, [zoomMode]);
|
||||
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((t) => {
|
||||
const dur = durationRef.current;
|
||||
if (!playheadRef.current || dur <= 0) return;
|
||||
const px = t * ppsRef.current;
|
||||
playheadRef.current.style.left = `${GUTTER + px}px`;
|
||||
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
|
||||
playheadRef.current.style.left = `${playheadX}px`;
|
||||
|
||||
// Auto-scroll to follow playhead during playback or seeking
|
||||
const scroll = scrollRef.current;
|
||||
if (scroll && !isDragging.current) {
|
||||
const playheadX = GUTTER + px;
|
||||
if (
|
||||
scroll &&
|
||||
!isDragging.current &&
|
||||
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
|
||||
) {
|
||||
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
|
||||
const visibleLeft = scroll.scrollLeft;
|
||||
const edgeMargin = scroll.clientWidth * 0.12;
|
||||
@@ -444,7 +504,13 @@ export const Timeline = memo(function Timeline({
|
||||
(clientX: number) => {
|
||||
cancelAnimationFrame(dragScrollRaf.current);
|
||||
const el = scrollRef.current;
|
||||
if (!el || !isDragging.current) return;
|
||||
if (
|
||||
!el ||
|
||||
!isDragging.current ||
|
||||
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const edgeZone = 40;
|
||||
const maxSpeed = 12;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampTimelineZoomPercent,
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelinePixelsPerSecond,
|
||||
getTimelineZoomPercent,
|
||||
MAX_TIMELINE_ZOOM_PERCENT,
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
} from "./timelineZoom";
|
||||
|
||||
describe("clampTimelineZoomPercent", () => {
|
||||
it("defaults invalid values to 100", () => {
|
||||
expect(clampTimelineZoomPercent(Number.NaN)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps to the supported percent bounds", () => {
|
||||
expect(clampTimelineZoomPercent(1)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
expect(clampTimelineZoomPercent(5000)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineZoomPercent", () => {
|
||||
it("treats fit mode as 100 percent", () => {
|
||||
expect(getTimelineZoomPercent("fit", 375)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns the clamped manual zoom percent", () => {
|
||||
expect(getTimelineZoomPercent("manual", 125.2)).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelinePixelsPerSecond", () => {
|
||||
it("uses fit pixels per second in fit mode", () => {
|
||||
expect(getTimelinePixelsPerSecond(144, "fit", 250)).toBe(144);
|
||||
});
|
||||
|
||||
it("scales from fit pixels per second in manual mode", () => {
|
||||
expect(getTimelinePixelsPerSecond(144, "manual", 125)).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextTimelineZoomPercent", () => {
|
||||
it("zooms out from fit relative to 100 percent", () => {
|
||||
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(80);
|
||||
});
|
||||
|
||||
it("zooms in from fit relative to 100 percent", () => {
|
||||
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(125);
|
||||
});
|
||||
|
||||
it("clamps the lower bound", () => {
|
||||
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT)).toBe(
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps the upper bound", () => {
|
||||
expect(getNextTimelineZoomPercent("in", "manual", MAX_TIMELINE_ZOOM_PERCENT)).toBe(
|
||||
MAX_TIMELINE_ZOOM_PERCENT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ZoomMode } from "../store/playerStore";
|
||||
|
||||
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
||||
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
||||
const ZOOM_OUT_FACTOR = 0.8;
|
||||
const ZOOM_IN_FACTOR = 1.25;
|
||||
|
||||
export function clampTimelineZoomPercent(percent: number): number {
|
||||
if (!Number.isFinite(percent)) return 100;
|
||||
return Math.max(
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
Math.min(MAX_TIMELINE_ZOOM_PERCENT, Math.round(percent)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: number): number {
|
||||
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
|
||||
}
|
||||
|
||||
export function getTimelinePixelsPerSecond(
|
||||
fitPixelsPerSecond: number,
|
||||
zoomMode: ZoomMode,
|
||||
manualZoomPercent: number,
|
||||
): number {
|
||||
if (!Number.isFinite(fitPixelsPerSecond) || fitPixelsPerSecond <= 0) return 100;
|
||||
const zoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
return zoomMode === "fit" ? fitPixelsPerSecond : fitPixelsPerSecond * (zoomPercent / 100);
|
||||
}
|
||||
|
||||
export function getNextTimelineZoomPercent(
|
||||
direction: "in" | "out",
|
||||
zoomMode: ZoomMode,
|
||||
manualZoomPercent: number,
|
||||
): number {
|
||||
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
||||
return clampTimelineZoomPercent(next);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ describe("usePlayerStore", () => {
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
expect(state.playbackRate).toBe(1);
|
||||
expect(state.zoomMode).toBe("fit");
|
||||
expect(state.pixelsPerSecond).toBe(100);
|
||||
expect(state.manualZoomPercent).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,20 +160,25 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPixelsPerSecond", () => {
|
||||
it("updates pixelsPerSecond", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(200);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(200);
|
||||
describe("setManualZoomPercent", () => {
|
||||
it("updates the manual zoom percent", () => {
|
||||
usePlayerStore.getState().setManualZoomPercent(200);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(200);
|
||||
});
|
||||
|
||||
it("clamps to minimum of 10", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(5);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
|
||||
usePlayerStore.getState().setManualZoomPercent(5);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
||||
});
|
||||
|
||||
it("clamps negative values to 10", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(-50);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
|
||||
usePlayerStore.getState().setManualZoomPercent(-50);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
||||
});
|
||||
|
||||
it("clamps to the maximum supported zoom percent", () => {
|
||||
usePlayerStore.getState().setManualZoomPercent(5000);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,11 +205,11 @@ describe("usePlayerStore", () => {
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reset playbackRate, zoomMode, or pixelsPerSecond", () => {
|
||||
it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setPlaybackRate(2);
|
||||
store.setZoomMode("manual");
|
||||
store.setPixelsPerSecond(200);
|
||||
store.setManualZoomPercent(200);
|
||||
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
@@ -212,7 +217,7 @@ describe("usePlayerStore", () => {
|
||||
// reset() only resets the fields explicitly listed in the reset function
|
||||
expect(state.playbackRate).toBe(2);
|
||||
expect(state.zoomMode).toBe("manual");
|
||||
expect(state.pixelsPerSecond).toBe(200);
|
||||
expect(state.manualZoomPercent).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,10 @@ interface PlayerState {
|
||||
elements: TimelineElement[];
|
||||
selectedElementId: string | null;
|
||||
playbackRate: number;
|
||||
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses pixelsPerSecond */
|
||||
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
||||
zoomMode: ZoomMode;
|
||||
/** Pixels per second when in manual zoom mode */
|
||||
pixelsPerSecond: number;
|
||||
/** Timeline zoom percent relative to the fit width when in manual mode */
|
||||
manualZoomPercent: number;
|
||||
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
@@ -51,7 +51,7 @@ interface PlayerState {
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart">>,
|
||||
) => void;
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setPixelsPerSecond: (pps: number) => void;
|
||||
setManualZoomPercent: (percent: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -77,12 +77,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
selectedElementId: null,
|
||||
playbackRate: 1,
|
||||
zoomMode: "fit",
|
||||
pixelsPerSecond: 100,
|
||||
manualZoomPercent: 100,
|
||||
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
||||
setZoomMode: (mode) => set({ zoomMode: mode }),
|
||||
setPixelsPerSecond: (pps) => set({ pixelsPerSecond: Math.max(10, pps) }),
|
||||
setManualZoomPercent: (percent) =>
|
||||
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
||||
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
||||
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
||||
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
||||
@@ -95,7 +96,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
),
|
||||
})),
|
||||
// Resets project-specific state when switching compositions.
|
||||
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
|
||||
// playbackRate, 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