diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index dcac74be..355906e2 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -1,931 +1,17 @@ -import type { SeekbarStyle } from '../store/authStoreTypes'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; -import { bumpPerfCounter } from '../utils/perfTelemetry'; -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; -const STATIC_REDRAW_MIN_MS = 90; -const STATIC_REDRAW_FORCE_MS = 220; -const INTERPOLATION_PAINT_MIN_MS = 80; - -// ── 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 ────────────────────────────────────────────────────────────── - -type SeekbarColors = { - played: string; - buffered: string; - unplayed: string; -}; - -let cachedColors: SeekbarColors | null = null; -let cachedColorsKey = ''; - -function invalidateColorCache() { - cachedColors = null; -} - -function getColors(): SeekbarColors { - const root = document.documentElement; - const style = root.style; - const key = [ - root.getAttribute('data-theme') ?? '', - style.getPropertyValue('--accent'), - style.getPropertyValue('--waveform-played'), - style.getPropertyValue('--waveform-buffered'), - style.getPropertyValue('--waveform-unplayed'), - ].join('|'); - if (cachedColors && cachedColorsKey === key) return cachedColors; - const s = getComputedStyle(root); - cachedColorsKey = key; - cachedColors = { - played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7', - buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086', - unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244', - }; - return cachedColors; -} - -// ── canvas setup ────────────────────────────────────────────────────────────── - -function setupCanvas( - canvas: HTMLCanvasElement, -): { ctx: CanvasRenderingContext2D; w: number; h: number } | null { - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - const w = canvas.clientWidth || canvas.getBoundingClientRect().width; - const h = canvas.clientHeight || canvas.getBoundingClientRect().height; - if (w === 0 || h === 0) return null; - const dpr = window.devicePixelRatio || 1; - const pw = Math.round(w * dpr); - const ph = Math.round(h * dpr); - if (canvas.width !== pw || canvas.height !== ph) { - canvas.width = pw; - canvas.height = ph; - } - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - return { ctx, w, h }; -} - -function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) { - ctx.shadowBlur = Math.max(0, blur); -} - -// ── 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 quantizeProgressByBars(progress: number): number { - const clamped = Math.max(0, Math.min(1, progress)); - return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT)); -} - -function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean { - return style === 'truewave' || style === 'pseudowave'; -} - -function drawWaveform( - canvas: HTMLCanvasElement, - heights: Float32Array | null, - progress: number, - buffered: number, -) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const pNorm = Math.max(0, Math.min(1, progress)); - const bNorm = Math.max(pNorm, Math.min(1, buffered)); - - if (!heights) { - // No waveform data yet: flat rail like `drawLineDot`, but do not return early - // before played/buffered — otherwise there is no visible playhead. - const cy = h / 2; - const lh = 2; - const dotR = 5; - ctx.globalAlpha = 0.35; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - lh / 2, w, lh); - if (buffered > 0) { - ctx.globalAlpha = 0.55; - ctx.fillStyle = buffCol; - ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh); - } - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 5); - ctx.fillRect(0, cy - lh / 2, pNorm * w, lh); - setShadowBlur(ctx, 0); - } - ctx.globalAlpha = 1; - if (w > 0) { - const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w)); - ctx.shadowColor = played; - setShadowBlur(ctx, 7); - ctx.beginPath(); - ctx.arc(dx, cy, dotR, 0, Math.PI * 2); - ctx.fillStyle = played; - ctx.fill(); - setShadowBlur(ctx, 0); - } - ctx.globalAlpha = 1; - return; - } - const x1Of = (i: number) => (i / BAR_COUNT) * w; - const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; - ctx.globalAlpha = 0.28; - ctx.fillStyle = unplayed; - for (let i = 0; i < BAR_COUNT; i++) { - if (i / BAR_COUNT < bNorm) continue; - const bh = waveformBarThickness(h, heights[i]); - const x = x1Of(i); - ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); - } - - ctx.globalAlpha = 0.45; - ctx.fillStyle = buffCol; - for (let i = 0; i < BAR_COUNT; i++) { - const frac = i / BAR_COUNT; - if (frac < pNorm || frac >= bNorm) continue; - const bh = waveformBarThickness(h, heights[i]); - const x = x1Of(i); - ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); - } - - if (pNorm > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - for (let i = 0; i < BAR_COUNT; i++) { - if (i / BAR_COUNT >= pNorm) break; - const bh = waveformBarThickness(h, heights[i]); - const x = x1Of(i); - ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); - } - } - ctx.globalAlpha = 1; -} - -function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const cy = h / 2; - const lh = 2; - const dotR = 5; - - ctx.globalAlpha = 0.35; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - lh / 2, w, lh); - - if (buffered > 0) { - ctx.globalAlpha = 0.55; - ctx.fillStyle = buffCol; - ctx.fillRect(0, cy - lh / 2, buffered * w, lh); - } - - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.fillRect(0, cy - lh / 2, progress * w, lh); - - const dx = Math.max(dotR, Math.min(w - dotR, progress * w)); - ctx.shadowColor = played; - setShadowBlur(ctx, 7); - ctx.beginPath(); - ctx.arc(dx, cy, dotR, 0, Math.PI * 2); - ctx.fillStyle = played; - ctx.fill(); - setShadowBlur(ctx, 0); - ctx.globalAlpha = 1; -} - -function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const bh = 4; - const rad = bh / 2; - const y = (h - bh) / 2; - - ctx.globalAlpha = 0.3; - ctx.fillStyle = unplayed; - ctx.beginPath(); - ctx.roundRect(0, y, w, bh, rad); - ctx.fill(); - - if (buffered > 0) { - ctx.globalAlpha = 0.5; - ctx.fillStyle = buffCol; - ctx.beginPath(); - ctx.roundRect(0, y, buffered * w, bh, rad); - ctx.fill(); - } - - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 5); - ctx.beginPath(); - ctx.roundRect(0, y, progress * w, bh, rad); - ctx.fill(); - setShadowBlur(ctx, 0); - } - ctx.globalAlpha = 1; -} - -function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const bh = Math.min(14, h); - const rad = bh / 2; - const y = (h - bh) / 2; - - ctx.globalAlpha = 0.25; - ctx.fillStyle = unplayed; - ctx.beginPath(); - ctx.roundRect(0, y, w, bh, rad); - ctx.fill(); - - if (buffered > 0) { - ctx.globalAlpha = 0.45; - ctx.fillStyle = buffCol; - ctx.beginPath(); - ctx.roundRect(0, y, buffered * w, bh, rad); - ctx.fill(); - } - - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 10); - ctx.beginPath(); - ctx.roundRect(0, y, progress * w, bh, rad); - ctx.fill(); - setShadowBlur(ctx, 0); - } - ctx.globalAlpha = 1; -} - -function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const gap = 2; - const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT; - const segH = h * 0.65; - const y = (h - segH) / 2; - const playedIdx = Math.floor(progress * SEG_COUNT); - - for (let i = 0; i < SEG_COUNT; i++) { - const frac = i / SEG_COUNT; - const x = i * (segW + gap); - setShadowBlur(ctx, 0); - if (frac < progress) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - if (i === playedIdx - 1) { - ctx.shadowColor = played; - setShadowBlur(ctx, 5); - } - } else if (frac < buffered) { - ctx.globalAlpha = 0.55; - ctx.fillStyle = buffCol; - } else { - ctx.globalAlpha = 0.28; - ctx.fillStyle = unplayed; - } - ctx.beginPath(); - ctx.roundRect(x, y, Math.max(1, segW), segH, 1); - ctx.fill(); - } - setShadowBlur(ctx, 0); - ctx.globalAlpha = 1; -} - -// ── new styles ──────────────────────────────────────────────────────────────── - -function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, unplayed } = getColors(); - const cy = h / 2; - - // Ghost track — barely visible - ctx.globalAlpha = 0.07; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - 1, w, 2); - - if (buffered > 0) { - ctx.globalAlpha = 0.12; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - 1, buffered * w, 2); - } - - if (progress <= 0) return; - - const px = progress * w; - - // Wide outer glow - ctx.globalAlpha = 0.18; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 22); - ctx.fillRect(0, cy - 5, px, 10); - - // Mid glow - ctx.globalAlpha = 0.45; - setShadowBlur(ctx, 12); - ctx.fillRect(0, cy - 2.5, px, 5); - - // Inner glow - ctx.globalAlpha = 0.85; - setShadowBlur(ctx, 5); - ctx.fillRect(0, cy - 1.5, px, 3); - - // Bright white core - ctx.globalAlpha = 1; - ctx.fillStyle = '#ffffff'; - ctx.shadowColor = played; - setShadowBlur(ctx, 4); - ctx.fillRect(0, cy - 0.75, px, 1.5); - - // End-cap flare - setShadowBlur(ctx, 16); - ctx.beginPath(); - ctx.arc(px, cy, 2.5, 0, Math.PI * 2); - ctx.fillStyle = '#ffffff'; - ctx.fill(); - - setShadowBlur(ctx, 0); - ctx.globalAlpha = 1; -} - -function drawPulseWave( - canvas: HTMLCanvasElement, - progress: number, - buffered: number, - animState: AnimState, -) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const cy = h / 2; - const px = progress * w; - const t = animState.time; - - // Base line - ctx.globalAlpha = 0.3; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - 1, w, 2); - - if (buffered > 0) { - ctx.globalAlpha = 0.45; - ctx.fillStyle = buffCol; - ctx.fillRect(0, cy - 1, buffered * w, 2); - } - - // Animated pulse centered at playhead - const pulseR = Math.min(38, w * 0.13); - const amp = Math.min(h * 0.42, 5.5); - const sigma = pulseR * 0.42; - const startX = Math.max(0, px - pulseR); - const endX = Math.min(w, px + pulseR); - - // Flat played line up to where the wave envelope starts - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 3); - ctx.fillRect(0, cy - 1, startX, 2); - setShadowBlur(ctx, 0); - } - - ctx.globalAlpha = 1; - ctx.strokeStyle = played; - ctx.lineWidth = 1.5; - ctx.shadowColor = played; - setShadowBlur(ctx, 7); - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - ctx.beginPath(); - ctx.moveTo(startX, cy); - for (let x = startX; x <= endX; x += 0.75) { - const dx = x - px; - const env = Math.exp(-(dx * dx) / (2 * sigma * sigma)); - const wave = env * amp * Math.sin(dx * 0.28 - t * 18); - ctx.lineTo(x, cy - wave); - } - ctx.stroke(); - setShadowBlur(ctx, 0); - ctx.globalAlpha = 1; -} - -function drawParticleTrail( - canvas: HTMLCanvasElement, - progress: number, - buffered: number, - animState: AnimState, -) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const cy = h / 2; - const px = progress * w; - - // Spawn particles at playhead based on movement - const prevPx = animState.lastProgress * w; - const moved = Math.abs(px - prevPx); - const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5)); - for (let i = 0; i < spawnN; i++) { - animState.particles.push({ - x: px + (Math.random() - 0.5) * 3, - y: cy + (Math.random() - 0.5) * (h * 0.55), - vx: -(Math.random() * 1.0 + 0.3), - vy: (Math.random() - 0.5) * 0.6, - life: 1, - maxLife: 25 + Math.random() * 35, - size: Math.random() * 1.8 + 0.8, - }); - } - animState.lastProgress = progress; - - // Update + cull - for (const p of animState.particles) { - p.x += p.vx; - p.y += p.vy; - p.vy *= 0.97; - p.life -= 1 / p.maxLife; - } - animState.particles = animState.particles.filter(p => p.life > 0); - if (animState.particles.length > 180) { - animState.particles = animState.particles.slice(-180); - } - - // Background line - ctx.globalAlpha = 0.28; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - 1, w, 2); - - if (buffered > 0) { - ctx.globalAlpha = 0.45; - ctx.fillStyle = buffCol; - ctx.fillRect(0, cy - 1, buffered * w, 2); - } - - // Played line - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 4); - ctx.fillRect(0, cy - 1, px, 2); - setShadowBlur(ctx, 0); - } - - // Particles - ctx.shadowColor = played; - for (const p of animState.particles) { - ctx.globalAlpha = p.life * 0.85; - setShadowBlur(ctx, 5); - ctx.fillStyle = played; - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); - ctx.fill(); - } - setShadowBlur(ctx, 0); - - // Playhead dot - if (progress > 0) { - const dx = Math.max(5, Math.min(w - 5, px)); - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 10); - ctx.beginPath(); - ctx.arc(dx, cy, 4, 0, Math.PI * 2); - ctx.fill(); - setShadowBlur(ctx, 0); - } - - ctx.globalAlpha = 1; -} - -function drawLiquidFill( - canvas: HTMLCanvasElement, - progress: number, - buffered: number, - animState: AnimState, -) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const t = animState.time; - - const tubeH = Math.min(13, Math.max(6, h * 0.62)); - const tubeR = tubeH / 2; - const y0 = (h - tubeH) / 2; - const y1 = y0 + tubeH; - - // Glass tube background - ctx.globalAlpha = 0.18; - ctx.fillStyle = unplayed; - ctx.beginPath(); - ctx.roundRect(0, y0, w, tubeH, tubeR); - ctx.fill(); - ctx.globalAlpha = 0.3; - ctx.strokeStyle = unplayed; - ctx.lineWidth = 0.8; - ctx.stroke(); - - if (buffered > 0) { - ctx.save(); - ctx.beginPath(); - ctx.roundRect(0, y0, w, tubeH, tubeR); - ctx.clip(); - ctx.globalAlpha = 0.3; - ctx.fillStyle = buffCol; - ctx.fillRect(0, y0, buffered * w, tubeH); - ctx.restore(); - } - - if (progress > 0) { - const px = progress * w; - - ctx.save(); - ctx.beginPath(); - ctx.roundRect(0, y0, w, tubeH, tubeR); - ctx.clip(); - - // Liquid body with animated wave on top surface - const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full - const waveAmp = Math.min(2.0, tubeH * 0.14); - const waveFreq = 0.09; - - ctx.beginPath(); - ctx.moveTo(-1, y1 + 1); - ctx.lineTo(-1, surfaceY); - - for (let x = 0; x <= px + 1; x += 1) { - const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2); - ctx.lineTo(x, surfaceY + wave); - } - ctx.lineTo(px + 1, y1 + 1); - ctx.closePath(); - - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 9); - ctx.fill(); - setShadowBlur(ctx, 0); - - // Glass highlight on top - const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45); - hl.addColorStop(0, 'rgba(255,255,255,0.28)'); - hl.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.globalAlpha = 0.6; - ctx.fillStyle = hl; - ctx.fillRect(0, y0, px, tubeH * 0.45); - - ctx.restore(); - } - - // Tube outline (on top) - ctx.globalAlpha = 0.5; - ctx.strokeStyle = unplayed; - ctx.lineWidth = 0.8; - ctx.beginPath(); - ctx.roundRect(0, y0, w, tubeH, tubeR); - ctx.stroke(); - - ctx.globalAlpha = 1; -} - -function drawRetroTape( - canvas: HTMLCanvasElement, - progress: number, - buffered: number, - animState: AnimState, -) { - const r = setupCanvas(canvas); - if (!r) return; - const { ctx, w, h } = r; - const { played, buffered: buffCol, unplayed } = getColors(); - const cy = h / 2; - - animState.angle += 0.055; - - const reelR = Math.min(h / 2 - 0.5, 9); - // Map progress to a center x that keeps the reel fully within the canvas - const px = reelR + (w - 2 * reelR) * progress; - - // Background track - ctx.globalAlpha = 0.3; - ctx.fillStyle = unplayed; - ctx.fillRect(0, cy - 1, w, 2); - - if (buffered > 0) { - ctx.globalAlpha = 0.5; - ctx.fillStyle = buffCol; - ctx.fillRect(0, cy - 1, buffered * w, 2); - } - - // Played portion — up to the left edge of the reel - if (progress > 0) { - ctx.globalAlpha = 1; - ctx.fillStyle = played; - ctx.shadowColor = played; - setShadowBlur(ctx, 4); - ctx.fillRect(0, cy - 1, px - reelR, 2); - setShadowBlur(ctx, 0); - } - - // Spinning reel at playhead - ctx.globalAlpha = 1; - ctx.strokeStyle = played; - ctx.lineWidth = 1; - ctx.shadowColor = played; - setShadowBlur(ctx, 7); - - // Outer ring - ctx.beginPath(); - ctx.arc(px, cy, reelR, 0, Math.PI * 2); - ctx.stroke(); - setShadowBlur(ctx, 0); - - // Hub - const hubR = Math.max(1.5, reelR * 0.28); - ctx.fillStyle = played; - ctx.beginPath(); - ctx.arc(px, cy, hubR, 0, Math.PI * 2); - ctx.fill(); - - // Spokes - if (reelR > hubR + 2) { - ctx.lineWidth = 0.9; - ctx.strokeStyle = played; - for (let s = 0; s < 3; s++) { - const a = animState.angle + (s * Math.PI * 2) / 3; - ctx.beginPath(); - ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5)); - ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5)); - ctx.stroke(); - } - } - - setShadowBlur(ctx, 0); - ctx.globalAlpha = 1; -} - -// ── dispatcher ──────────────────────────────────────────────────────────────── - -export function drawSeekbar( - canvas: HTMLCanvasElement, - style: SeekbarStyle, - heights: Float32Array | null, - progress: number, - buffered: number, - animState?: AnimState, -) { - bumpPerfCounter('waveformDraws'); - const anim = animState ?? makeAnimState(); - switch (style) { - case 'truewave': drawWaveform(canvas, heights, progress, buffered); break; - case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break; - case 'linedot': drawLineDot(canvas, progress, buffered); break; - case 'bar': drawBar(canvas, progress, buffered); break; - case 'thick': drawThick(canvas, progress, buffered); break; - case 'segmented': drawSegmented(canvas, progress, buffered); break; - case 'neon': drawNeon(canvas, progress, buffered); break; - case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break; - case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break; - case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break; - case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break; - // Safety net: if a legacy or tampered persisted style sneaks past the - // authStore migration, fall back to the truewave renderer instead of - // leaving a blank canvas. - default: drawWaveform(canvas, heights, progress, buffered); break; - } -} - -// ── 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) { - 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 ( - - ); -} +import { + ANIMATED_STYLES, AnimState, + STATIC_REDRAW_FORCE_MS, STATIC_REDRAW_MIN_MS, + fmt, invalidateColorCache, isBarQuantizedSeekStyle, makeAnimState, + quantizeProgressByBars, +} from '../utils/waveformSeekHelpers'; +import { drawSeekbar } from '../utils/waveformSeekRenderers'; +import { useWaveformHeights } from '../hooks/useWaveformHeights'; +import { useWaveformInterpolation } from '../hooks/useWaveformInterpolation'; // ── main component ──────────────────────────────────────────────────────────── // @@ -942,12 +28,13 @@ interface Props { trackId: string | undefined; } +const SEEK_COMMIT_GUARD_MS = 900; +const SEEK_COMMIT_MIN_HOLD_MS = 320; +const SEEK_COMMIT_PROGRESS_EPS = 0.02; +const WHEEL_SEEK_STEP_SECONDS = 10; +const WHEEL_SEEK_DEBOUNCE_MS = 350; + export default function WaveformSeek({ trackId }: Props) { - const SEEK_COMMIT_GUARD_MS = 900; - const SEEK_COMMIT_MIN_HOLD_MS = 320; - const SEEK_COMMIT_PROGRESS_EPS = 0.02; - const WHEEL_SEEK_STEP_SECONDS = 10; - const WHEEL_SEEK_DEBOUNCE_MS = 350; const canvasRef = useRef(null); const heightsRef = useRef(null); const progressRef = useRef(getPlaybackProgressSnapshot().progress); @@ -975,86 +62,11 @@ export default function WaveformSeek({ trackId }: Props) { 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]); + useWaveformHeights({ + trackId, waveformBins, seekbarStyle, + heightsRef, canvasRef, styleRef, progressRef, bufferedRef, animStateRef, + }); + // Imperative subscription — no React re-renders from progress changes. // Static styles draw here; animated styles only update refs. @@ -1184,80 +196,6 @@ export default function WaveformSeek({ trackId }: Props) { // Keep current visual position as-is; only reset timing anchor. }, [previewFreezesMainSeekbar]); - useEffect(() => { - if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return; - // This effect is torn down while paused, so `progressAnchorRef.atMs` is not refreshed. - // On resume the first `tick` would add the entire pause duration to `elapsedSec` and - // overshoot the playhead until the next transport heartbeat corrects it. - const snap = getPlaybackProgressSnapshot(); - const raw = snap.progress; - progressRef.current = raw; - progressAnchorRef.current = { - progress: raw, - atMs: performance.now(), - }; - const resumeVisual = isBarQuantizedSeekStyle(styleRef.current) - ? quantizeProgressByBars(raw) - : raw; - visualTargetProgressRef.current = resumeVisual; - visualProgressRef.current = resumeVisual; - - let rafId: number | null = null; - let lastPaintAt = 0; - const tick = (now: number) => { - if (document.hidden || window.__psyHidden) { - rafId = requestAnimationFrame(tick); - return; - } - if (isDragging.current) { - rafId = requestAnimationFrame(tick); - return; - } - const wheelPreviewFraction = wheelPreviewFractionRef.current; - if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) { - rafId = requestAnimationFrame(tick); - return; - } - if (pendingCommittedSeekRef.current) { - rafId = requestAnimationFrame(tick); - return; - } - const anchor = progressAnchorRef.current; - const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000); - const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration)); - const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current) - ? quantizeProgressByBars(predicted) - : predicted; - if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) { - visualTargetProgressRef.current = nextTargetProgress; - } - const currentVisual = visualProgressRef.current; - const targetVisual = visualTargetProgressRef.current; - const delta = targetVisual - currentVisual; - if (Math.abs(delta) > 0.000001) { - const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28; - const nextVisualProgress = Math.abs(delta) < 0.002 - ? targetVisual - : currentVisual + delta * smoothing; - visualProgressRef.current = nextVisualProgress; - progressRef.current = nextVisualProgress; - const needsDirectDraw = !ANIMATED_STYLES.has(styleRef.current); - if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) { - const canvas = canvasRef.current; - if (canvas) { - drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current); - lastPaintAt = now; - } - } - } - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); - return () => { - if (rafId != null) cancelAnimationFrame(rafId); - }; - }, [duration, isPlaying, previewFreezesMainSeekbar]); - // Resize observer. useEffect(() => { const canvas = canvasRef.current; @@ -1296,6 +234,14 @@ export default function WaveformSeek({ trackId }: Props) { const wheelPreviewFractionRef = useRef(null); const wheelPreviewUntilRef = useRef(0); + useWaveformInterpolation({ + duration, isPlaying, previewFreezesMainSeekbar, + canvasRef, heightsRef, styleRef, + progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef, + progressAnchorRef, animStateRef, isDraggingRef: isDragging, + wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef, + }); + useEffect(() => () => { if (wheelSeekTimerRef.current != null) { window.clearTimeout(wheelSeekTimerRef.current); diff --git a/src/components/WaveformSeekPreview.tsx b/src/components/WaveformSeekPreview.tsx new file mode 100644 index 00000000..7cad9a5a --- /dev/null +++ b/src/components/WaveformSeekPreview.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react'; +import type { SeekbarStyle } from '../store/authStoreTypes'; +import { makeAnimState, makeHeights } from '../utils/waveformSeekHelpers'; +import { drawSeekbar } from '../utils/waveformSeekRenderers'; + +interface Props { + style: SeekbarStyle; + label: string; + selected: boolean; + onClick: () => void; +} + +/** Animated preview tile for Settings — drives drawSeekbar via a rAF loop and + * parks itself when the window/tab is hidden. */ +export function SeekbarPreview({ style, label, selected, onClick }: Props) { + const canvasRef = useRef(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 ( + + ); +} diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 56997da8..802bfac4 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -10,7 +10,7 @@ import { IS_LINUX, IS_WINDOWS } from '../../utils/platform'; import CustomSelect from '../CustomSelect'; import SettingsSubSection from '../SettingsSubSection'; import ThemePicker, { THEME_GROUPS } from '../ThemePicker'; -import { SeekbarPreview } from '../WaveformSeek'; +import { SeekbarPreview } from '../WaveformSeekPreview'; export function AppearanceTab() { const { t, i18n } = useTranslation(); diff --git a/src/hooks/useWaveformHeights.ts b/src/hooks/useWaveformHeights.ts new file mode 100644 index 00000000..0c6e2d0a --- /dev/null +++ b/src/hooks/useWaveformHeights.ts @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; +import type { SeekbarStyle } from '../store/authStoreTypes'; +import type { AnimState } from '../utils/waveformSeekHelpers'; +import { + ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS, + binsToHeights, easeOutCubic, heightsNearlyEqual, + makeFlatWaveHeights, makeHeights, +} from '../utils/waveformSeekHelpers'; +import { drawSeekbar } from '../utils/waveformSeekRenderers'; + +interface Args { + trackId: string | undefined; + waveformBins: number[] | null | undefined; + seekbarStyle: SeekbarStyle; + heightsRef: React.MutableRefObject; + canvasRef: React.RefObject; + styleRef: React.MutableRefObject; + progressRef: React.MutableRefObject; + bufferedRef: React.MutableRefObject; + animStateRef: React.MutableRefObject; +} + +/** Manages `heightsRef` for the seekbar: bootstraps deterministic bars for + * pseudowave, animates a morph between waveform analysis updates, and falls + * back to a flat wave when no bins exist yet. */ +export function useWaveformHeights({ + trackId, waveformBins, seekbarStyle, + heightsRef, canvasRef, styleRef, progressRef, bufferedRef, animStateRef, +}: Args) { + useEffect(() => { + if (!trackId) { + heightsRef.current = null; + return; + } + // Pseudowave is the deterministic per-track-ID variant — no analysis needed, + // no morph animation, no flat-fallback. It just sits there looking like a + // waveform. + if (seekbarStyle === 'pseudowave') { + heightsRef.current = makeHeights(trackId); + const canvas = canvasRef.current; + if (canvas && !ANIMATED_STYLES.has(seekbarStyle)) { + drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); + } + return; + } + if (waveformBins && waveformBins.length > 0) { + const h = binsToHeights(waveformBins); + const prev = heightsRef.current; + if (!prev || prev.length !== BAR_COUNT) { + heightsRef.current = h; + return; + } + if (heightsNearlyEqual(prev, h, 0.02)) { + heightsRef.current = h; + return; + } + const from = new Float32Array(prev); + const to = h; + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + } + if (heightsRef.current?.length === BAR_COUNT) { + const current = heightsRef.current; + let isAlreadyFlat = true; + for (let i = 0; i < BAR_COUNT; i++) { + if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) { + isAlreadyFlat = false; + break; + } + } + if (isAlreadyFlat) return; + const from = new Float32Array(current); + const to = makeFlatWaveHeights(); + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + } + // No analysis bins yet: render 500 flat bars immediately. + heightsRef.current = makeFlatWaveHeights(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackId, waveformBins, seekbarStyle]); +} diff --git a/src/hooks/useWaveformInterpolation.ts b/src/hooks/useWaveformInterpolation.ts new file mode 100644 index 00000000..6a938d9b --- /dev/null +++ b/src/hooks/useWaveformInterpolation.ts @@ -0,0 +1,113 @@ +import React, { useEffect } from 'react'; +import type { SeekbarStyle } from '../store/authStoreTypes'; +import { getPlaybackProgressSnapshot } from '../store/playbackProgress'; +import { + ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS, + isBarQuantizedSeekStyle, quantizeProgressByBars, +} from '../utils/waveformSeekHelpers'; +import { drawSeekbar } from '../utils/waveformSeekRenderers'; + +interface Args { + duration: number; + isPlaying: boolean; + previewFreezesMainSeekbar: boolean; + canvasRef: React.RefObject; + heightsRef: React.MutableRefObject; + styleRef: React.MutableRefObject; + progressRef: React.MutableRefObject; + bufferedRef: React.MutableRefObject; + visualProgressRef: React.MutableRefObject; + visualTargetProgressRef: React.MutableRefObject; + progressAnchorRef: React.MutableRefObject<{ progress: number; atMs: number }>; + animStateRef: React.MutableRefObject; + isDraggingRef: React.MutableRefObject; + wheelPreviewFractionRef: React.MutableRefObject; + wheelPreviewUntilRef: React.MutableRefObject; + pendingCommittedSeekRef: React.MutableRefObject<{ fraction: number; setAtMs: number } | null>; +} + +/** Smooth interpolation rAF loop — predicts playhead position between sparse + * transport heartbeats, with proper anchor reset on resume. Inactive while + * paused, dragging, wheel-preview, or pending-committed-seek. */ +export function useWaveformInterpolation({ + duration, isPlaying, previewFreezesMainSeekbar, + canvasRef, heightsRef, styleRef, + progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef, + progressAnchorRef, animStateRef, isDraggingRef, + wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef, +}: Args) { + useEffect(() => { + if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return; + // This effect is torn down while paused, so `progressAnchorRef.atMs` is not refreshed. + // On resume the first `tick` would add the entire pause duration to `elapsedSec` and + // overshoot the playhead until the next transport heartbeat corrects it. + const snap = getPlaybackProgressSnapshot(); + const raw = snap.progress; + progressRef.current = raw; + progressAnchorRef.current = { + progress: raw, + atMs: performance.now(), + }; + const resumeVisual = isBarQuantizedSeekStyle(styleRef.current) + ? quantizeProgressByBars(raw) + : raw; + visualTargetProgressRef.current = resumeVisual; + visualProgressRef.current = resumeVisual; + + let rafId: number | null = null; + let lastPaintAt = 0; + const tick = (now: number) => { + if (document.hidden || window.__psyHidden) { + rafId = requestAnimationFrame(tick); + return; + } + if (isDraggingRef.current) { + rafId = requestAnimationFrame(tick); + return; + } + const wheelPreviewFraction = wheelPreviewFractionRef.current; + if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) { + rafId = requestAnimationFrame(tick); + return; + } + if (pendingCommittedSeekRef.current) { + rafId = requestAnimationFrame(tick); + return; + } + const anchor = progressAnchorRef.current; + const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000); + const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration)); + const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current) + ? quantizeProgressByBars(predicted) + : predicted; + if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) { + visualTargetProgressRef.current = nextTargetProgress; + } + const currentVisual = visualProgressRef.current; + const targetVisual = visualTargetProgressRef.current; + const delta = targetVisual - currentVisual; + if (Math.abs(delta) > 0.000001) { + const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28; + const nextVisualProgress = Math.abs(delta) < 0.002 + ? targetVisual + : currentVisual + delta * smoothing; + visualProgressRef.current = nextVisualProgress; + progressRef.current = nextVisualProgress; + const needsDirectDraw = !ANIMATED_STYLES.has(styleRef.current); + if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) { + const canvas = canvasRef.current; + if (canvas) { + drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current); + lastPaintAt = now; + } + } + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => { + if (rafId != null) cancelAnimationFrame(rafId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [duration, isPlaying, previewFreezesMainSeekbar]); +} diff --git a/src/utils/waveformSeekHelpers.ts b/src/utils/waveformSeekHelpers.ts new file mode 100644 index 00000000..cdd68319 --- /dev/null +++ b/src/utils/waveformSeekHelpers.ts @@ -0,0 +1,186 @@ +import type { SeekbarStyle } from '../store/authStoreTypes'; + +export function fmt(s: number): string { + if (!s || isNaN(s)) return '0:00'; + return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`; +} + +export const BAR_COUNT = 500; +/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */ +export const WAVE_BIN_COUNT = 500; +/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */ +export const WAVE_MIX_MEAN = 0.7; +export const WAVE_MIX_MAX = 0.3; +export const SEG_COUNT = 60; +export const FLAT_WAVE_NORM = 0.06; +export const WAVE_MORPH_MS = 1000; +export const STATIC_REDRAW_MIN_MS = 90; +export const STATIC_REDRAW_FORCE_MS = 220; +export const INTERPOLATION_PAINT_MIN_MS = 80; + +export type Particle = { + x: number; y: number; + vx: number; vy: number; + life: number; maxLife: number; + size: number; +}; + +export type AnimState = { + particles: Particle[]; + time: number; + lastProgress: number; + angle: number; +}; + +export function makeAnimState(): AnimState { + return { particles: [], time: 0, lastProgress: 0, angle: 0 }; +} + +export const ANIMATED_STYLES = new Set(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']); + +export type SeekbarColors = { + played: string; + buffered: string; + unplayed: string; +}; + +let cachedColors: SeekbarColors | null = null; +let cachedColorsKey = ''; + +export function invalidateColorCache() { + cachedColors = null; +} + +export function getColors(): SeekbarColors { + const root = document.documentElement; + const style = root.style; + const key = [ + root.getAttribute('data-theme') ?? '', + style.getPropertyValue('--accent'), + style.getPropertyValue('--waveform-played'), + style.getPropertyValue('--waveform-buffered'), + style.getPropertyValue('--waveform-unplayed'), + ].join('|'); + if (cachedColors && cachedColorsKey === key) return cachedColors; + const s = getComputedStyle(root); + cachedColorsKey = key; + cachedColors = { + played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7', + buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086', + unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244', + }; + return cachedColors; +} + +export function setupCanvas( + canvas: HTMLCanvasElement, +): { ctx: CanvasRenderingContext2D; w: number; h: number } | null { + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const w = canvas.clientWidth || canvas.getBoundingClientRect().width; + const h = canvas.clientHeight || canvas.getBoundingClientRect().height; + if (w === 0 || h === 0) return null; + const dpr = window.devicePixelRatio || 1; + const pw = Math.round(w * dpr); + const ph = Math.round(h * dpr); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + return { ctx, w, h }; +} + +export function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) { + ctx.shadowBlur = Math.max(0, blur); +} + +function hashStr(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h = (h ^ str.charCodeAt(i)) >>> 0; + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +export function makeHeights(trackId: string): Float32Array { + let s = hashStr(trackId); + const h = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + h[i] = s / 0xffffffff; + } + for (let pass = 0; pass < 5; pass++) { + for (let i = 1; i < BAR_COUNT - 1; i++) { + h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25; + } + } + let max = 0; + for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i]; + if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88; + return h; +} + +export function makeFlatWaveHeights(): Float32Array { + const h = new Float32Array(BAR_COUNT); + h.fill(FLAT_WAVE_NORM); + return h; +} + +export function easeOutCubic(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return 1 - Math.pow(1 - x, 3); +} + +export function binsToHeights(src: number[]): Float32Array { + const h = new Float32Array(BAR_COUNT); + const n = src.length; + if (n === WAVE_BIN_COUNT * 2) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const maxNorm = Number(src[idx]) / 255; + const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255; + const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm; + h[i] = Math.max(0.08, Math.min(1, v)); + } + return h; + } + if (n === WAVE_BIN_COUNT) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; + } + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; +} + +export function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > eps) return false; + } + return true; +} + +export function waveformBarThickness(logicalH: number, norm: number): number { + const safeNorm = Math.max(FLAT_WAVE_NORM, norm); + return Math.max(1, safeNorm * logicalH); +} + +export function quantizeProgressByBars(progress: number): number { + const clamped = Math.max(0, Math.min(1, progress)); + return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT)); +} + +export function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean { + return style === 'truewave' || style === 'pseudowave'; +} diff --git a/src/utils/waveformSeekRenderers.ts b/src/utils/waveformSeekRenderers.ts new file mode 100644 index 00000000..021f08db --- /dev/null +++ b/src/utils/waveformSeekRenderers.ts @@ -0,0 +1,38 @@ +import type { SeekbarStyle } from '../store/authStoreTypes'; +import { bumpPerfCounter } from './perfTelemetry'; +import { AnimState, makeAnimState } from './waveformSeekHelpers'; +import { + drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform, +} from './waveformSeekRenderersStatic'; +import { + drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape, +} from './waveformSeekRenderersAnimated'; + +export function drawSeekbar( + canvas: HTMLCanvasElement, + style: SeekbarStyle, + heights: Float32Array | null, + progress: number, + buffered: number, + animState?: AnimState, +) { + bumpPerfCounter('waveformDraws'); + const anim = animState ?? makeAnimState(); + switch (style) { + case 'truewave': drawWaveform(canvas, heights, progress, buffered); break; + case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break; + case 'linedot': drawLineDot(canvas, progress, buffered); break; + case 'bar': drawBar(canvas, progress, buffered); break; + case 'thick': drawThick(canvas, progress, buffered); break; + case 'segmented': drawSegmented(canvas, progress, buffered); break; + case 'neon': drawNeon(canvas, progress, buffered); break; + case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break; + case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break; + case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break; + case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break; + // Safety net: if a legacy or tampered persisted style sneaks past the + // authStore migration, fall back to the truewave renderer instead of + // leaving a blank canvas. + default: drawWaveform(canvas, heights, progress, buffered); break; + } +} diff --git a/src/utils/waveformSeekRenderersAnimated.ts b/src/utils/waveformSeekRenderersAnimated.ts new file mode 100644 index 00000000..a51d6dee --- /dev/null +++ b/src/utils/waveformSeekRenderersAnimated.ts @@ -0,0 +1,324 @@ +import { AnimState, BAR_COUNT, getColors, setShadowBlur, setupCanvas } from './waveformSeekHelpers'; + +export function drawPulseWave( + canvas: HTMLCanvasElement, + progress: number, + buffered: number, + animState: AnimState, +) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const cy = h / 2; + const px = progress * w; + const t = animState.time; + + // Base line + ctx.globalAlpha = 0.3; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - 1, w, 2); + + if (buffered > 0) { + ctx.globalAlpha = 0.45; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - 1, buffered * w, 2); + } + + // Animated pulse centered at playhead + const pulseR = Math.min(38, w * 0.13); + const amp = Math.min(h * 0.42, 5.5); + const sigma = pulseR * 0.42; + const startX = Math.max(0, px - pulseR); + const endX = Math.min(w, px + pulseR); + + // Flat played line up to where the wave envelope starts + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 3); + ctx.fillRect(0, cy - 1, startX, 2); + setShadowBlur(ctx, 0); + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = played; + ctx.lineWidth = 1.5; + ctx.shadowColor = played; + setShadowBlur(ctx, 7); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(startX, cy); + for (let x = startX; x <= endX; x += 0.75) { + const dx = x - px; + const env = Math.exp(-(dx * dx) / (2 * sigma * sigma)); + const wave = env * amp * Math.sin(dx * 0.28 - t * 18); + ctx.lineTo(x, cy - wave); + } + ctx.stroke(); + setShadowBlur(ctx, 0); + ctx.globalAlpha = 1; +} + +export function drawParticleTrail( + canvas: HTMLCanvasElement, + progress: number, + buffered: number, + animState: AnimState, +) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const cy = h / 2; + const px = progress * w; + + // Spawn particles at playhead based on movement + const prevPx = animState.lastProgress * w; + const moved = Math.abs(px - prevPx); + const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5)); + for (let i = 0; i < spawnN; i++) { + animState.particles.push({ + x: px + (Math.random() - 0.5) * 3, + y: cy + (Math.random() - 0.5) * (h * 0.55), + vx: -(Math.random() * 1.0 + 0.3), + vy: (Math.random() - 0.5) * 0.6, + life: 1, + maxLife: 25 + Math.random() * 35, + size: Math.random() * 1.8 + 0.8, + }); + } + animState.lastProgress = progress; + + // Update + cull + for (const p of animState.particles) { + p.x += p.vx; + p.y += p.vy; + p.vy *= 0.97; + p.life -= 1 / p.maxLife; + } + animState.particles = animState.particles.filter(p => p.life > 0); + if (animState.particles.length > 180) { + animState.particles = animState.particles.slice(-180); + } + + // Background line + ctx.globalAlpha = 0.28; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - 1, w, 2); + + if (buffered > 0) { + ctx.globalAlpha = 0.45; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - 1, buffered * w, 2); + } + + // Played line + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 4); + ctx.fillRect(0, cy - 1, px, 2); + setShadowBlur(ctx, 0); + } + + // Particles + ctx.shadowColor = played; + for (const p of animState.particles) { + ctx.globalAlpha = p.life * 0.85; + setShadowBlur(ctx, 5); + ctx.fillStyle = played; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + setShadowBlur(ctx, 0); + + // Playhead dot + if (progress > 0) { + const dx = Math.max(5, Math.min(w - 5, px)); + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 10); + ctx.beginPath(); + ctx.arc(dx, cy, 4, 0, Math.PI * 2); + ctx.fill(); + setShadowBlur(ctx, 0); + } + + ctx.globalAlpha = 1; +} + +export function drawLiquidFill( + canvas: HTMLCanvasElement, + progress: number, + buffered: number, + animState: AnimState, +) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const t = animState.time; + + const tubeH = Math.min(13, Math.max(6, h * 0.62)); + const tubeR = tubeH / 2; + const y0 = (h - tubeH) / 2; + const y1 = y0 + tubeH; + + // Glass tube background + ctx.globalAlpha = 0.18; + ctx.fillStyle = unplayed; + ctx.beginPath(); + ctx.roundRect(0, y0, w, tubeH, tubeR); + ctx.fill(); + ctx.globalAlpha = 0.3; + ctx.strokeStyle = unplayed; + ctx.lineWidth = 0.8; + ctx.stroke(); + + if (buffered > 0) { + ctx.save(); + ctx.beginPath(); + ctx.roundRect(0, y0, w, tubeH, tubeR); + ctx.clip(); + ctx.globalAlpha = 0.3; + ctx.fillStyle = buffCol; + ctx.fillRect(0, y0, buffered * w, tubeH); + ctx.restore(); + } + + if (progress > 0) { + const px = progress * w; + + ctx.save(); + ctx.beginPath(); + ctx.roundRect(0, y0, w, tubeH, tubeR); + ctx.clip(); + + // Liquid body with animated wave on top surface + const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full + const waveAmp = Math.min(2.0, tubeH * 0.14); + const waveFreq = 0.09; + + ctx.beginPath(); + ctx.moveTo(-1, y1 + 1); + ctx.lineTo(-1, surfaceY); + + for (let x = 0; x <= px + 1; x += 1) { + const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2); + ctx.lineTo(x, surfaceY + wave); + } + ctx.lineTo(px + 1, y1 + 1); + ctx.closePath(); + + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 9); + ctx.fill(); + setShadowBlur(ctx, 0); + + // Glass highlight on top + const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45); + hl.addColorStop(0, 'rgba(255,255,255,0.28)'); + hl.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.globalAlpha = 0.6; + ctx.fillStyle = hl; + ctx.fillRect(0, y0, px, tubeH * 0.45); + + ctx.restore(); + } + + // Tube outline (on top) + ctx.globalAlpha = 0.5; + ctx.strokeStyle = unplayed; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.roundRect(0, y0, w, tubeH, tubeR); + ctx.stroke(); + + ctx.globalAlpha = 1; +} + +export function drawRetroTape( + canvas: HTMLCanvasElement, + progress: number, + buffered: number, + animState: AnimState, +) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const cy = h / 2; + + animState.angle += 0.055; + + const reelR = Math.min(h / 2 - 0.5, 9); + // Map progress to a center x that keeps the reel fully within the canvas + const px = reelR + (w - 2 * reelR) * progress; + + // Background track + ctx.globalAlpha = 0.3; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - 1, w, 2); + + if (buffered > 0) { + ctx.globalAlpha = 0.5; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - 1, buffered * w, 2); + } + + // Played portion — up to the left edge of the reel + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 4); + ctx.fillRect(0, cy - 1, px - reelR, 2); + setShadowBlur(ctx, 0); + } + + // Spinning reel at playhead + ctx.globalAlpha = 1; + ctx.strokeStyle = played; + ctx.lineWidth = 1; + ctx.shadowColor = played; + setShadowBlur(ctx, 7); + + // Outer ring + ctx.beginPath(); + ctx.arc(px, cy, reelR, 0, Math.PI * 2); + ctx.stroke(); + setShadowBlur(ctx, 0); + + // Hub + const hubR = Math.max(1.5, reelR * 0.28); + ctx.fillStyle = played; + ctx.beginPath(); + ctx.arc(px, cy, hubR, 0, Math.PI * 2); + ctx.fill(); + + // Spokes + if (reelR > hubR + 2) { + ctx.lineWidth = 0.9; + ctx.strokeStyle = played; + for (let s = 0; s < 3; s++) { + const a = animState.angle + (s * Math.PI * 2) / 3; + ctx.beginPath(); + ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5)); + ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5)); + ctx.stroke(); + } + } + + setShadowBlur(ctx, 0); + ctx.globalAlpha = 1; +} + +// ── dispatcher ──────────────────────────────────────────────────────────────── diff --git a/src/utils/waveformSeekRenderersStatic.ts b/src/utils/waveformSeekRenderersStatic.ts new file mode 100644 index 00000000..63bde758 --- /dev/null +++ b/src/utils/waveformSeekRenderersStatic.ts @@ -0,0 +1,290 @@ +import { + BAR_COUNT, SEG_COUNT, + getColors, setShadowBlur, setupCanvas, waveformBarThickness, +} from './waveformSeekHelpers'; + +export function drawWaveform( + canvas: HTMLCanvasElement, + heights: Float32Array | null, + progress: number, + buffered: number, +) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const pNorm = Math.max(0, Math.min(1, progress)); + const bNorm = Math.max(pNorm, Math.min(1, buffered)); + + if (!heights) { + // No waveform data yet: flat rail like `drawLineDot`, but do not return early + // before played/buffered — otherwise there is no visible playhead. + const cy = h / 2; + const lh = 2; + const dotR = 5; + ctx.globalAlpha = 0.35; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - lh / 2, w, lh); + if (buffered > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh); + } + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 5); + ctx.fillRect(0, cy - lh / 2, pNorm * w, lh); + setShadowBlur(ctx, 0); + } + ctx.globalAlpha = 1; + if (w > 0) { + const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w)); + ctx.shadowColor = played; + setShadowBlur(ctx, 7); + ctx.beginPath(); + ctx.arc(dx, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = played; + ctx.fill(); + setShadowBlur(ctx, 0); + } + ctx.globalAlpha = 1; + return; + } + const x1Of = (i: number) => (i / BAR_COUNT) * w; + const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; + ctx.globalAlpha = 0.28; + ctx.fillStyle = unplayed; + for (let i = 0; i < BAR_COUNT; i++) { + if (i / BAR_COUNT < bNorm) continue; + const bh = waveformBarThickness(h, heights[i]); + const x = x1Of(i); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); + } + + ctx.globalAlpha = 0.45; + ctx.fillStyle = buffCol; + for (let i = 0; i < BAR_COUNT; i++) { + const frac = i / BAR_COUNT; + if (frac < pNorm || frac >= bNorm) continue; + const bh = waveformBarThickness(h, heights[i]); + const x = x1Of(i); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); + } + + if (pNorm > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + for (let i = 0; i < BAR_COUNT; i++) { + if (i / BAR_COUNT >= pNorm) break; + const bh = waveformBarThickness(h, heights[i]); + const x = x1Of(i); + ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); + } + } + ctx.globalAlpha = 1; +} + +export function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const cy = h / 2; + const lh = 2; + const dotR = 5; + + ctx.globalAlpha = 0.35; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - lh / 2, w, lh); + + if (buffered > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - lh / 2, buffered * w, lh); + } + + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.fillRect(0, cy - lh / 2, progress * w, lh); + + const dx = Math.max(dotR, Math.min(w - dotR, progress * w)); + ctx.shadowColor = played; + setShadowBlur(ctx, 7); + ctx.beginPath(); + ctx.arc(dx, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = played; + ctx.fill(); + setShadowBlur(ctx, 0); + ctx.globalAlpha = 1; +} + +export function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const bh = 4; + const rad = bh / 2; + const y = (h - bh) / 2; + + ctx.globalAlpha = 0.3; + ctx.fillStyle = unplayed; + ctx.beginPath(); + ctx.roundRect(0, y, w, bh, rad); + ctx.fill(); + + if (buffered > 0) { + ctx.globalAlpha = 0.5; + ctx.fillStyle = buffCol; + ctx.beginPath(); + ctx.roundRect(0, y, buffered * w, bh, rad); + ctx.fill(); + } + + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 5); + ctx.beginPath(); + ctx.roundRect(0, y, progress * w, bh, rad); + ctx.fill(); + setShadowBlur(ctx, 0); + } + ctx.globalAlpha = 1; +} + +export function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const bh = Math.min(14, h); + const rad = bh / 2; + const y = (h - bh) / 2; + + ctx.globalAlpha = 0.25; + ctx.fillStyle = unplayed; + ctx.beginPath(); + ctx.roundRect(0, y, w, bh, rad); + ctx.fill(); + + if (buffered > 0) { + ctx.globalAlpha = 0.45; + ctx.fillStyle = buffCol; + ctx.beginPath(); + ctx.roundRect(0, y, buffered * w, bh, rad); + ctx.fill(); + } + + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 10); + ctx.beginPath(); + ctx.roundRect(0, y, progress * w, bh, rad); + ctx.fill(); + setShadowBlur(ctx, 0); + } + ctx.globalAlpha = 1; +} + +export function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, buffered: buffCol, unplayed } = getColors(); + const gap = 2; + const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT; + const segH = h * 0.65; + const y = (h - segH) / 2; + const playedIdx = Math.floor(progress * SEG_COUNT); + + for (let i = 0; i < SEG_COUNT; i++) { + const frac = i / SEG_COUNT; + const x = i * (segW + gap); + setShadowBlur(ctx, 0); + if (frac < progress) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + if (i === playedIdx - 1) { + ctx.shadowColor = played; + setShadowBlur(ctx, 5); + } + } else if (frac < buffered) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + } else { + ctx.globalAlpha = 0.28; + ctx.fillStyle = unplayed; + } + ctx.beginPath(); + ctx.roundRect(x, y, Math.max(1, segW), segH, 1); + ctx.fill(); + } + setShadowBlur(ctx, 0); + ctx.globalAlpha = 1; +} + +// ── new styles ──────────────────────────────────────────────────────────────── + +export function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) { + const r = setupCanvas(canvas); + if (!r) return; + const { ctx, w, h } = r; + const { played, unplayed } = getColors(); + const cy = h / 2; + + // Ghost track — barely visible + ctx.globalAlpha = 0.07; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - 1, w, 2); + + if (buffered > 0) { + ctx.globalAlpha = 0.12; + ctx.fillStyle = unplayed; + ctx.fillRect(0, cy - 1, buffered * w, 2); + } + + if (progress <= 0) return; + + const px = progress * w; + + // Wide outer glow + ctx.globalAlpha = 0.18; + ctx.fillStyle = played; + ctx.shadowColor = played; + setShadowBlur(ctx, 22); + ctx.fillRect(0, cy - 5, px, 10); + + // Mid glow + ctx.globalAlpha = 0.45; + setShadowBlur(ctx, 12); + ctx.fillRect(0, cy - 2.5, px, 5); + + // Inner glow + ctx.globalAlpha = 0.85; + setShadowBlur(ctx, 5); + ctx.fillRect(0, cy - 1.5, px, 3); + + // Bright white core + ctx.globalAlpha = 1; + ctx.fillStyle = '#ffffff'; + ctx.shadowColor = played; + setShadowBlur(ctx, 4); + ctx.fillRect(0, cy - 0.75, px, 1.5); + + // End-cap flare + setShadowBlur(ctx, 16); + ctx.beginPath(); + ctx.arc(px, cy, 2.5, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + + setShadowBlur(ctx, 0); + ctx.globalAlpha = 1; +} +