mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
419 lines
19 KiB
TypeScript
419 lines
19 KiB
TypeScript
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 0–1 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>
|
||
);
|
||
}
|