|
| 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