Files
psysonic/src/components/WaveformSeek.tsx
T
Frank Stellmacher b97b6c545b fix(windows): tighten WebView2 idle hooks from #273 review (#276)
- Type `window.__psyHidden` once via global declaration; drop 6 `(window as any)` casts.
- `WindowVisibilityProvider` now ORs `window.__psyHidden` into the hidden state
  (and into the initial state). On WebView2 `document.hidden` does not always
  flip when `win.hide()` is called, so effects gated by `useWindowVisibility()`
  can now actually skip creating intervals/rAF instead of relying solely on the
  per-tick fallback check.
- Slow visible-poll from 200 ms to 500 ms (5 → 2 wakeups/s for visibility alone).
- Fix stray leading-space indent in `App.tsx` `window:close-requested` comment.

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-23 22:50:36 +02:00

1015 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
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')}`;
}
const BAR_COUNT = 500;
const SEG_COUNT = 60;
// ── animation state ───────────────────────────────────────────────────────────
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 };
}
const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
// ── color helper ──────────────────────────────────────────────────────────────
function getColors() {
const s = getComputedStyle(document.documentElement);
return {
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',
};
}
// ── canvas setup ──────────────────────────────────────────────────────────────
function setupCanvas(
canvas: HTMLCanvasElement,
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
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 };
}
// ── waveform heights ──────────────────────────────────────────────────────────
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;
}
// ── draw functions ────────────────────────────────────────────────────────────
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();
if (!heights) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2);
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 < buffered) continue;
const bh = Math.max(1, heights[i] * h);
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 < progress || frac >= buffered) continue;
const bh = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
const bh = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
// Fade both edges to transparent using destination-in gradient mask
const fadeW = Math.min(22, w * 0.07);
const mask = ctx.createLinearGradient(0, 0, w, 0);
mask.addColorStop(0, 'transparent');
mask.addColorStop(fadeW / w, 'black');
mask.addColorStop(1 - fadeW / w, 'black');
mask.addColorStop(1, 'transparent');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = mask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
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;
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
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;
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
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;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
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);
ctx.shadowBlur = 0;
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
ctx.shadowBlur = 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();
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
// ── new styles ────────────────────────────────────────────────────────────────
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;
ctx.shadowBlur = 22;
ctx.fillRect(0, cy - 5, px, 10);
// Mid glow
ctx.globalAlpha = 0.45;
ctx.shadowBlur = 12;
ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow
ctx.globalAlpha = 0.85;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = played;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare
ctx.shadowBlur = 16;
ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
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;
ctx.shadowBlur = 3;
ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1.5;
ctx.shadowColor = played;
ctx.shadowBlur = 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();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
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;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 1, px, 2);
ctx.shadowBlur = 0;
}
// Particles
ctx.shadowColor = played;
for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85;
ctx.shadowBlur = 5;
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 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;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
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;
ctx.shadowBlur = 9;
ctx.fill();
ctx.shadowBlur = 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;
}
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;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 1, px - reelR, 2);
ctx.shadowBlur = 0;
}
// Spinning reel at playhead
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
// Outer ring
ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 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();
}
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
// ── dispatcher ────────────────────────────────────────────────────────────────
export function drawSeekbar(
canvas: HTMLCanvasElement,
style: SeekbarStyle,
heights: Float32Array | null,
progress: number,
buffered: number,
animState?: AnimState,
) {
const anim = animState ?? makeAnimState();
switch (style) {
case 'waveform': 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;
}
}
// ── SeekbarPreview (animated, for Settings) ───────────────────────────────────
export function SeekbarPreview({
style,
label,
selected,
onClick,
}: {
style: SeekbarStyle;
label: string;
selected: boolean;
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let heights: Float32Array | null = null;
if (style === 'waveform') {
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>
);
}
// ── 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;
}
export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_GUARD_MS = 900;
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
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);
styleRef.current = seekbarStyle;
useEffect(() => {
if (!trackId) {
heightsRef.current = null;
return;
}
heightsRef.current = makeHeights(trackId);
}, [trackId]);
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
useEffect(() => {
return usePlayerStore.subscribe((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 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;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
}
});
}, []);
// Initial draw for static styles when style or track 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]);
// 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]);
// 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(() => {
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, [seekbarStyle]);
const trackIdRef = useRef(trackId);
trackIdRef.current = trackId;
const seekRef = useRef(seek);
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
// 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;
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;
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 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' }}
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>
);
}