mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(waveform): co-locate seekbar feature into features/waveform
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* `WaveformSeek` characterization (Phase F5a).
|
||||
*
|
||||
* jsdom does not run canvas rendering, so this file tests the input
|
||||
* surface (cursor state, no-track guard, wheel-debounce → seek wiring)
|
||||
* rather than the visual output. The actual canvas drawing path is
|
||||
* covered by manual smoke per the v2 plan.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
|
||||
import WaveformSeek from '@/features/waveform/components/WaveformSeek';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetAllStores();
|
||||
// Seed an active server so any downstream invokes are valid.
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('WaveformSeek — render surface', () => {
|
||||
it('renders a canvas element', () => {
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
expect(container.querySelector('canvas')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('canvas cursor is "default" when trackId is undefined (no track loaded)', () => {
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId={undefined} />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
expect(canvas.style.cursor).toBe('default');
|
||||
});
|
||||
|
||||
it('canvas cursor is "pointer" when a trackId is present (seekable)', () => {
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
expect(canvas.style.cursor).toBe('pointer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveformSeek — guards before seek', () => {
|
||||
it('does not call seek when wheeled without a trackId', () => {
|
||||
const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId={undefined} />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(seekSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call seek when wheeled without a current track in the store (duration = 0)', () => {
|
||||
const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
|
||||
// trackId is set but the store currentTrack is null → duration = 0 → guard fires.
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(seekSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveformSeek — wheel-to-seek wiring', () => {
|
||||
it('commits the seek through a 350 ms trailing debounce', () => {
|
||||
const track = makeTrack({ id: 't1', duration: 200 });
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
|
||||
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
|
||||
fireEvent.wheel(canvas, { deltaY: -120 });
|
||||
// Seek not yet committed — still inside the 350 ms debounce.
|
||||
expect(seekSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(seekSpy).toHaveBeenCalledTimes(1);
|
||||
const fraction = seekSpy.mock.calls[0]?.[0] as number;
|
||||
expect(fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(fraction).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('coalesces rapid wheel events when each fires within the debounce window', () => {
|
||||
const track = makeTrack({ id: 't1', duration: 200 });
|
||||
usePlayerStore.setState({ currentTrack: track });
|
||||
const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
|
||||
|
||||
const { container } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
|
||||
|
||||
// Three wheels in quick succession (all within 350 ms of each other).
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
// Far fewer commits than wheel events — coalescing reduces engine load.
|
||||
expect(seekSpy.mock.calls.length).toBeLessThan(3);
|
||||
expect(seekSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveformSeek — listener lifecycle', () => {
|
||||
it('mount + unmount completes without throwing', () => {
|
||||
const { unmount } = renderWithProviders(<WaveformSeek trackId="t1" />);
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { SeekbarStyle } from '@/store/authStoreTypes';
|
||||
import { makeAnimState, makeHeights } from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
|
||||
|
||||
interface Props {
|
||||
style: SeekbarStyle;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Animated preview tile for Settings — drives drawSeekbar via a rAF loop and
|
||||
* parks itself when the window/tab is hidden. */
|
||||
export function SeekbarPreview({ style, label, selected, onClick }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
let heights: Float32Array | null = null;
|
||||
if (style === 'truewave' || style === 'pseudowave') {
|
||||
heights = makeHeights('seekbar-preview-demo');
|
||||
}
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
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;
|
||||
}
|
||||
t += 0.016;
|
||||
animState.time = t;
|
||||
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
|
||||
const buffered = Math.min(1, progress + 0.18);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered, animState);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => stop();
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--bg-app))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
alignItems: 'stretch',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: 24, display: 'block' }}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
fontWeight: selected ? 600 : 400,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import type { SeekbarStyle } from '@/store/authStoreTypes';
|
||||
import type { AnimState } from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
import {
|
||||
ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS,
|
||||
binsToHeights, easeOutCubic, heightsNearlyEqual,
|
||||
makeFlatWaveHeights, makeHeights,
|
||||
} from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
import { drawSeekbar } from '@/features/waveform/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,122 @@
|
||||
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 '@/features/waveform/utils/waveformSeekHelpers';
|
||||
import { drawSeekbar } from '@/features/waveform/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.buffering || snap.currentTime < 0.005 ? 0 : 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 snap = getPlaybackProgressSnapshot();
|
||||
if (snap.buffering || snap.currentTime < 0.005) {
|
||||
progressRef.current = 0;
|
||||
visualTargetProgressRef.current = 0;
|
||||
visualProgressRef.current = 0;
|
||||
progressAnchorRef.current = { progress: 0, atMs: now };
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Waveform seekbar feature — the canvas seekbar, its waveform-bin loading, and
|
||||
* the per-track refresh generation guard. Co-located mirror of `cover/` /
|
||||
* `music-network/`. `waveformSilence` stays in `utils/waveform/` (crossfade /
|
||||
* auto-dj concern, not seekbar rendering).
|
||||
*/
|
||||
export { default as WaveformSeek } from './components/WaveformSeek';
|
||||
export { SeekbarPreview } from './components/WaveformSeekPreview';
|
||||
export { fetchWaveformBins, refreshWaveformForTrack } from './store/waveformRefresh';
|
||||
export type { WaveformCachePayload } from './store/waveformRefresh';
|
||||
export { bumpWaveformRefreshGen, getWaveformRefreshGen } from './store/waveformRefreshGen';
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `refreshWaveformForTrack` fetches an analysis row from Rust and applies
|
||||
* it to the player store — but only if the refresh generation hasn't been
|
||||
* bumped meanwhile and the track is still current. The tests pin both
|
||||
* guards and the success / null-row / empty-bins / error branches.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
|
||||
coerceWaveformBinsMock: vi.fn((bins: unknown) => {
|
||||
if (bins == null) return null;
|
||||
if (Array.isArray(bins) && bins.length === 0) return null;
|
||||
return bins as number[];
|
||||
}),
|
||||
playerSnapshot: {
|
||||
currentTrack: null as { id: string } | null,
|
||||
},
|
||||
playerSetStateMock: vi.fn(),
|
||||
gen: 0,
|
||||
getGenMock: vi.fn(() => hoisted.gen),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('@/features/waveform/utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
|
||||
vi.mock('@/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerSnapshot,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/features/waveform/store/waveformRefreshGen', () => ({
|
||||
getWaveformRefreshGen: hoisted.getGenMock,
|
||||
}));
|
||||
|
||||
import { refreshWaveformForTrack } from '@/features/waveform/store/waveformRefresh';
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(null);
|
||||
hoisted.coerceWaveformBinsMock.mockClear();
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.playerSnapshot.currentTrack = null;
|
||||
hoisted.gen = 0;
|
||||
});
|
||||
|
||||
describe('refreshWaveformForTrack', () => {
|
||||
it('is a no-op for empty trackId', async () => {
|
||||
await refreshWaveformForTrack('');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards results when the gen has been bumped since the call started', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockImplementationOnce(async () => {
|
||||
hoisted.gen = 99; // simulate concurrent bump
|
||||
return { bins: [1, 2, 3] };
|
||||
});
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when the track is no longer current after the fetch', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] });
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blanks bins when the row is null', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' });
|
||||
hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('applies the coerced bins on a clean fetch', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] });
|
||||
hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] });
|
||||
});
|
||||
|
||||
it('swallows fetch errors silently (placeholder waveform stays)', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined();
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { coerceWaveformBins } from '@/features/waveform/utils/waveformParse';
|
||||
import { getPlaybackIndexKey } from '@/utils/playback/playbackServer';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { getWaveformRefreshGen } from '@/features/waveform/store/waveformRefreshGen';
|
||||
|
||||
/** Subsonic-server waveform-cache row as Rust hands it back. */
|
||||
export type WaveformCachePayload = {
|
||||
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
|
||||
bins: number[] | Uint8Array;
|
||||
binCount: number;
|
||||
isPartial: boolean;
|
||||
knownUntilSec: number;
|
||||
durationSec: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the cached waveform row for `trackId` from Rust and apply its bins
|
||||
* to the player store — but only if (a) the refresh generation snapshot
|
||||
* still matches (no newer invalidation has fired meanwhile) and (b) the
|
||||
* track is still the current one. Best-effort: any failure leaves the
|
||||
* seekbar with the placeholder waveform.
|
||||
*/
|
||||
/**
|
||||
* Fetch a track's cached waveform bins **without touching the player store** —
|
||||
* used by the silence-aware crossfade to inspect the *next* track's leading
|
||||
* silence while a different track is still playing (writing `waveformBins` here
|
||||
* would replace the current track's seekbar). Returns `null` on a cold miss /
|
||||
* any failure so callers degrade to no-trim.
|
||||
*/
|
||||
export async function fetchWaveformBins(
|
||||
trackId: string,
|
||||
serverId?: string | null,
|
||||
): Promise<number[] | null> {
|
||||
if (!trackId) return null;
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
|
||||
trackId,
|
||||
serverId: serverId ?? getPlaybackIndexKey() ?? null,
|
||||
});
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
return bins && bins.length > 0 ? bins : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
|
||||
if (!trackId) return;
|
||||
const gen = getWaveformRefreshGen(trackId);
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
|
||||
trackId,
|
||||
serverId: getPlaybackIndexKey() || null,
|
||||
});
|
||||
if (getWaveformRefreshGen(trackId) !== gen) return;
|
||||
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
||||
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
if (!bins || bins.length === 0) {
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
waveformBins: bins,
|
||||
});
|
||||
} catch {
|
||||
// best-effort; seekbar falls back to placeholder waveform
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
_resetWaveformRefreshGenForTest,
|
||||
bumpWaveformRefreshGen,
|
||||
getWaveformRefreshGen,
|
||||
} from '@/features/waveform/store/waveformRefreshGen';
|
||||
|
||||
afterEach(() => {
|
||||
_resetWaveformRefreshGenForTest();
|
||||
});
|
||||
|
||||
describe('waveformRefreshGen', () => {
|
||||
it('returns 0 for an unknown track', () => {
|
||||
expect(getWaveformRefreshGen('missing')).toBe(0);
|
||||
});
|
||||
|
||||
it('increments the per-track generation on each bump', () => {
|
||||
bumpWaveformRefreshGen('t1');
|
||||
expect(getWaveformRefreshGen('t1')).toBe(1);
|
||||
bumpWaveformRefreshGen('t1');
|
||||
expect(getWaveformRefreshGen('t1')).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps tracks independent', () => {
|
||||
bumpWaveformRefreshGen('a');
|
||||
bumpWaveformRefreshGen('a');
|
||||
bumpWaveformRefreshGen('b');
|
||||
expect(getWaveformRefreshGen('a')).toBe(2);
|
||||
expect(getWaveformRefreshGen('b')).toBe(1);
|
||||
});
|
||||
|
||||
it('is a no-op for an empty trackId', () => {
|
||||
bumpWaveformRefreshGen('');
|
||||
expect(getWaveformRefreshGen('')).toBe(0);
|
||||
});
|
||||
|
||||
it('captures the stale-result guard pattern: a snapshot is invalidated by a later bump', () => {
|
||||
bumpWaveformRefreshGen('t1');
|
||||
const snapshot = getWaveformRefreshGen('t1');
|
||||
expect(snapshot).toBe(1);
|
||||
bumpWaveformRefreshGen('t1');
|
||||
expect(getWaveformRefreshGen('t1')).not.toBe(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Last-write-wins generation counter per track. Avoids applying a stale
|
||||
* empty waveform read when `analysis:waveform-updated` bumps the gen after
|
||||
* SQLite commit while an older `analysis_get_waveform_for_track` is still
|
||||
* in flight. Gen is bumped only on explicit invalidation (waveform-updated,
|
||||
* analysis storage), not on every `refreshWaveformForTrack` call —
|
||||
* otherwise bursts (Lucky Mix, queue) cancel each other.
|
||||
*
|
||||
* Typical usage:
|
||||
*
|
||||
* const gen = getWaveformRefreshGen(trackId);
|
||||
* const row = await invoke('analysis_get_waveform_for_track', { trackId });
|
||||
* if (getWaveformRefreshGen(trackId) !== gen) return; // stale result
|
||||
*/
|
||||
|
||||
const waveformRefreshGenByTrackId: Record<string, number> = {};
|
||||
|
||||
export function bumpWaveformRefreshGen(trackId: string): void {
|
||||
if (!trackId) return;
|
||||
waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
export function getWaveformRefreshGen(trackId: string): number {
|
||||
return waveformRefreshGenByTrackId[trackId] ?? 0;
|
||||
}
|
||||
|
||||
/** Test-only: wipe the per-track generations so each spec starts fresh. */
|
||||
export function _resetWaveformRefreshGenForTest(): void {
|
||||
for (const k of Object.keys(waveformRefreshGenByTrackId)) {
|
||||
delete waveformRefreshGenByTrackId[k];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceWaveformBins, waveformBlobLenOk } from '@/features/waveform/utils/waveformParse';
|
||||
|
||||
describe('waveformBlobLenOk', () => {
|
||||
it('accepts the legacy single-curve length (500)', () => {
|
||||
expect(waveformBlobLenOk(500)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the v4 dual-curve length (1000)', () => {
|
||||
expect(waveformBlobLenOk(1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects every other length', () => {
|
||||
expect(waveformBlobLenOk(0)).toBe(false);
|
||||
expect(waveformBlobLenOk(499)).toBe(false);
|
||||
expect(waveformBlobLenOk(501)).toBe(false);
|
||||
expect(waveformBlobLenOk(999)).toBe(false);
|
||||
expect(waveformBlobLenOk(1001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceWaveformBins', () => {
|
||||
it('passes a 500-length number[] through with byte-mask', () => {
|
||||
const input = new Array(500).fill(0).map((_, i) => i);
|
||||
const out = coerceWaveformBins(input);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.length).toBe(500);
|
||||
expect(out![0]).toBe(0);
|
||||
expect(out![255]).toBe(255);
|
||||
// index 256 wraps to 0 because of the & 255 mask
|
||||
expect(out![256]).toBe(0);
|
||||
});
|
||||
|
||||
it('passes a 1000-length Uint8Array through unchanged', () => {
|
||||
const u8 = new Uint8Array(1000);
|
||||
u8[42] = 200;
|
||||
const out = coerceWaveformBins(u8);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.length).toBe(1000);
|
||||
expect(out![42]).toBe(200);
|
||||
});
|
||||
|
||||
it('coerces a generic ArrayLike (Tauri serializes Vec<u8> as object)', () => {
|
||||
// Fill remaining slots with zeros to match expected shape
|
||||
const proxy: ArrayLike<number> = {
|
||||
length: 500,
|
||||
...Object.fromEntries(new Array(500).fill(0).map((_, i) => [i, i === 0 ? 10 : i === 1 ? 20 : 0])),
|
||||
} as ArrayLike<number>;
|
||||
const out = coerceWaveformBins(proxy);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out![0]).toBe(10);
|
||||
expect(out![1]).toBe(20);
|
||||
expect(out![2]).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null for null / undefined', () => {
|
||||
expect(coerceWaveformBins(null)).toBeNull();
|
||||
expect(coerceWaveformBins(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty arrays', () => {
|
||||
expect(coerceWaveformBins([])).toBeNull();
|
||||
expect(coerceWaveformBins(new Uint8Array(0))).toBeNull();
|
||||
expect(coerceWaveformBins({ length: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when length is not 500 or 1000', () => {
|
||||
expect(coerceWaveformBins(new Array(750).fill(0))).toBeNull();
|
||||
expect(coerceWaveformBins(new Uint8Array(123))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unsupported shapes (string, plain object without length)', () => {
|
||||
expect(coerceWaveformBins('hello')).toBeNull();
|
||||
expect(coerceWaveformBins({ a: 1 })).toBeNull();
|
||||
expect(coerceWaveformBins(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Parse the waveform-bin payload Rust hands us. Two on-disk shapes survive:
|
||||
* the v4 dual-curve (500 bytes peak + 500 bytes mean-abs = 1000 total) and
|
||||
* the legacy single curve (500 bytes, treated as both peak and mean).
|
||||
*
|
||||
* `bins` may arrive as a real `number[]`, a `Uint8Array`, or any other
|
||||
* `ArrayLike<number>` depending on Tauri's serialization path — coerce to a
|
||||
* plain `number[]` clamped to a single byte, or return null when the shape
|
||||
* doesn't match an accepted curve length.
|
||||
*/
|
||||
export function waveformBlobLenOk(len: number): boolean {
|
||||
return len === 500 || len === 1000;
|
||||
}
|
||||
|
||||
export function coerceWaveformBins(bins: unknown): number[] | null {
|
||||
if (bins == null) return null;
|
||||
let raw: number[];
|
||||
if (Array.isArray(bins)) {
|
||||
if (bins.length === 0) return null;
|
||||
raw = bins.map(x => Number(x) & 255);
|
||||
} else if (bins instanceof Uint8Array) {
|
||||
if (bins.length === 0) return null;
|
||||
raw = Array.from(bins);
|
||||
} else if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
|
||||
const len = (bins as { length: number }).length;
|
||||
if (len === 0) return null;
|
||||
try {
|
||||
raw = Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!waveformBlobLenOk(raw.length)) return null;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { SeekbarStyle } from '@/store/authStoreTypes';
|
||||
|
||||
export function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const BAR_COUNT = 500;
|
||||
/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */
|
||||
export const WAVE_BIN_COUNT = 500;
|
||||
/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */
|
||||
export const WAVE_MIX_MEAN = 0.7;
|
||||
export const WAVE_MIX_MAX = 0.3;
|
||||
export const SEG_COUNT = 60;
|
||||
export const FLAT_WAVE_NORM = 0.06;
|
||||
export const WAVE_MORPH_MS = 1000;
|
||||
export const STATIC_REDRAW_MIN_MS = 90;
|
||||
export const STATIC_REDRAW_FORCE_MS = 220;
|
||||
export const INTERPOLATION_PAINT_MIN_MS = 80;
|
||||
|
||||
export type Particle = {
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
life: number; maxLife: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AnimState = {
|
||||
particles: Particle[];
|
||||
time: number;
|
||||
lastProgress: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
export function makeAnimState(): AnimState {
|
||||
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
|
||||
}
|
||||
|
||||
export const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
|
||||
|
||||
export type SeekbarColors = {
|
||||
played: string;
|
||||
buffered: string;
|
||||
unplayed: string;
|
||||
};
|
||||
|
||||
let cachedColors: SeekbarColors | null = null;
|
||||
let cachedColorsKey = '';
|
||||
|
||||
export function invalidateColorCache() {
|
||||
cachedColors = null;
|
||||
}
|
||||
|
||||
export function getColors(): SeekbarColors {
|
||||
const root = document.documentElement;
|
||||
const style = root.style;
|
||||
const key = [
|
||||
root.getAttribute('data-theme') ?? '',
|
||||
style.getPropertyValue('--accent'),
|
||||
style.getPropertyValue('--waveform-played'),
|
||||
style.getPropertyValue('--waveform-buffered'),
|
||||
style.getPropertyValue('--waveform-unplayed'),
|
||||
].join('|');
|
||||
if (cachedColors && cachedColorsKey === key) return cachedColors;
|
||||
const s = getComputedStyle(root);
|
||||
cachedColorsKey = key;
|
||||
cachedColors = {
|
||||
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
|
||||
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
|
||||
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
|
||||
};
|
||||
return cachedColors;
|
||||
}
|
||||
|
||||
export function setupCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
|
||||
const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
|
||||
if (w === 0 || h === 0) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
return { ctx, w, h };
|
||||
}
|
||||
|
||||
export function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
|
||||
ctx.shadowBlur = Math.max(0, blur);
|
||||
}
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h ^ str.charCodeAt(i)) >>> 0;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
return h;
|
||||
}
|
||||
|
||||
export function makeFlatWaveHeights(): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
h.fill(FLAT_WAVE_NORM);
|
||||
return h;
|
||||
}
|
||||
|
||||
export function easeOutCubic(t: number): number {
|
||||
const x = Math.max(0, Math.min(1, t));
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
export function binsToHeights(src: number[]): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
const n = src.length;
|
||||
if (n === WAVE_BIN_COUNT * 2) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
|
||||
const maxNorm = Number(src[idx]) / 255;
|
||||
const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255;
|
||||
const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm;
|
||||
h[i] = Math.max(0.08, Math.min(1, v));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
if (n === WAVE_BIN_COUNT) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
|
||||
const v = src[idx];
|
||||
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n));
|
||||
const v = src[idx];
|
||||
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (Math.abs(a[i] - b[i]) > eps) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function waveformBarThickness(logicalH: number, norm: number): number {
|
||||
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
|
||||
return Math.max(1, safeNorm * logicalH);
|
||||
}
|
||||
|
||||
export function quantizeProgressByBars(progress: number): number {
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT));
|
||||
}
|
||||
|
||||
export function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean {
|
||||
return style === 'truewave' || style === 'pseudowave';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SeekbarStyle } from '@/store/authStoreTypes';
|
||||
import { bumpPerfCounter } from '@/utils/perf/perfTelemetry';
|
||||
import { AnimState, makeAnimState } from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
import {
|
||||
drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform,
|
||||
} from '@/features/waveform/utils/waveformSeekRenderersStatic';
|
||||
import {
|
||||
drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape,
|
||||
} from '@/features/waveform/utils/waveformSeekRenderersAnimated';
|
||||
|
||||
export function drawSeekbar(
|
||||
canvas: HTMLCanvasElement,
|
||||
style: SeekbarStyle,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState?: AnimState,
|
||||
) {
|
||||
bumpPerfCounter('waveformDraws');
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
case 'segmented': drawSegmented(canvas, progress, buffered); break;
|
||||
case 'neon': drawNeon(canvas, progress, buffered); break;
|
||||
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
|
||||
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
|
||||
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
|
||||
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
|
||||
// Safety net: if a legacy or tampered persisted style sneaks past the
|
||||
// authStore migration, fall back to the truewave renderer instead of
|
||||
// leaving a blank canvas.
|
||||
default: drawWaveform(canvas, heights, progress, buffered); break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { AnimState, getColors, setShadowBlur, setupCanvas } from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
|
||||
export function drawPulseWave(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
const t = animState.time;
|
||||
|
||||
// Base line
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Animated pulse centered at playhead
|
||||
const pulseR = Math.min(38, w * 0.13);
|
||||
const amp = Math.min(h * 0.42, 5.5);
|
||||
const sigma = pulseR * 0.42;
|
||||
const startX = Math.max(0, px - pulseR);
|
||||
const endX = Math.min(w, px + pulseR);
|
||||
|
||||
// Flat played line up to where the wave envelope starts
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 3);
|
||||
ctx.fillRect(0, cy - 1, startX, 2);
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, cy);
|
||||
for (let x = startX; x <= endX; x += 0.75) {
|
||||
const dx = x - px;
|
||||
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
|
||||
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
|
||||
ctx.lineTo(x, cy - wave);
|
||||
}
|
||||
ctx.stroke();
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawParticleTrail(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
|
||||
// Spawn particles at playhead based on movement
|
||||
const prevPx = animState.lastProgress * w;
|
||||
const moved = Math.abs(px - prevPx);
|
||||
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
|
||||
for (let i = 0; i < spawnN; i++) {
|
||||
animState.particles.push({
|
||||
x: px + (Math.random() - 0.5) * 3,
|
||||
y: cy + (Math.random() - 0.5) * (h * 0.55),
|
||||
vx: -(Math.random() * 1.0 + 0.3),
|
||||
vy: (Math.random() - 0.5) * 0.6,
|
||||
life: 1,
|
||||
maxLife: 25 + Math.random() * 35,
|
||||
size: Math.random() * 1.8 + 0.8,
|
||||
});
|
||||
}
|
||||
animState.lastProgress = progress;
|
||||
|
||||
// Update + cull
|
||||
for (const p of animState.particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy *= 0.97;
|
||||
p.life -= 1 / p.maxLife;
|
||||
}
|
||||
animState.particles = animState.particles.filter(p => p.life > 0);
|
||||
if (animState.particles.length > 180) {
|
||||
animState.particles = animState.particles.slice(-180);
|
||||
}
|
||||
|
||||
// Background line
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
// Particles
|
||||
ctx.shadowColor = played;
|
||||
for (const p of animState.particles) {
|
||||
ctx.globalAlpha = p.life * 0.85;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Playhead dot
|
||||
if (progress > 0) {
|
||||
const dx = Math.max(5, Math.min(w - 5, px));
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 10);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawLiquidFill(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const t = animState.time;
|
||||
|
||||
const tubeH = Math.min(13, Math.max(6, h * 0.62));
|
||||
const tubeR = tubeH / 2;
|
||||
const y0 = (h - tubeH) / 2;
|
||||
const y1 = y0 + tubeH;
|
||||
|
||||
// Glass tube background
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, y0, buffered * w, tubeH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
const px = progress * w;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
|
||||
// Liquid body with animated wave on top surface
|
||||
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
|
||||
const waveAmp = Math.min(2.0, tubeH * 0.14);
|
||||
const waveFreq = 0.09;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-1, y1 + 1);
|
||||
ctx.lineTo(-1, surfaceY);
|
||||
|
||||
for (let x = 0; x <= px + 1; x += 1) {
|
||||
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
|
||||
ctx.lineTo(x, surfaceY + wave);
|
||||
}
|
||||
ctx.lineTo(px + 1, y1 + 1);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 9);
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Glass highlight on top
|
||||
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
|
||||
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
|
||||
hl.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillStyle = hl;
|
||||
ctx.fillRect(0, y0, px, tubeH * 0.45);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Tube outline (on top)
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawRetroTape(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
animState.angle += 0.055;
|
||||
|
||||
const reelR = Math.min(h / 2 - 0.5, 9);
|
||||
// Map progress to a center x that keeps the reel fully within the canvas
|
||||
const px = reelR + (w - 2 * reelR) * progress;
|
||||
|
||||
// Background track
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played portion — up to the left edge of the reel
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 1, px - reelR, 2);
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
// Spinning reel at playhead
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 7);
|
||||
|
||||
// Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, reelR * 0.28);
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spokes
|
||||
if (reelR > hubR + 2) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = played;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = animState.angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── dispatcher ────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
BAR_COUNT, SEG_COUNT,
|
||||
getColors, setShadowBlur, setupCanvas, waveformBarThickness,
|
||||
} from '@/features/waveform/utils/waveformSeekHelpers';
|
||||
|
||||
export function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const pNorm = Math.max(0, Math.min(1, progress));
|
||||
const bNorm = Math.max(pNorm, Math.min(1, buffered));
|
||||
|
||||
if (!heights) {
|
||||
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
|
||||
// before played/buffered — otherwise there is no visible playhead.
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
|
||||
}
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (w > 0) {
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w));
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < bNorm) continue;
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < pNorm || frac >= bNorm) continue;
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
if (pNorm > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= pNorm) break;
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
|
||||
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = 4;
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = Math.min(14, h);
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 10);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const gap = 2;
|
||||
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
|
||||
const segH = h * 0.65;
|
||||
const y = (h - segH) / 2;
|
||||
const playedIdx = Math.floor(progress * SEG_COUNT);
|
||||
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const frac = i / SEG_COUNT;
|
||||
const x = i * (segW + gap);
|
||||
setShadowBlur(ctx, 0);
|
||||
if (frac < progress) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
if (i === playedIdx - 1) {
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 5);
|
||||
}
|
||||
} else if (frac < buffered) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
} else {
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── new styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
// Ghost track — barely visible
|
||||
ctx.globalAlpha = 0.07;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
if (progress <= 0) return;
|
||||
|
||||
const px = progress * w;
|
||||
|
||||
// Wide outer glow
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 22);
|
||||
ctx.fillRect(0, cy - 5, px, 10);
|
||||
|
||||
// Mid glow
|
||||
ctx.globalAlpha = 0.45;
|
||||
setShadowBlur(ctx, 12);
|
||||
ctx.fillRect(0, cy - 2.5, px, 5);
|
||||
|
||||
// Inner glow
|
||||
ctx.globalAlpha = 0.85;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillRect(0, cy - 1.5, px, 3);
|
||||
|
||||
// Bright white core
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = played;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 0.75, px, 1.5);
|
||||
|
||||
// End-cap flare
|
||||
setShadowBlur(ctx, 16);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user