From 31346e97dd55cfb5a3a8bdddac18bcf44626b8a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 18 Jul 2026 21:50:17 +0200 Subject: [PATCH] perf(studio): stabilize virtualized beat gestures --- .../src/player/components/BeatStrip.test.tsx | 315 ++++++++++++++++++ .../src/player/components/BeatStrip.tsx | 290 ++++++++++++++-- 2 files changed, 570 insertions(+), 35 deletions(-) create mode 100644 packages/studio/src/player/components/BeatStrip.test.tsx diff --git a/packages/studio/src/player/components/BeatStrip.test.tsx b/packages/studio/src/player/components/BeatStrip.test.tsx new file mode 100644 index 0000000000..e29eed3f5f --- /dev/null +++ b/packages/studio/src/player/components/BeatStrip.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; +import { BeatStrip } from "./BeatStrip"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const roots: Root[] = []; +const requestSeek = vi.fn(); +const commitBeatEdits = usePlayerStore.getState().commitBeatEdits; +const commitBeatEditsSpy = vi.fn((...args: Parameters) => + commitBeatEdits(...args), +); +const MUSIC_ELEMENT: TimelineElement = { + id: "background-music", + label: "Music", + tag: "audio", + src: "music.mp3", + start: 0, + duration: 10, + track: 0, + timelineRole: "music", +}; +const BEAT_ANALYSIS = { + beatTimes: [1, 3], + beatStrengths: [0.5, 0.8], + bpm: 120, + bpmConfidence: "high" as const, + channelData: null, + sampleRate: 48_000, + peak: 1, +}; + +function pointerEvent(type: string, init: PointerEventInit): Event { + if (typeof PointerEvent === "function") return new PointerEvent(type, init); + const event = new MouseEvent(type, init); + Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 }); + return event; +} + +function mountBeatStrip(renderTimeRange?: { start: number; end: number }) { + const viewport = document.createElement("div"); + viewport.dataset.timelineScrollViewport = ""; + Object.defineProperties(viewport, { + clientWidth: { configurable: true, value: 500 }, + clientHeight: { configurable: true, value: 300 }, + scrollWidth: { configurable: true, value: 2_000 }, + scrollHeight: { configurable: true, value: 2_000 }, + }); + viewport.getBoundingClientRect = () => + ({ left: 0, right: 1_000, top: 0, bottom: 500, width: 1_000, height: 500 }) as DOMRect; + Object.assign(viewport, { + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => true), + releasePointerCapture: vi.fn(), + }); + document.body.appendChild(viewport); + const root = createRoot(viewport); + roots.push(root); + act(() => { + root.render( + , + ); + }); + return { root, viewport }; +} + +function firstBeat(): HTMLDivElement { + const beat = document.querySelector( + '[title="Drag to move · double-click to delete"]', + ); + if (!beat) throw new Error("Expected a beat handle"); + return beat; +} + +function startBeatDrag(clientX = 100, pointerId = 1): void { + act(() => { + firstBeat().dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY: 100, pointerId }), + ); + }); + expect(usePlayerStore.getState().beatDragging).toBe(true); +} + +beforeEach(() => { + commitBeatEditsSpy.mockClear(); + requestSeek.mockReset(); + usePlayerStore.setState({ + timelineSessionEpoch: 1, + timelineProjectId: "project-a", + beatDragging: false, + requestSeek, + elements: [MUSIC_ELEMENT], + beatAnalysis: BEAT_ANALYSIS, + beatEdits: null, + commitBeatEdits: commitBeatEditsSpy, + }); +}); + +afterEach(() => { + act(() => window.dispatchEvent(new Event("blur"))); + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("BeatStrip gesture ownership", () => { + it("commits once after its source row unmounts", () => { + const { root } = mountBeatStrip(); + startBeatDrag(); + act(() => root.render(null)); + expect(usePlayerStore.getState().beatDragging).toBe(true); + + act(() => { + window.dispatchEvent( + pointerEvent("pointermove", { + bubbles: true, + clientX: 140, + clientY: 100, + pointerId: 1, + }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 140, + clientY: 100, + pointerId: 1, + }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 160, + clientY: 100, + pointerId: 1, + }), + ); + }); + + expect(commitBeatEditsSpy).toHaveBeenCalledExactlyOnceWith(expect.anything(), "move beat"); + expect(usePlayerStore.getState().beatEdits?.added[0]?.time).toBe(1.4); + expect(usePlayerStore.getState().beatDragging).toBe(false); + }); + + it("includes viewport scrolling in the final beat time", () => { + const { viewport } = mountBeatStrip(); + startBeatDrag(100); + viewport.scrollLeft = 50; + + act(() => { + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 110, + clientY: 100, + pointerId: 1, + }), + ); + }); + + expect(commitBeatEditsSpy).toHaveBeenCalledExactlyOnceWith(expect.anything(), "move beat"); + expect(usePlayerStore.getState().beatEdits?.added[0]?.time).toBe(1.6); + }); + + it("cancels without mutation on pointer cancel, Escape, and project switch", () => { + mountBeatStrip(); + startBeatDrag(); + act(() => { + window.dispatchEvent(pointerEvent("pointercancel", { pointerId: 1 })); + window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 })); + }); + + startBeatDrag(); + act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + + startBeatDrag(); + act(() => usePlayerStore.getState().beginTimelineSession("project-b")); + act(() => { + window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 })); + }); + + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().beatDragging).toBe(false); + }); + + it("cancels without mutation when the captured music source disappears", () => { + mountBeatStrip(); + startBeatDrag(); + act(() => usePlayerStore.setState({ elements: [] })); + act(() => { + window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 })); + }); + + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().beatDragging).toBe(false); + }); + + it("cancels when the captured beat disappears during the gesture", () => { + mountBeatStrip(); + startBeatDrag(); + act(() => { + usePlayerStore.setState({ + beatAnalysis: { ...BEAT_ANALYSIS, beatTimes: [3], beatStrengths: [0.8] }, + }); + window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 })); + }); + + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().beatDragging).toBe(false); + }); + + it("releases the actor when the owning timeline viewport unmounts", async () => { + const { viewport } = mountBeatStrip(); + startBeatDrag(); + + await act(async () => { + viewport.remove(); + await Promise.resolve(); + }); + act(() => { + window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 })); + }); + + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().beatDragging).toBe(false); + }); + + it("ignores unrelated pointers and keeps the active beat rendered outside the time window", () => { + const { root } = mountBeatStrip(); + startBeatDrag(); + act(() => { + window.dispatchEvent( + pointerEvent("pointermove", { + bubbles: true, + clientX: 150, + clientY: 100, + pointerId: 2, + }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 150, + clientY: 100, + pointerId: 2, + }), + ); + root.render( + , + ); + }); + + expect( + document.querySelectorAll('[title="Drag to move · double-click to delete"]'), + ).toHaveLength(2); + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + + act(() => { + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 150, + clientY: 100, + pointerId: 1, + }), + ); + }); + expect(commitBeatEditsSpy).toHaveBeenCalledOnce(); + }); + + it("does not autoscroll or mutate below the drag threshold", () => { + const requestAnimationFrame = vi.fn(() => 1); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + mountBeatStrip(); + startBeatDrag(990); + + act(() => { + window.dispatchEvent( + pointerEvent("pointermove", { + bubbles: true, + clientX: 991, + clientY: 100, + pointerId: 1, + }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { + bubbles: true, + clientX: 991, + clientY: 100, + pointerId: 1, + }), + ); + }); + + expect(requestAnimationFrame).not.toHaveBeenCalled(); + expect(commitBeatEditsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index a7558d56a8..03fad4daa4 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -1,12 +1,250 @@ -import { memo, useRef, useState } from "react"; -import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions"; +import { memo, useSyncExternalStore } from "react"; +import { + deleteBeatAtCompositionTime, + moveBeatCompositionTime, + remapBeatAnalysisToComposition, +} from "../../utils/beatEditActions"; import { usePlayerStore } from "../store/playerStore"; import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; +import { + applyTimelineAutoScrollStep, + resolveTimelineAutoScrollLoopAction, +} from "./timelineEditing"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; export const BEAT_BAND_H = 14; // dark band height at top of track const BEAT_HIT_W = 12; // grab width per beat (px) +interface BeatDragActor { + readonly pointerId: number; + readonly index: number; + readonly startX: number; + readonly clientX: number; + readonly clientY: number; + readonly originalTime: number; + readonly pixelsPerSecond: number; + readonly startScrollLeft: number; + readonly dx: number; + readonly started: boolean; + readonly sessionEpoch: number; + readonly projectId: string | null; + readonly musicElement: NonNullable["musicElement"]>; + readonly musicKey: string; + readonly musicSrc: string; + readonly scroll: HTMLElement; +} + +let beatDragActor: BeatDragActor | null = null; +let beatDragRaf = 0; +let unsubscribeBeatDragSession: (() => void) | null = null; +let beatDragViewportObserver: MutationObserver | null = null; +const beatDragSubscribers = new Set<() => void>(); + +function publishBeatDrag(next: BeatDragActor | null): void { + beatDragActor = next; + for (const subscriber of beatDragSubscribers) subscriber(); +} + +function subscribeBeatDrag(subscriber: () => void): () => void { + beatDragSubscribers.add(subscriber); + return () => beatDragSubscribers.delete(subscriber); +} + +function getBeatDragSnapshot(): BeatDragActor | null { + return beatDragActor; +} + +function releaseBeatDragResources(actor: BeatDragActor): void { + if (beatDragRaf !== 0) { + cancelAnimationFrame(beatDragRaf); + beatDragRaf = 0; + } + unsubscribeBeatDragSession?.(); + unsubscribeBeatDragSession = null; + beatDragViewportObserver?.disconnect(); + beatDragViewportObserver = null; + window.removeEventListener("pointermove", handleBeatDragPointerMove); + window.removeEventListener("pointerup", handleBeatDragPointerUp); + window.removeEventListener("pointercancel", handleBeatDragPointerCancel); + window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel); + window.removeEventListener("keydown", handleBeatDragKeyDown, true); + window.removeEventListener("blur", cancelBeatDrag); + try { + if (actor.scroll.hasPointerCapture?.(actor.pointerId)) { + actor.scroll.releasePointerCapture(actor.pointerId); + } + } catch { + // Window listeners retain terminal ownership when pointer capture is unavailable. + } + usePlayerStore.getState().setBeatDragging(false); +} + +/** Claiming clears the shared actor before cleanup, so later terminal events are no-ops. */ +function claimBeatDrag(pointerId?: number): BeatDragActor | null { + const actor = beatDragActor; + if (!actor || (pointerId !== undefined && actor.pointerId !== pointerId)) return null; + publishBeatDrag(null); + releaseBeatDragResources(actor); + return actor; +} + +function cancelBeatDrag(): void { + claimBeatDrag(); +} + +function beatDragTime(actor: BeatDragActor): number { + return Math.max(0, actor.originalTime + actor.dx / actor.pixelsPerSecond); +} + +function isBeatDragSourceCurrent( + actor: BeatDragActor, + state: ReturnType, +): boolean { + const currentMusic = getTimelineElementIndexes(state.elements).musicElement; + if ( + !actor.scroll.isConnected || + state.timelineSessionEpoch !== actor.sessionEpoch || + state.timelineProjectId !== actor.projectId || + currentMusic !== actor.musicElement || + (currentMusic.key ?? currentMusic.id) !== actor.musicKey || + currentMusic.src !== actor.musicSrc + ) { + return false; + } + const analysis = remapBeatAnalysisToComposition( + state.beatAnalysis, + currentMusic, + state.beatEdits, + ); + return analysis?.beatTimes.some((time) => Math.abs(time - actor.originalTime) < 1e-3) === true; +} + +function updateBeatDrag(clientX: number, clientY: number): BeatDragActor | null { + const actor = beatDragActor; + if (!actor) return null; + const pointerDx = clientX - actor.startX; + const next = { + ...actor, + clientX, + clientY, + dx: pointerDx + actor.scroll.scrollLeft - actor.startScrollLeft, + started: actor.started || Math.abs(pointerDx) > 2, + }; + publishBeatDrag(next); + usePlayerStore.getState().requestSeek(beatDragTime(next)); + return next; +} + +function stepBeatDragAutoScroll(): void { + beatDragRaf = 0; + const actor = beatDragActor; + if (!actor?.started) return; + if (!applyTimelineAutoScrollStep(actor.scroll, actor.clientX, actor.clientY)) return; + updateBeatDrag(actor.clientX, actor.clientY); + beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll); +} + +function syncBeatDragAutoScroll(actor: BeatDragActor): void { + const action = resolveTimelineAutoScrollLoopAction( + actor.scroll, + actor.clientX, + actor.clientY, + beatDragRaf !== 0, + ); + if (action === "start" && actor.started) { + beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll); + } else if (action === "stop" && beatDragRaf !== 0) { + cancelAnimationFrame(beatDragRaf); + beatDragRaf = 0; + } +} + +function handleBeatDragPointerMove(event: PointerEvent): void { + if (event.pointerId !== beatDragActor?.pointerId) return; + event.preventDefault(); + const actor = updateBeatDrag(event.clientX, event.clientY); + if (actor) syncBeatDragAutoScroll(actor); +} + +function handleBeatDragPointerUp(event: PointerEvent): void { + if (event.pointerId !== beatDragActor?.pointerId) return; + const latest = updateBeatDrag(event.clientX, event.clientY); + const actor = claimBeatDrag(event.pointerId); + if (!actor || !latest?.started) return; + const store = usePlayerStore.getState(); + if (!isBeatDragSourceCurrent(actor, store)) return; + const newTime = beatDragTime(latest); + moveBeatCompositionTime(actor.originalTime, newTime); + store.requestSeek(newTime); +} + +function handleBeatDragPointerCancel(event: PointerEvent): void { + claimBeatDrag(event.pointerId); +} + +function handleBeatDragKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape" || !beatDragActor) return; + event.preventDefault(); + event.stopPropagation(); + cancelBeatDrag(); +} + +function beginBeatDrag( + event: React.PointerEvent, + index: number, + originalTime: number, + pixelsPerSecond: number, +): void { + if (event.button !== 0 || !(pixelsPerSecond > 0)) return; + const scroll = event.currentTarget.closest("[data-timeline-scroll-viewport]"); + if (!scroll) return; + cancelBeatDrag(); + const store = usePlayerStore.getState(); + const musicElement = getTimelineElementIndexes(store.elements).musicElement; + if (!musicElement?.src) return; + const actor: BeatDragActor = { + pointerId: event.pointerId, + index, + startX: event.clientX, + clientX: event.clientX, + clientY: event.clientY, + originalTime, + pixelsPerSecond, + startScrollLeft: scroll.scrollLeft, + dx: 0, + started: false, + sessionEpoch: store.timelineSessionEpoch, + projectId: store.timelineProjectId, + musicElement, + musicKey: musicElement.key ?? musicElement.id, + musicSrc: musicElement.src, + scroll, + }; + if (!isBeatDragSourceCurrent(actor, store)) return; + publishBeatDrag(actor); + window.addEventListener("pointermove", handleBeatDragPointerMove); + window.addEventListener("pointerup", handleBeatDragPointerUp); + window.addEventListener("pointercancel", handleBeatDragPointerCancel); + window.addEventListener("lostpointercapture", handleBeatDragPointerCancel); + window.addEventListener("keydown", handleBeatDragKeyDown, true); + window.addEventListener("blur", cancelBeatDrag); + unsubscribeBeatDragSession = usePlayerStore.subscribe((state) => { + if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag(); + }); + beatDragViewportObserver = new MutationObserver(() => { + if (!actor.scroll.isConnected) cancelBeatDrag(); + }); + beatDragViewportObserver.observe(document, { childList: true, subtree: true }); + try { + scroll.setPointerCapture?.(event.pointerId); + } catch { + // Window listeners retain terminal ownership when pointer capture is unavailable. + } + store.setBeatDragging(true); + store.requestSeek(Math.max(0, originalTime)); +} + /** Hide both layers when beats are packed tighter than this (px) — too dense to read. */ function beatsTooDense(beatTimes: number[], pps: number): boolean { if (beatTimes.length < 2) return true; @@ -96,11 +334,22 @@ export const BeatStrip = memo(function BeatStrip({ pps: number; renderTimeRange?: TimelineTimeRange; }) { - // Active drag: which beat and how far (px) it's been dragged. - const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null); - const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null); + const activeActor = useSyncExternalStore( + subscribeBeatDrag, + getBeatDragSnapshot, + getBeatDragSnapshot, + ); + const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch); + const projectId = usePlayerStore((state) => state.timelineProjectId); if (!beatTimes || beatsTooDense(beatTimes, pps)) return null; + const drag = + activeActor && + activeActor.sessionEpoch === sessionEpoch && + activeActor.projectId === projectId && + Math.abs((beatTimes[activeActor.index] ?? Number.NaN) - activeActor.originalTime) < 1e-3 + ? activeActor + : null; const cy = BEAT_BAND_H / 2; const beatEntries = getTimelineBeatEntries( beatTimes, @@ -141,36 +390,7 @@ export const BeatStrip = memo(function BeatStrip({ // selection (which otherwise "selects" the whole panel mid-drag). e.preventDefault(); e.stopPropagation(); - e.currentTarget.setPointerCapture(e.pointerId); - dragRef.current = { index: i, startX: e.clientX, origTime: t }; - setDrag({ index: i, dx: 0 }); - usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline - usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat - }} - onPointerMove={(e) => { - const d = dragRef.current; - if (!d || d.index !== i) return; - e.preventDefault(); - const dx = e.clientX - d.startX; - setDrag({ index: i, dx }); - // Scrub the audio (and move the playhead) to follow the dragged beat. - usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps)); - }} - onPointerUp={(e) => { - const d = dragRef.current; - dragRef.current = null; - setDrag(null); - usePlayerStore.getState().setBeatDragging(false); - if (e.currentTarget.hasPointerCapture?.(e.pointerId)) { - e.currentTarget.releasePointerCapture(e.pointerId); - } - if (!d || d.index !== i) return; - const dx = e.clientX - d.startX; - if (Math.abs(dx) > 2) { - const newTime = Math.max(0, d.origTime + dx / pps); - moveBeatCompositionTime(d.origTime, newTime); - usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat - } + beginBeatDrag(e, i, t, pps); }} onDoubleClick={(e) => { e.stopPropagation();