Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ describe("TimelineToolbar Fit", () => {
expect(fit()?.getAttribute("aria-pressed")).toBe("false");
act(() => root.unmount());
});

it("counts a zoom-in click as a person's zoom, so the timeline anchors it on the playhead", () => {
const { host, root } = renderToolbar();
const before = usePlayerStore.getState().userZoomCount;
act(() =>
host
.querySelector('button[aria-label="Zoom in"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
);
expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
act(() => root.unmount());
});
});

describe("TimelineToolbar audio meters", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/nle/NLEContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface NLEContextValue {
play: () => void;
pause: () => void;
togglePlay: () => void;
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
seek: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => boolean;
refreshPlayer: () => void;
onIframeLoad: () => void;
// The hidden reload iframe NLEPreview renders next to the live one during a full reload.
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/player/components/TimelineTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface TimelineProps
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
sessionEpoch?: number;
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
onDrillDown?: (element: TimelineElement) => void;
/** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
renderClipContent?: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,9 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
const h = renderResizeHarness([a], [], { onSeek });
h.startResize(a, "end");
h.movePointer(50);
expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true });
expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true, follow: false });
await h.dropPointer();
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
h.unmount();
});

Expand All @@ -525,7 +525,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
const h = renderResizeHarness([a], [], { onSeek });
h.startResize(a, "start");
h.movePointer(50);
expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true });
expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true, follow: false });
h.unmount();
});

Expand All @@ -545,7 +545,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
h.movePointer(80);
h.movePointer(120);
await h.dropPointer();
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
h.unmount();
});

Expand All @@ -571,7 +571,10 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
const clip = h.getResizingClip()!;
expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
expect(clip).toMatchObject({ snapTime: null, snapType: null });
expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { keepPlaying: true });
expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), {
keepPlaying: true,
follow: false,
});
h.unmount();
});
});
5 changes: 3 additions & 2 deletions packages/studio/src/player/components/useTimelineClipDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ interface UseTimelineClipDragInput {
onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
/** Seeks the preview; a trim shows the frame at its dragged edge. */
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
setShowPopover: (show: boolean) => void;
/** Stable ref to the range selection setter — wired after mount to break circular dependency. */
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
Expand Down Expand Up @@ -311,6 +311,7 @@ export function useTimelineClipDrag({
// and it only resumes playback if it was already playing.
onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
keepPlaying: true,
follow: false,
});
publishResizingClip(
resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
Expand Down Expand Up @@ -361,7 +362,7 @@ export function useTimelineClipDrag({
if (trimSeekOriginRef.current != null) {
// Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
if (!usePlayerStore.getState().isPlaying) {
onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true });
onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false });
}
trimSeekOriginRef.current = null;
}
Expand Down
241 changes: 215 additions & 26 deletions packages/studio/src/player/components/useTimelinePlayhead.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// @vitest-environment happy-dom

import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { act, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
import { useTimelinePlayhead } from "./useTimelinePlayhead";

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

const ORIGIN = 32;

function scrollBox(scrollLeft: number) {
const el = document.createElement("div");
let left = scrollLeft;
Expand All @@ -18,18 +21,34 @@ function scrollBox(scrollLeft: number) {
return el;
}

function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
interface HarnessProps {
pps: number;
scroll: HTMLDivElement;
percent?: number;
dragging?: boolean;
zoomMode?: ZoomMode;
}

function Harness({
pps,
scroll,
percent = 100,
dragging = false,
zoomMode = "manual",
}: HarnessProps) {
const scrollRef = useRef(scroll);
const durationRef = useRef(60);
useTimelinePlayhead({
playheadRef: createRef(),
scrollRef: { current: scroll },
playheadRef: { current: document.createElement("div") },
scrollRef,
ppsRef: { current: pps },
durationRef: { current: 60 },
isDragging: { current: false },
durationRef,
isDragging: { current: dragging },
currentTime: 0,
zoomMode: "manual",
manualZoomPercent: 100,
zoomModeRef: { current: "manual" },
manualZoomPercentRef: { current: 100 },
zoomMode,
manualZoomPercent: percent,
zoomModeRef: { current: zoomMode },
manualZoomPercentRef: { current: percent },
fitPps: pps,
fitPpsRef: { current: pps },
effectiveDuration: 60,
Expand All @@ -38,28 +57,198 @@ function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
elementsLength: 1,
setZoomMode: () => {},
setManualZoomPercent: () => {},
contentOrigin: 32,
contentOrigin: ORIGIN,
});
return null;
}

function zoom(scrollLeft: number, fromPps: number, toPps: number) {
const scroll = scrollBox(scrollLeft);
const host = document.createElement("div");
const root = createRoot(host);
act(() => root.render(<Harness pps={fromPps} scroll={scroll} />));
act(() => root.render(<Harness pps={toPps} scroll={scroll} />));
act(() => root.unmount());
return scroll.scrollLeft;
const roots: Root[] = [];
function mount(props: HarnessProps) {
const root = createRoot(document.createElement("div"));
roots.push(root);
act(() => root.render(<Harness {...props} />));
return (next: Partial<HarnessProps>, byPerson = false) =>
act(() => {
if (byPerson) usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 }));
root.render(<Harness {...props} {...next} />);
});
}

describe("useTimelinePlayhead centre anchor", () => {
it("keeps a view at the start at the start when the scale changes", () => {
expect(zoom(0, 100, 114)).toBe(0);
beforeEach(() => {
usePlayerStore.setState({ currentTime: 0, isPlaying: false, beatDragging: false });
});
afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
});

/** Where the playhead at `time` sits inside the 800px viewport. */
const onScreenX = (scroll: HTMLDivElement, time: number, pps: number) =>
ORIGIN + time * pps - scroll.scrollLeft;
function expectVisible(scroll: HTMLDivElement, time: number, pps: number) {
const x = onScreenX(scroll, time, pps);
expect(x).toBeGreaterThanOrEqual(ORIGIN);
expect(x).toBeLessThanOrEqual(800);
}

describe("useTimelinePlayhead zoom anchor", () => {
it("keeps a view at the start at the start when the window resizes", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll })({ pps: 114 });
expect(scroll.scrollLeft).toBe(0);
});

it("keeps the time at the viewport centre when the view is scrolled", () => {
it("keeps the time at the viewport centre when a scrolled window resizes", () => {
const scroll = scrollBox(400);
// Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400.
expect(zoom(400, 100, 200)).toBe(1168);
mount({ pps: 100, scroll })({ pps: 200 });
expect(scroll.scrollLeft).toBe(1168);
});

it("keeps a view at 00:00 on a resize even when the playhead is mid-film", () => {
usePlayerStore.setState({ currentTime: 6 });
const scroll = scrollBox(0);
mount({ pps: 100, scroll })({ pps: 150 });
expect(scroll.scrollLeft).toBe(0);
});

it("keeps the playhead where it is on screen when the toolbar zooms", () => {
usePlayerStore.setState({ currentTime: 6 });
const scroll = scrollBox(400);
const before = onScreenX(scroll, 6, 100);
mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before);
});

it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }, true);
expect(scroll.scrollLeft).toBe(0);
});

