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
@@ -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);
}