mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
committed by
GitHub
parent
b8a9fe860e
commit
dc5c64a109
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user