it("keeps the centre on a resize after a toolbar zoom that hit the zoom limit", () => {
usePlayerStore.setState({ currentTime: 6 });
const scroll = scrollBox(400);
const update = mount({ pps: 100, scroll });
update({}, true);
update({ pps: 200 });
expect(scroll.scrollLeft).toBe(1168);
});

it("brings an off-screen playhead into view when the toolbar zooms", () => {
usePlayerStore.setState({ currentTime: 30 });
const scroll = scrollBox(0);
mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
expectVisible(scroll, 30, 200);
});
});

describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", () => {
it("keeps 00:00 when the window resizes after an edit pinned the zoom", () => {
usePlayerStore.setState({ currentTime: 6 });
const scroll = scrollBox(0);
const update = mount({ pps: 100, scroll, percent: 200 });
update({ percent: 100 });
update({ pps: 130, percent: 100 });
expect(scroll.scrollLeft).toBe(0);
});

it("leaves an off-screen playhead alone when a length change re-pins the zoom", () => {
usePlayerStore.setState({ currentTime: 30 });
const scroll = scrollBox(400);
const update = mount({ pps: 100, scroll, percent: 150 });
update({ pps: 90, percent: 150 });
update({ pps: 101, percent: 168 });
const x = onScreenX(scroll, 30, 101);
expect(x > 800 || x < ORIGIN).toBe(true);
});

it("anchors a person's zoom that lands on the percent already stored", () => {
usePlayerStore.setState({ currentTime: 30 });
const scroll = scrollBox(0);
mount({ pps: 100, scroll, percent: 200 })({ pps: 200, percent: 200 }, true);
expectVisible(scroll, 30, 200);
});
});

describe("useTimelinePlayhead follow while paused", () => {
it("scrolls a paused seek that lands off screen into view", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => liveTime.notifySeek(30));
expectVisible(scroll, 30, 100);
});

it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => liveTime.notifySeek(7));
expect(scroll.scrollLeft).toBe(0);
});

it("scrolls back to the playhead when a person seeks to the time it already has", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => liveTime.notifySeek(30));
scroll.scrollLeft = 0;
act(() => liveTime.notifySeek(30));
expectVisible(scroll, 30, 100);
});

it("keeps a person's scroll when a reload republishes the seek rounded to a frame", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => liveTime.notifySeek(12.3456));
scroll.scrollLeft = 0;
act(() => liveTime.notify(Math.floor(12.3456 * 30) / 30));
expect(scroll.scrollLeft).toBe(0);
});

it("keeps a person's scroll when a reload follows a keyboard pause", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => usePlayerStore.setState({ isPlaying: true }));
act(() => liveTime.notify(30));
act(() => {
usePlayerStore.getState().setCurrentTime(30.012);
usePlayerStore.setState({ isPlaying: false });
});
scroll.scrollLeft = 0;
act(() => liveTime.notify(30.012));
expect(scroll.scrollLeft).toBe(0);
});

it("keeps a person's scroll when a reload stops a reverse shuttle", () => {
usePlayerStore.setState({ currentTime: 30 });
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => usePlayerStore.setState({ isPlaying: true }));
act(() => liveTime.notify(29));
act(() => liveTime.notify(20));
act(() => usePlayerStore.setState({ isPlaying: false }));
scroll.scrollLeft = 0;
act(() => liveTime.notify(20));
expect(scroll.scrollLeft).toBe(0);
});

it("does not scroll while the playhead is being dragged", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll, dragging: true });
act(() => liveTime.notifySeek(30));
expect(scroll.scrollLeft).toBe(0);
});

it("does not scroll while a beat is being dragged past the edge", () => {
usePlayerStore.setState({ beatDragging: true });
const scroll = scrollBox(0);
mount({ pps: 100, scroll });
act(() => liveTime.notifySeek(30));
expect(scroll.scrollLeft).toBe(0);
});

it("does not scroll in Fit", () => {
const scroll = scrollBox(0);
mount({ pps: 100, scroll, zoomMode: "fit" });
act(() => liveTime.notifySeek(30));
expect(scroll.scrollLeft).toBe(0);
});
});
Loading
Loading