Files
Psychotoxical-psysonic/src/features/waveform/components/WaveformSeek.tsx
T

419 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '@/store/playerStore';
import { usePreviewStore } from '@/store/previewStore';
import { useAuthStore } from '@/store/authStore';
import {
ANIMATED_STYLES, AnimState,
STATIC_REDRAW_FORCE_MS, STATIC_REDRAW_MIN_MS,
fmt, invalidateColorCache, isBarQuantizedSeekStyle, makeAnimState,
quantizeProgressByBars,
} from '@/features/waveform/utils/waveformSeekHelpers';
import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
import { useWaveformHeights } from '@/features/waveform/hooks/useWaveformHeights';
import { useWaveformInterpolation } from '@/features/waveform/hooks/useWaveformInterpolation';
// ── main component ────────────────────────────────────────────────────────────
//
// Architecture:
// Static styles (waveform, bar, …): drawn directly in the Zustand subscription
// callback — no React re-renders, no rAF loop. 2 draws/s at the 500 ms
// Rust interval. shadowBlur + 500 canvas bars on a software-rendered
// WebKitGTK context is too expensive for a continuous 60 fps loop.
// Animated styles (pulsewave, particletrail, …): rAF loop at 60 fps, reads
// refs that the subscription keeps up-to-date.
// Drag: draws synchronously in seekToFraction for 1:1 responsiveness.
interface Props {
trackId: string | undefined;
}
const SEEK_COMMIT_GUARD_MS = 900;
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const WHEEL_SEEK_STEP_SECONDS = 10;
const WHEEL_SEEK_DEBOUNCE_MS = 350;
export default function WaveformSeek({ trackId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(getPlaybackProgressSnapshot().progress);
const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const visualProgressRef = useRef(progressRef.current);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const visualTargetProgressRef = useRef(progressRef.current);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const lastStaticDrawAtRef = useRef(0);
const lastStaticDrawProgressRef = useRef(-1);
const lastStaticDrawBufferedRef = useRef(-1);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
const isPlaying = usePlayerStore(s => s.isPlaying);
/** Track preview pauses the main sink in Rust; `isPlaying` stays true so the bar must not extrapolate. */
const previewFreezesMainSeekbar = usePreviewStore(s => s.previewingId != null);
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
// Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues.
const styleRef = useRef(seekbarStyle);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
styleRef.current = seekbarStyle;
useWaveformHeights({
trackId, waveformBins, seekbarStyle,
heightsRef, canvasRef, styleRef, progressRef, bufferedRef, animStateRef,
});
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
useEffect(() => {
return subscribePlaybackProgress((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
if (isDragging.current) return;
const now = Date.now();
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null) {
if (now < wheelPreviewUntilRef.current) return;
wheelPreviewFractionRef.current = null;
}
const pendingCommit = pendingCommittedSeekRef.current;
if (pendingCommit) {
const ageMs = Date.now() - pendingCommit.setAtMs;
if (ageMs < SEEK_COMMIT_MIN_HOLD_MS) return;
const matched = Math.abs(state.progress - pendingCommit.fraction) <= SEEK_COMMIT_PROGRESS_EPS;
const expired = ageMs > SEEK_COMMIT_GUARD_MS;
if (!matched && !expired) return;
pendingCommittedSeekRef.current = null;
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
progressAnchorRef.current = {
progress: state.progress,
atMs: performance.now(),
};
visualTargetProgressRef.current = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(state.progress)
: state.progress;
// While paused the interpolation rAF is disabled, so keep the drawn playhead
// in sync with external seeks (keyboard, MPRIS, queue). Drag/wheel still
// update these refs via previewFraction.
if (!usePlayerStore.getState().isPlaying) {
visualProgressRef.current = visualTargetProgressRef.current;
}
// Static styles always redraw on progress; animated styles let the rAF
// loop drive paints.
const drawNow = !ANIMATED_STYLES.has(styleRef.current);
if (drawNow) {
const canvas = canvasRef.current;
if (!canvas) return;
if (!ANIMATED_STYLES.has(styleRef.current) && !isDragging.current) {
const now = Date.now();
const widthPx = Math.max(1, canvas.clientWidth || canvas.width || 1);
const minVisualDelta = 0.35 / widthPx; // allow smoother progress while still skipping no-op paints
const progressDelta = Math.abs(state.progress - lastStaticDrawProgressRef.current);
const bufferedDelta = Math.abs(state.buffered - lastStaticDrawBufferedRef.current);
const ageMs = now - lastStaticDrawAtRef.current;
const visuallySame = progressDelta < minVisualDelta && bufferedDelta < minVisualDelta;
if (
ageMs < STATIC_REDRAW_MIN_MS &&
visuallySame
) return;
if (visuallySame && ageMs < STATIC_REDRAW_FORCE_MS) return;
lastStaticDrawAtRef.current = now;
lastStaticDrawProgressRef.current = state.progress;
lastStaticDrawBufferedRef.current = state.buffered;
}
drawSeekbar(canvas, styleRef.current, heightsRef.current, visualProgressRef.current, state.buffered);
}
});
}, []);
// Initial draw for static styles when style, track, or waveform payload changes.
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
}, [
seekbarStyle,
trackId,
waveformBins,
duration,
]);
// rAF loop — animated styles only.
useEffect(() => {
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current;
if (!canvas) return;
animStateRef.current = makeAnimState();
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
tick();
return () => stop();
}, [seekbarStyle]);
// Smoothly advance progress between sparse transport ticks.
// Preview pauses main sink in Rust while UI `isPlaying` may still be true.
// When preview ends, interpolation must restart from "now", otherwise the
// old anchor timestamp adds preview duration and causes a one-frame jump.
useEffect(() => {
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: progressRef.current,
atMs: performance.now(),
};
const quantizedOrRaw = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(progressRef.current)
: progressRef.current;
visualTargetProgressRef.current = quantizedOrRaw;
// Keep current visual position as-is; only reset timing anchor.
}, [previewFreezesMainSeekbar]);
// Resize observer.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(() => {
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
ro.observe(canvas);
return () => ro.disconnect();
}, [seekbarStyle]);
// Theme change observer — redraw canvas when theme changes.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new MutationObserver(() => {
invalidateColorCache();
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, [seekbarStyle]);
// Dev-only: a theme's CSS variables can change via Vite HMR without the
// `data-theme` attribute changing, so the observer above never fires and the
// cached colours + canvas keep the old palette until a manual theme switch
// (annoying during theme dev — issue: waveform doesn't reflect CSS var edits).
// On every HMR update, drop the colour cache and repaint. `import.meta.hot` is
// undefined in production builds, so this whole effect is stripped there.
useEffect(() => {
// `import.meta.hot` is undefined in prod builds and only a partial stub in
// the test/SSR env (has `.on` but not `.off`), so feature-detect both before
// wiring — otherwise the cleanup throws under vitest.
const hot = import.meta.hot;
if (!hot || typeof hot.on !== 'function' || typeof hot.off !== 'function') return;
const repaint = () => {
invalidateColorCache();
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
};
hot.on('vite:afterUpdate', repaint);
return () => { hot.off('vite:afterUpdate', repaint); };
}, [seekbarStyle]);
const trackIdRef = useRef(trackId);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
trackIdRef.current = trackId;
const seekRef = useRef(seek);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const progressAnchorRef = useRef<{ progress: number; atMs: number }>({
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
progress: progressRef.current,
// React Compiler purity rule: intentional live-timestamp read at render (performance.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
atMs: performance.now(),
});
const wheelSeekTimerRef = useRef<number | null>(null);
const queuedWheelSeekFractionRef = useRef<number | null>(null);
const wheelPreviewFractionRef = useRef<number | null>(null);
const wheelPreviewUntilRef = useRef(0);
useWaveformInterpolation({
duration, isPlaying, previewFreezesMainSeekbar,
canvasRef, heightsRef, styleRef,
progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef,
progressAnchorRef, animStateRef, isDraggingRef: isDragging,
wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef,
});
useEffect(() => () => {
if (wheelSeekTimerRef.current != null) {
window.clearTimeout(wheelSeekTimerRef.current);
wheelSeekTimerRef.current = null;
}
wheelPreviewFractionRef.current = null;
wheelPreviewUntilRef.current = 0;
}, []);
// Preview a 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => {
progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
};
pendingSeekRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
}
};
const commitSeek = () => {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
seekRef.current(fraction);
};
useEffect(() => {
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackIdRef.current) return;
const rect = canvas.getBoundingClientRect();
previewFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
commitSeek();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
return (
<div className="waveform-seek-container" style={{ position: 'relative', width: '100%' }}>
{hoverPct !== null && duration > 0 && (
<span
className="player-volume-pct"
style={{ left: `${hoverPct * 100}%` }}
>
{fmt(hoverPct * duration)}
</span>
)}
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onWheel={e => {
if (!trackIdRef.current || duration <= 0 || isDragging.current) return;
e.preventDefault();
const wheelSteps = Math.max(1, Math.round(Math.abs(e.deltaY) / 100));
if (wheelSteps <= 0) return;
const now = Date.now();
const currentSeconds = progressRef.current * duration;
const deltaSeconds = (e.deltaY > 0 ? -1 : 1) * WHEEL_SEEK_STEP_SECONDS * wheelSteps;
const nextSeconds = Math.max(0, Math.min(duration, currentSeconds + deltaSeconds));
const nextFraction = Math.max(0, Math.min(1, nextSeconds / duration));
// Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewFractionRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextFraction, bufferedRef.current);
}
// Trailing debounce: commit seek only after wheel activity settles.
queuedWheelSeekFractionRef.current = nextFraction;
if (wheelSeekTimerRef.current != null) {
window.clearTimeout(wheelSeekTimerRef.current);
}
wheelSeekTimerRef.current = window.setTimeout(() => {
wheelSeekTimerRef.current = null;
const queuedFraction = queuedWheelSeekFractionRef.current;
queuedWheelSeekFractionRef.current = null;
if (queuedFraction == null) return;
wheelPreviewFractionRef.current = null;
wheelPreviewUntilRef.current = 0;
pendingCommittedSeekRef.current = { fraction: queuedFraction, setAtMs: Date.now() };
seekRef.current(queuedFraction);
}, WHEEL_SEEK_DEBOUNCE_MS);
}}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
previewFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
const rect = e.currentTarget.getBoundingClientRect();
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseLeave={() => setHoverPct(null)}
/>
</div>
);
}