mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(waveform-seek): H3 — extract renderers + 2 hooks + SeekbarPreview component (#665)
* refactor(waveform-seek): H3.1 — extract helpers + constants + types * refactor(waveform-seek): H3.2 — extract drawSeekbar + style renderers to utils/waveformSeekRenderers.ts * refactor(waveform-seek): H3.3 — split renderers into static + animated * refactor(waveform-seek): H3.4 — extract SeekbarPreview to WaveformSeekPreview.tsx * refactor(waveform-seek): H3.5 — extract useWaveformHeights hook + hoist constants * refactor(waveform-seek): H3.6 — extract useWaveformInterpolation hook
This commit is contained in:
committed by
GitHub
parent
b8a9fe860e
commit
dc5c64a109
+29
-1083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { SeekbarStyle } from '../store/authStoreTypes';
|
||||||
|
import { makeAnimState, makeHeights } from '../utils/waveformSeekHelpers';
|
||||||
|
import { drawSeekbar } from '../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(--ctp-surface1)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: selected
|
||||||
|
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||||
|
: 'var(--bg-card, var(--ctp-base))',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
|||||||
import CustomSelect from '../CustomSelect';
|
import CustomSelect from '../CustomSelect';
|
||||||
import SettingsSubSection from '../SettingsSubSection';
|
import SettingsSubSection from '../SettingsSubSection';
|
||||||
import ThemePicker, { THEME_GROUPS } from '../ThemePicker';
|
import ThemePicker, { THEME_GROUPS } from '../ThemePicker';
|
||||||
import { SeekbarPreview } from '../WaveformSeek';
|
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||||
|
|
||||||
export function AppearanceTab() {
|
export function AppearanceTab() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import type { SeekbarStyle } from '../store/authStoreTypes';
|
||||||
|
import type { AnimState } from '../utils/waveformSeekHelpers';
|
||||||
|
import {
|
||||||
|
ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS,
|
||||||
|
binsToHeights, easeOutCubic, heightsNearlyEqual,
|
||||||
|
makeFlatWaveHeights, makeHeights,
|
||||||
|
} from '../utils/waveformSeekHelpers';
|
||||||
|
import { drawSeekbar } from '../utils/waveformSeekRenderers';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
trackId: string | undefined;
|
||||||
|
waveformBins: number[] | null | undefined;
|
||||||
|
seekbarStyle: SeekbarStyle;
|
||||||
|
heightsRef: React.MutableRefObject<Float32Array | null>;
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||||
|
styleRef: React.MutableRefObject<SeekbarStyle>;
|
||||||
|
progressRef: React.MutableRefObject<number>;
|
||||||
|
bufferedRef: React.MutableRefObject<number>;
|
||||||
|
animStateRef: React.MutableRefObject<AnimState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manages `heightsRef` for the seekbar: bootstraps deterministic bars for
|
||||||
|
* pseudowave, animates a morph between waveform analysis updates, and falls
|
||||||
|
* back to a flat wave when no bins exist yet. */
|
||||||
|
export function useWaveformHeights({
|
||||||
|
trackId, waveformBins, seekbarStyle,
|
||||||
|
heightsRef, canvasRef, styleRef, progressRef, bufferedRef, animStateRef,
|
||||||
|
}: Args) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trackId) {
|
||||||
|
heightsRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pseudowave is the deterministic per-track-ID variant — no analysis needed,
|
||||||
|
// no morph animation, no flat-fallback. It just sits there looking like a
|
||||||
|
// waveform.
|
||||||
|
if (seekbarStyle === 'pseudowave') {
|
||||||
|
heightsRef.current = makeHeights(trackId);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas && !ANIMATED_STYLES.has(seekbarStyle)) {
|
||||||
|
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (waveformBins && waveformBins.length > 0) {
|
||||||
|
const h = binsToHeights(waveformBins);
|
||||||
|
const prev = heightsRef.current;
|
||||||
|
if (!prev || prev.length !== BAR_COUNT) {
|
||||||
|
heightsRef.current = h;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (heightsNearlyEqual(prev, h, 0.02)) {
|
||||||
|
heightsRef.current = h;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = new Float32Array(prev);
|
||||||
|
const to = h;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const step = (now: number) => {
|
||||||
|
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||||
|
const next = new Float32Array(BAR_COUNT);
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||||
|
}
|
||||||
|
heightsRef.current = next;
|
||||||
|
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||||
|
}
|
||||||
|
if (p < 1) raf = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
if (heightsRef.current?.length === BAR_COUNT) {
|
||||||
|
const current = heightsRef.current;
|
||||||
|
let isAlreadyFlat = true;
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
|
||||||
|
isAlreadyFlat = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isAlreadyFlat) return;
|
||||||
|
const from = new Float32Array(current);
|
||||||
|
const to = makeFlatWaveHeights();
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const step = (now: number) => {
|
||||||
|
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||||
|
const next = new Float32Array(BAR_COUNT);
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||||
|
}
|
||||||
|
heightsRef.current = next;
|
||||||
|
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||||
|
}
|
||||||
|
if (p < 1) raf = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
// No analysis bins yet: render 500 flat bars immediately.
|
||||||
|
heightsRef.current = makeFlatWaveHeights();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [trackId, waveformBins, seekbarStyle]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import type { SeekbarStyle } from '../store/authStoreTypes';
|
||||||
|
import { getPlaybackProgressSnapshot } from '../store/playbackProgress';
|
||||||
|
import {
|
||||||
|
ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS,
|
||||||
|
isBarQuantizedSeekStyle, quantizeProgressByBars,
|
||||||
|
} from '../utils/waveformSeekHelpers';
|
||||||
|
import { drawSeekbar } from '../utils/waveformSeekRenderers';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
duration: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
previewFreezesMainSeekbar: boolean;
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||||
|
heightsRef: React.MutableRefObject<Float32Array | null>;
|
||||||
|
styleRef: React.MutableRefObject<SeekbarStyle>;
|
||||||
|
progressRef: React.MutableRefObject<number>;
|
||||||
|
bufferedRef: React.MutableRefObject<number>;
|
||||||
|
visualProgressRef: React.MutableRefObject<number>;
|
||||||
|
visualTargetProgressRef: React.MutableRefObject<number>;
|
||||||
|
progressAnchorRef: React.MutableRefObject<{ progress: number; atMs: number }>;
|
||||||
|
animStateRef: React.MutableRefObject<AnimState>;
|
||||||
|
isDraggingRef: React.MutableRefObject<boolean>;
|
||||||
|
wheelPreviewFractionRef: React.MutableRefObject<number | null>;
|
||||||
|
wheelPreviewUntilRef: React.MutableRefObject<number>;
|
||||||
|
pendingCommittedSeekRef: React.MutableRefObject<{ fraction: number; setAtMs: number } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smooth interpolation rAF loop — predicts playhead position between sparse
|
||||||
|
* transport heartbeats, with proper anchor reset on resume. Inactive while
|
||||||
|
* paused, dragging, wheel-preview, or pending-committed-seek. */
|
||||||
|
export function useWaveformInterpolation({
|
||||||
|
duration, isPlaying, previewFreezesMainSeekbar,
|
||||||
|
canvasRef, heightsRef, styleRef,
|
||||||
|
progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef,
|
||||||
|
progressAnchorRef, animStateRef, isDraggingRef,
|
||||||
|
wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef,
|
||||||
|
}: Args) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return;
|
||||||
|
// This effect is torn down while paused, so `progressAnchorRef.atMs` is not refreshed.
|
||||||
|
// On resume the first `tick` would add the entire pause duration to `elapsedSec` and
|
||||||
|
// overshoot the playhead until the next transport heartbeat corrects it.
|
||||||
|
const snap = getPlaybackProgressSnapshot();
|
||||||
|
const raw = snap.progress;
|
||||||
|
progressRef.current = raw;
|
||||||
|
progressAnchorRef.current = {
|
||||||
|
progress: raw,
|
||||||
|
atMs: performance.now(),
|
||||||
|
};
|
||||||
|
const resumeVisual = isBarQuantizedSeekStyle(styleRef.current)
|
||||||
|
? quantizeProgressByBars(raw)
|
||||||
|
: raw;
|
||||||
|
visualTargetProgressRef.current = resumeVisual;
|
||||||
|
visualProgressRef.current = resumeVisual;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let lastPaintAt = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (document.hidden || window.__psyHidden) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDraggingRef.current) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wheelPreviewFraction = wheelPreviewFractionRef.current;
|
||||||
|
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingCommittedSeekRef.current) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchor = progressAnchorRef.current;
|
||||||
|
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
|
||||||
|
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
|
||||||
|
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
|
||||||
|
? quantizeProgressByBars(predicted)
|
||||||
|
: predicted;
|
||||||
|
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
|
||||||
|
visualTargetProgressRef.current = nextTargetProgress;
|
||||||
|
}
|
||||||
|
const currentVisual = visualProgressRef.current;
|
||||||
|
const targetVisual = visualTargetProgressRef.current;
|
||||||
|
const delta = targetVisual - currentVisual;
|
||||||
|
if (Math.abs(delta) > 0.000001) {
|
||||||
|
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
|
||||||
|
const nextVisualProgress = Math.abs(delta) < 0.002
|
||||||
|
? targetVisual
|
||||||
|
: currentVisual + delta * smoothing;
|
||||||
|
visualProgressRef.current = nextVisualProgress;
|
||||||
|
progressRef.current = nextVisualProgress;
|
||||||
|
const needsDirectDraw = !ANIMATED_STYLES.has(styleRef.current);
|
||||||
|
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
|
||||||
|
lastPaintAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafId != null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [duration, isPlaying, previewFreezesMainSeekbar]);
|
||||||
|
}
|
||||||
@@ -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 './perfTelemetry';
|
||||||
|
import { AnimState, makeAnimState } from './waveformSeekHelpers';
|
||||||
|
import {
|
||||||
|
drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform,
|
||||||
|
} from './waveformSeekRenderersStatic';
|
||||||
|
import {
|
||||||
|
drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape,
|
||||||
|
} from './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, BAR_COUNT, getColors, setShadowBlur, setupCanvas } from './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 './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