refactor(waveform): co-locate seekbar feature into features/waveform

This commit is contained in:
Psychotoxical
2026-06-29 22:30:53 +02:00
parent d9f8d01be9
commit 6d7464fddf
31 changed files with 66 additions and 55 deletions
@@ -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 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => {
progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
};
pendingSeekRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
}
};
const commitSeek = () => {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
seekRef.current(fraction);
};
useEffect(() => {
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackIdRef.current) return;
const rect = canvas.getBoundingClientRect();
previewFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
commitSeek();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
return (
<div className="waveform-seek-container" style={{ position: 'relative', width: '100%' }}>
{hoverPct !== null && duration > 0 && (
<span
className="player-volume-pct"
style={{ left: `${hoverPct * 100}%` }}
>
{fmt(hoverPct * duration)}
</span>
)}
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onWheel={e => {
if (!trackIdRef.current || duration <= 0 || isDragging.current) return;
e.preventDefault();
const wheelSteps = Math.max(1, Math.round(Math.abs(e.deltaY) / 100));
if (wheelSteps <= 0) return;
const now = Date.now();
const currentSeconds = progressRef.current * duration;
const deltaSeconds = (e.deltaY > 0 ? -1 : 1) * WHEEL_SEEK_STEP_SECONDS * wheelSteps;
const nextSeconds = Math.max(0, Math.min(duration, currentSeconds + deltaSeconds));
const nextFraction = Math.max(0, Math.min(1, nextSeconds / duration));
// Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewFractionRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextFraction, bufferedRef.current);
}
// Trailing debounce: commit seek only after wheel activity settles.
queuedWheelSeekFractionRef.current = nextFraction;
if (wheelSeekTimerRef.current != null) {
window.clearTimeout(wheelSeekTimerRef.current);
}
wheelSeekTimerRef.current = window.setTimeout(() => {
wheelSeekTimerRef.current = null;
const queuedFraction = queuedWheelSeekFractionRef.current;
queuedWheelSeekFractionRef.current = null;
if (queuedFraction == null) return;
wheelPreviewFractionRef.current = null;
wheelPreviewUntilRef.current = 0;
pendingCommittedSeekRef.current = { fraction: queuedFraction, setAtMs: Date.now() };
seekRef.current(queuedFraction);
}, WHEEL_SEEK_DEBOUNCE_MS);
}}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
previewFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
const rect = e.currentTarget.getBoundingClientRect();
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseLeave={() => setHoverPct(null)}
/>
</div>
);
}
@@ -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]);
}
+11
View File
@@ -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;
}