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(['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; // 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; } } // ── SeekbarPreview (animated, for Settings) ─────────────────────────────────── export function SeekbarPreview({ style, label, selected, onClick, }: { style: SeekbarStyle; label: string; selected: boolean; onClick: () => void; }) { const canvasRef = useRef(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 || window.__psyBlurred) { 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 ( ); } // ── 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(null); const heightsRef = useRef(null); const progressRef = useRef(usePlayerStore.getState().progress); const bufferedRef = useRef(usePlayerStore.getState().buffered); const isDragging = useRef(false); const animStateRef = useRef(makeAnimState()); const [hoverPct, setHoverPct] = useState(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); const reducedAnimations = useAuthStore(s => s.reducedAnimations); // 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; const reducedRef = useRef(reducedAnimations); reducedRef.current = reducedAnimations; 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; let skip = false; const stop = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } if (pollId !== null) { window.clearTimeout(pollId); pollId = null; } }; const tick = () => { if (document.hidden || window.__psyHidden || window.__psyBlurred) { pollId = window.setTimeout(() => { pollId = null; tick(); }, 400); return; } // 30 fps cap when reducedAnimations is on: skip every other rAF, advance // animation time by a doubled delta so wave speed stays the same. if (reducedRef.current && skip) { skip = false; rafId = requestAnimationFrame(tick); return; } skip = reducedRef.current; animStateRef.current.time += reducedRef.current ? 0.032 : 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(null); const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null); const wheelSeekTimerRef = useRef(null); const queuedWheelSeekFractionRef = useRef(null); const wheelPreviewFractionRef = useRef(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 (
{hoverPct !== null && duration > 0 && ( {fmt(hoverPct * duration)} )} { 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)} />
); }