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:
Miguel Ángel
2026-04-22 01:51:41 +02:00
committed by GitHub
parent 0ba56f9187
commit 1aea1415c4
11 changed files with 302 additions and 36 deletions
@@ -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({