refactor(waveform-seek): H3 — extract renderers + 2 hooks + SeekbarPreview component (#665)

* refactor(waveform-seek): H3.1 — extract helpers + constants + types

* refactor(waveform-seek): H3.2 — extract drawSeekbar + style renderers to utils/waveformSeekRenderers.ts

* refactor(waveform-seek): H3.3 — split renderers into static + animated

* refactor(waveform-seek): H3.4 — extract SeekbarPreview to WaveformSeekPreview.tsx

* refactor(waveform-seek): H3.5 — extract useWaveformHeights hook + hoist constants

* refactor(waveform-seek): H3.6 — extract useWaveformInterpolation hook
This commit is contained in:
Frank Stellmacher
2026-05-13 22:04:45 +02:00
committed by GitHub
parent b8a9fe860e
commit dc5c64a109
9 changed files with 1183 additions and 1084 deletions
+111
View File
@@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import type { SeekbarStyle } from '../store/authStoreTypes';
import type { AnimState } from '../utils/waveformSeekHelpers';
import {
ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS,
binsToHeights, easeOutCubic, heightsNearlyEqual,
makeFlatWaveHeights, makeHeights,
} from '../utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveformSeekRenderers';
interface Args {
trackId: string | undefined;
waveformBins: number[] | null | undefined;
seekbarStyle: SeekbarStyle;
heightsRef: React.MutableRefObject<Float32Array | null>;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
styleRef: React.MutableRefObject<SeekbarStyle>;
progressRef: React.MutableRefObject<number>;
bufferedRef: React.MutableRefObject<number>;
animStateRef: React.MutableRefObject<AnimState>;
}
/** Manages `heightsRef` for the seekbar: bootstraps deterministic bars for
* pseudowave, animates a morph between waveform analysis updates, and falls
* back to a flat wave when no bins exist yet. */
export function useWaveformHeights({
trackId, waveformBins, seekbarStyle,
heightsRef, canvasRef, styleRef, progressRef, bufferedRef, animStateRef,
}: Args) {
useEffect(() => {
if (!trackId) {
heightsRef.current = null;
return;
}
// Pseudowave is the deterministic per-track-ID variant — no analysis needed,
// no morph animation, no flat-fallback. It just sits there looking like a
// waveform.
if (seekbarStyle === 'pseudowave') {
heightsRef.current = makeHeights(trackId);
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(seekbarStyle)) {
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
}
return;
}
if (waveformBins && waveformBins.length > 0) {
const h = binsToHeights(waveformBins);
const prev = heightsRef.current;
if (!prev || prev.length !== BAR_COUNT) {
heightsRef.current = h;
return;
}
if (heightsNearlyEqual(prev, h, 0.02)) {
heightsRef.current = h;
return;
}
const from = new Float32Array(prev);
const to = h;
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
if (heightsRef.current?.length === BAR_COUNT) {
const current = heightsRef.current;
let isAlreadyFlat = true;
for (let i = 0; i < BAR_COUNT; i++) {
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
isAlreadyFlat = false;
break;
}
}
if (isAlreadyFlat) return;
const from = new Float32Array(current);
const to = makeFlatWaveHeights();
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
// No analysis bins yet: render 500 flat bars immediately.
heightsRef.current = makeFlatWaveHeights();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackId, waveformBins, seekbarStyle]);
}
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect } from 'react';
import type { SeekbarStyle } from '../store/authStoreTypes';
import { getPlaybackProgressSnapshot } from '../store/playbackProgress';
import {
ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS,
isBarQuantizedSeekStyle, quantizeProgressByBars,
} from '../utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveformSeekRenderers';
interface Args {
duration: number;
isPlaying: boolean;
previewFreezesMainSeekbar: boolean;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
heightsRef: React.MutableRefObject<Float32Array | null>;
styleRef: React.MutableRefObject<SeekbarStyle>;
progressRef: React.MutableRefObject<number>;
bufferedRef: React.MutableRefObject<number>;
visualProgressRef: React.MutableRefObject<number>;
visualTargetProgressRef: React.MutableRefObject<number>;
progressAnchorRef: React.MutableRefObject<{ progress: number; atMs: number }>;
animStateRef: React.MutableRefObject<AnimState>;
isDraggingRef: React.MutableRefObject<boolean>;
wheelPreviewFractionRef: React.MutableRefObject<number | null>;
wheelPreviewUntilRef: React.MutableRefObject<number>;
pendingCommittedSeekRef: React.MutableRefObject<{ fraction: number; setAtMs: number } | null>;
}
/** Smooth interpolation rAF loop — predicts playhead position between sparse
* transport heartbeats, with proper anchor reset on resume. Inactive while
* paused, dragging, wheel-preview, or pending-committed-seek. */
export function useWaveformInterpolation({
duration, isPlaying, previewFreezesMainSeekbar,
canvasRef, heightsRef, styleRef,
progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef,
progressAnchorRef, animStateRef, isDraggingRef,
wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef,
}: Args) {
useEffect(() => {
if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return;
// This effect is torn down while paused, so `progressAnchorRef.atMs` is not refreshed.
// On resume the first `tick` would add the entire pause duration to `elapsedSec` and
// overshoot the playhead until the next transport heartbeat corrects it.
const snap = getPlaybackProgressSnapshot();
const raw = snap.progress;
progressRef.current = raw;
progressAnchorRef.current = {
progress: raw,
atMs: performance.now(),
};
const resumeVisual = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(raw)
: raw;
visualTargetProgressRef.current = resumeVisual;
visualProgressRef.current = resumeVisual;
let rafId: number | null = null;
let lastPaintAt = 0;
const tick = (now: number) => {
if (document.hidden || window.__psyHidden) {
rafId = requestAnimationFrame(tick);
return;
}
if (isDraggingRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
if (pendingCommittedSeekRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
const anchor = progressAnchorRef.current;
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(predicted)
: predicted;
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
visualTargetProgressRef.current = nextTargetProgress;
}
const currentVisual = visualProgressRef.current;
const targetVisual = visualTargetProgressRef.current;
const delta = targetVisual - currentVisual;
if (Math.abs(delta) > 0.000001) {
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
const nextVisualProgress = Math.abs(delta) < 0.002
? targetVisual
: currentVisual + delta * smoothing;
visualProgressRef.current = nextVisualProgress;
progressRef.current = nextVisualProgress;
const needsDirectDraw = !ANIMATED_STYLES.has(styleRef.current);
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
const canvas = canvasRef.current;
if (canvas) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
lastPaintAt = now;
}
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId != null) cancelAnimationFrame(rafId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [duration, isPlaying, previewFreezesMainSeekbar]);
}