Skip to content

Commit 011f510

Browse files
committed
feat: clip volume line and timeline waveform improvements
1 parent c46d288 commit 011f510

File tree

19 files changed

+1637
-719
lines changed

19 files changed

+1637
-719
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"use client";
2+
3+
import { useCallback, useRef, useState } from "react";
4+
import { createPortal } from "react-dom";
5+
import { useEditor } from "@/hooks/use-editor";
6+
import { DEFAULTS } from "@/lib/timeline/defaults";
7+
import {
8+
getDbFromLinePos,
9+
getLinePosFromDb,
10+
} from "@/lib/timeline/audio-display";
11+
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
12+
import { hasAnimatedVolume } from "@/lib/timeline/audio-state";
13+
import type { AudioElement } from "@/lib/timeline/types";
14+
import {
15+
clamp,
16+
formatNumberForDisplay,
17+
getFractionDigitsForStep,
18+
isNearlyEqual,
19+
snapToStep,
20+
} from "@/utils/math";
21+
import { cn } from "@/utils/ui";
22+
23+
const HIT_AREA_HEIGHT_PX = 14;
24+
const TOOLTIP_OFFSET_PX = 10;
25+
const VOLUME_STEP = 0.1;
26+
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
27+
28+
function clampVolume({ value }: { value: number }): number {
29+
return clamp({
30+
value: snapToStep({ value, step: VOLUME_STEP }),
31+
min: VOLUME_DB_MIN,
32+
max: VOLUME_DB_MAX,
33+
});
34+
}
35+
36+
function getVolumeFromPointer({
37+
clientY,
38+
rect,
39+
}: {
40+
clientY: number;
41+
rect: DOMRect;
42+
}): number {
43+
const clampedOffset = clamp({
44+
value: clientY - rect.top,
45+
min: 0,
46+
max: rect.height,
47+
});
48+
const progressPercent =
49+
rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
50+
return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
51+
}
52+
53+
export function AudioVolumeLine({
54+
element,
55+
trackId,
56+
}: {
57+
element: AudioElement;
58+
trackId: string;
59+
}) {
60+
const editor = useEditor();
61+
const surfaceRef = useRef<HTMLDivElement>(null);
62+
const activePointerIdRef = useRef<number | null>(null);
63+
const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
64+
const lastPreviewVolumeRef = useRef(
65+
element.volume ?? DEFAULTS.element.volume,
66+
);
67+
const hasChangedRef = useRef(false);
68+
const [isDragging, setIsDragging] = useState(false);
69+
const [tooltipClientPos, setTooltipClientPos] = useState<{
70+
x: number;
71+
y: number;
72+
} | null>(null);
73+
74+
const hasAnimatedEnvelope = hasAnimatedVolume({ element });
75+
const currentVolume = element.volume ?? DEFAULTS.element.volume;
76+
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
77+
78+
const volumeLabel = `${formatNumberForDisplay({
79+
value: currentVolume,
80+
fractionDigits: VOLUME_FRACTION_DIGITS,
81+
})} dB`;
82+
83+
const previewVolume = useCallback(
84+
(nextVolume: number) => {
85+
if (
86+
isNearlyEqual({
87+
leftValue: nextVolume,
88+
rightValue: lastPreviewVolumeRef.current,
89+
})
90+
) {
91+
return;
92+
}
93+
94+
editor.timeline.previewElements({
95+
updates: [
96+
{
97+
trackId,
98+
elementId: element.id,
99+
updates: { volume: nextVolume },
100+
},
101+
],
102+
});
103+
lastPreviewVolumeRef.current = nextVolume;
104+
hasChangedRef.current = !isNearlyEqual({
105+
leftValue: startVolumeRef.current,
106+
rightValue: nextVolume,
107+
});
108+
},
109+
[editor, element.id, trackId],
110+
);
111+
112+
const finishDrag = useCallback(
113+
({ shouldCommit }: { shouldCommit: boolean }) => {
114+
activePointerIdRef.current = null;
115+
setIsDragging(false);
116+
117+
if (shouldCommit && hasChangedRef.current) {
118+
editor.timeline.commitPreview();
119+
} else {
120+
editor.timeline.discardPreview();
121+
}
122+
123+
hasChangedRef.current = false;
124+
lastPreviewVolumeRef.current = startVolumeRef.current;
125+
setTooltipClientPos(null);
126+
},
127+
[editor],
128+
);
129+
130+
const updateFromPointer = useCallback(
131+
({ clientX, clientY }: { clientX: number; clientY: number }) => {
132+
const rect = surfaceRef.current?.getBoundingClientRect();
133+
if (!rect) {
134+
return;
135+
}
136+
137+
setTooltipClientPos({
138+
x: clientX + TOOLTIP_OFFSET_PX,
139+
y: clientY - TOOLTIP_OFFSET_PX,
140+
});
141+
previewVolume(getVolumeFromPointer({ clientY, rect }));
142+
},
143+
[previewVolume],
144+
);
145+
146+
const handleClick = useCallback((event: React.MouseEvent) => {
147+
event.preventDefault();
148+
event.stopPropagation();
149+
}, []);
150+
151+
const handleMouseDown = useCallback((event: React.MouseEvent) => {
152+
event.stopPropagation();
153+
}, []);
154+
155+
const handlePointerDown = useCallback(
156+
(event: React.PointerEvent<HTMLDivElement>) => {
157+
if (event.button !== 0) {
158+
return;
159+
}
160+
161+
event.preventDefault();
162+
event.stopPropagation();
163+
editor.selection.setSelectedElements({
164+
elements: [{ trackId, elementId: element.id }],
165+
});
166+
activePointerIdRef.current = event.pointerId;
167+
startVolumeRef.current = currentVolume;
168+
lastPreviewVolumeRef.current = currentVolume;
169+
hasChangedRef.current = false;
170+
setIsDragging(true);
171+
event.currentTarget.setPointerCapture(event.pointerId);
172+
updateFromPointer({
173+
clientX: event.clientX,
174+
clientY: event.clientY,
175+
});
176+
},
177+
[currentVolume, editor.selection, element.id, trackId, updateFromPointer],
178+
);
179+
180+
const handlePointerMove = useCallback(
181+
(event: React.PointerEvent) => {
182+
if (activePointerIdRef.current !== event.pointerId) {
183+
return;
184+
}
185+
186+
event.preventDefault();
187+
updateFromPointer({
188+
clientX: event.clientX,
189+
clientY: event.clientY,
190+
});
191+
},
192+
[updateFromPointer],
193+
);
194+
195+
const handlePointerUp = useCallback(
196+
(event: React.PointerEvent) => {
197+
if (activePointerIdRef.current !== event.pointerId) {
198+
return;
199+
}
200+
201+
event.preventDefault();
202+
event.stopPropagation();
203+
finishDrag({ shouldCommit: true });
204+
},
205+
[finishDrag],
206+
);
207+
208+
const handlePointerCancel = useCallback(
209+
(event: React.PointerEvent) => {
210+
if (activePointerIdRef.current !== event.pointerId) {
211+
return;
212+
}
213+
214+
event.preventDefault();
215+
event.stopPropagation();
216+
finishDrag({ shouldCommit: false });
217+
},
218+
[finishDrag],
219+
);
220+
221+
const handleLostPointerCapture = useCallback(() => {
222+
if (activePointerIdRef.current === null) {
223+
return;
224+
}
225+
226+
finishDrag({ shouldCommit: hasChangedRef.current });
227+
}, [finishDrag]);
228+
229+
if (hasAnimatedEnvelope) {
230+
return null;
231+
}
232+
233+
return (
234+
<div className="pointer-events-none absolute inset-0">
235+
<div ref={surfaceRef} className="absolute inset-0">
236+
<div
237+
className={cn(
238+
"pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
239+
isDragging
240+
? "border-white"
241+
: "border-white/50 group-hover/audio:border-white/80",
242+
)}
243+
style={{ top: lineTop }}
244+
/>
245+
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
246+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
247+
<div
248+
className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
249+
style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
250+
onClick={handleClick}
251+
onMouseDown={handleMouseDown}
252+
onPointerDown={handlePointerDown}
253+
onPointerMove={handlePointerMove}
254+
onPointerUp={handlePointerUp}
255+
onPointerCancel={handlePointerCancel}
256+
onLostPointerCapture={handleLostPointerCapture}
257+
title="Drag to adjust clip volume"
258+
/>
259+
{isDragging &&
260+
tooltipClientPos &&
261+
createPortal(
262+
<div
263+
className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
264+
style={{
265+
transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
266+
}}
267+
>
268+
{volumeLabel}
269+
</div>,
270+
document.body,
271+
)}
272+
</div>
273+
</div>
274+
);
275+
}

0 commit comments

Comments
 (0)