mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
18b4a982ef
* feat(queue): add ETA display, equalizer indicator and collapsible now playing
* deleted endsAt and showDuration strings, changed eta update to 30s
* feat(queue): ETA tooltip, persistent Now Playing collapse, EQ bar pause, remove redundant Play icon
* feat(queue): fold ETA into existing total/remaining toggle as third mode
The standalone ETA span next to the track counter is removed; instead the
clickable duration label in the queue header now rotates through three
modes per click: total → remaining → eta → total. Counter (N/M) stays
where it was.
ETA mode keeps the live-feel treatment from the original PR (accent
colour while playing, muted at 50% opacity when paused). The other two
modes use plain accent.
i18n: queue.etaTooltip removed (no longer a separate descriptive label),
queue.showEta added as the action tooltip ('Show estimated end time')
in all 8 locales — matches the showRemaining / showTotal pattern.
* docs(changelog): add #419 queue UX improvements entry
Adds the [1.45.0] / Added entry for this PR's queue panel refinements
(position counter, tri-state duration toggle including ETA, collapsible
Now Playing section, animated EQ indicator).
---------
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
1228 lines
38 KiB
TypeScript
1228 lines
38 KiB
TypeScript
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;
|
||
/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */
|
||
const WAVE_BIN_COUNT = 500;
|
||
/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */
|
||
const WAVE_MIX_MEAN = 0.7;
|
||
const WAVE_MIX_MAX = 0.3;
|
||
const SEG_COUNT = 60;
|
||
const FLAT_WAVE_NORM = 0.06;
|
||
const WAVE_MORPH_MS = 1000;
|
||
|
||
// ── 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 makeFlatWaveHeights(): Float32Array {
|
||
const h = new Float32Array(BAR_COUNT);
|
||
h.fill(FLAT_WAVE_NORM);
|
||
return h;
|
||
}
|
||
|
||
function easeOutCubic(t: number): number {
|
||
const x = Math.max(0, Math.min(1, t));
|
||
return 1 - Math.pow(1 - x, 3);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function waveformBarThickness(logicalH: number, norm: number): number {
|
||
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
|
||
return Math.max(1, safeNorm * logicalH);
|
||
}
|
||
|
||
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) {
|
||
// 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;
|
||
ctx.shadowBlur = 5;
|
||
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
if (w > 0) {
|
||
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, 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;
|
||
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 = 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 < progress || frac >= buffered) continue;
|
||
const bh = waveformBarThickness(h, heights[i]);
|
||
const x = x1Of(i);
|
||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||
}
|
||
|
||
if (progress > 0) {
|
||
ctx.globalAlpha = 1;
|
||
ctx.fillStyle = played;
|
||
for (let i = 0; i < BAR_COUNT; i++) {
|
||
if (i / BAR_COUNT >= progress) break;
|
||
const bh = waveformBarThickness(h, heights[i]);
|
||
const x = x1Of(i);
|
||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
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 '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;
|
||
}
|
||
}
|
||
|
||
// ── 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 === '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>
|
||
);
|
||
}
|
||
|
||
// ── 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 WHEEL_SEEK_STEP_SECONDS = 10;
|
||
const WHEEL_SEEK_DEBOUNCE_MS = 1000;
|
||
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 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);
|
||
styleRef.current = seekbarStyle;
|
||
|
||
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();
|
||
}, [trackId, waveformBins, seekbarStyle]);
|
||
|
||
// 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 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;
|
||
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, 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]);
|
||
|
||
// 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);
|
||
const wheelSeekTimerRef = useRef<number | null>(null);
|
||
const queuedWheelSeekFractionRef = useRef<number | null>(null);
|
||
const wheelPreviewFractionRef = useRef<number | null>(null);
|
||
const wheelPreviewUntilRef = useRef(0);
|
||
|
||
useEffect(() => () => {
|
||
if (wheelSeekTimerRef.current != null) {
|
||
window.clearTimeout(wheelSeekTimerRef.current);
|
||
wheelSeekTimerRef.current = null;
|
||
}
|
||
wheelPreviewFractionRef.current = null;
|
||
wheelPreviewUntilRef.current = 0;
|
||
}, []);
|
||
|
||
// Preview a 0–1 fraction while dragging: draw immediately for 1:1
|
||
// responsiveness; the actual seek is committed on mouseup.
|
||
const previewFraction = (fraction: number) => {
|
||
progressRef.current = fraction;
|
||
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' }}
|
||
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;
|
||
wheelPreviewFractionRef.current = nextFraction;
|
||
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>
|
||
);
|
||
}
|