refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)

111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:27:44 +02:00
committed by GitHub
parent 2409a1fec8
commit 7a7a9f5e6b
324 changed files with 551 additions and 551 deletions
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { coerceWaveformBins, waveformBlobLenOk } from './waveformParse';
describe('waveformBlobLenOk', () => {
it('accepts the legacy single-curve length (500)', () => {
expect(waveformBlobLenOk(500)).toBe(true);
});
it('accepts the v4 dual-curve length (1000)', () => {
expect(waveformBlobLenOk(1000)).toBe(true);
});
it('rejects every other length', () => {
expect(waveformBlobLenOk(0)).toBe(false);
expect(waveformBlobLenOk(499)).toBe(false);
expect(waveformBlobLenOk(501)).toBe(false);
expect(waveformBlobLenOk(999)).toBe(false);
expect(waveformBlobLenOk(1001)).toBe(false);
});
});
describe('coerceWaveformBins', () => {
it('passes a 500-length number[] through with byte-mask', () => {
const input = new Array(500).fill(0).map((_, i) => i);
const out = coerceWaveformBins(input);
expect(out).not.toBeNull();
expect(out!.length).toBe(500);
expect(out![0]).toBe(0);
expect(out![255]).toBe(255);
// index 256 wraps to 0 because of the & 255 mask
expect(out![256]).toBe(0);
});
it('passes a 1000-length Uint8Array through unchanged', () => {
const u8 = new Uint8Array(1000);
u8[42] = 200;
const out = coerceWaveformBins(u8);
expect(out).not.toBeNull();
expect(out!.length).toBe(1000);
expect(out![42]).toBe(200);
});
it('coerces a generic ArrayLike (Tauri serializes Vec<u8> as object)', () => {
const arrayLike = { 0: 10, 1: 20, length: 500 } as ArrayLike<number>;
// Fill remaining slots with zeros to match expected shape
const proxy: ArrayLike<number> = {
length: 500,
...Object.fromEntries(new Array(500).fill(0).map((_, i) => [i, i === 0 ? 10 : i === 1 ? 20 : 0])),
} as ArrayLike<number>;
const out = coerceWaveformBins(proxy);
expect(out).not.toBeNull();
expect(out![0]).toBe(10);
expect(out![1]).toBe(20);
expect(out![2]).toBe(0);
});
it('returns null for null / undefined', () => {
expect(coerceWaveformBins(null)).toBeNull();
expect(coerceWaveformBins(undefined)).toBeNull();
});
it('returns null for empty arrays', () => {
expect(coerceWaveformBins([])).toBeNull();
expect(coerceWaveformBins(new Uint8Array(0))).toBeNull();
expect(coerceWaveformBins({ length: 0 })).toBeNull();
});
it('returns null when length is not 500 or 1000', () => {
expect(coerceWaveformBins(new Array(750).fill(0))).toBeNull();
expect(coerceWaveformBins(new Uint8Array(123))).toBeNull();
});
it('returns null for unsupported shapes (string, plain object without length)', () => {
expect(coerceWaveformBins('hello')).toBeNull();
expect(coerceWaveformBins({ a: 1 })).toBeNull();
expect(coerceWaveformBins(42)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Parse the waveform-bin payload Rust hands us. Two on-disk shapes survive:
* the v4 dual-curve (500 bytes peak + 500 bytes mean-abs = 1000 total) and
* the legacy single curve (500 bytes, treated as both peak and mean).
*
* `bins` may arrive as a real `number[]`, a `Uint8Array`, or any other
* `ArrayLike<number>` depending on Tauri's serialization path — coerce to a
* plain `number[]` clamped to a single byte, or return null when the shape
* doesn't match an accepted curve length.
*/
export function waveformBlobLenOk(len: number): boolean {
return len === 500 || len === 1000;
}
export function coerceWaveformBins(bins: unknown): number[] | null {
if (bins == null) return null;
let raw: number[] | null = null;
if (Array.isArray(bins)) {
if (bins.length === 0) return null;
raw = bins.map(x => Number(x) & 255);
} else if (bins instanceof Uint8Array) {
if (bins.length === 0) return null;
raw = Array.from(bins);
} else if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
const len = (bins as { length: number }).length;
if (len === 0) return null;
try {
raw = Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
} catch {
return null;
}
} else {
return null;
}
if (!waveformBlobLenOk(raw.length)) return null;
return raw;
}
+186
View File
@@ -0,0 +1,186 @@
import type { SeekbarStyle } from '../../store/authStoreTypes';
export function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
export const BAR_COUNT = 500;
/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */
export const WAVE_BIN_COUNT = 500;
/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */
export const WAVE_MIX_MEAN = 0.7;
export const WAVE_MIX_MAX = 0.3;
export const SEG_COUNT = 60;
export const FLAT_WAVE_NORM = 0.06;
export const WAVE_MORPH_MS = 1000;
export const STATIC_REDRAW_MIN_MS = 90;
export const STATIC_REDRAW_FORCE_MS = 220;
export const INTERPOLATION_PAINT_MIN_MS = 80;
export type Particle = {
x: number; y: number;
vx: number; vy: number;
life: number; maxLife: number;
size: number;
};
export type AnimState = {
particles: Particle[];
time: number;
lastProgress: number;
angle: number;
};
export function makeAnimState(): AnimState {
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
}
export const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
export type SeekbarColors = {
played: string;
buffered: string;
unplayed: string;
};
let cachedColors: SeekbarColors | null = null;
let cachedColorsKey = '';
export function invalidateColorCache() {
cachedColors = null;
}
export function getColors(): SeekbarColors {
const root = document.documentElement;
const style = root.style;
const key = [
root.getAttribute('data-theme') ?? '',
style.getPropertyValue('--accent'),
style.getPropertyValue('--waveform-played'),
style.getPropertyValue('--waveform-buffered'),
style.getPropertyValue('--waveform-unplayed'),
].join('|');
if (cachedColors && cachedColorsKey === key) return cachedColors;
const s = getComputedStyle(root);
cachedColorsKey = key;
cachedColors = {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
};
return cachedColors;
}
export function setupCanvas(
canvas: HTMLCanvasElement,
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
export function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
ctx.shadowBlur = Math.max(0, blur);
}
function hashStr(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h = (h ^ str.charCodeAt(i)) >>> 0;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
export function makeHeights(trackId: string): Float32Array {
let s = hashStr(trackId);
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
h[i] = s / 0xffffffff;
}
for (let pass = 0; pass < 5; pass++) {
for (let i = 1; i < BAR_COUNT - 1; i++) {
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
}
}
let max = 0;
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
return h;
}
export function makeFlatWaveHeights(): Float32Array {
const h = new Float32Array(BAR_COUNT);
h.fill(FLAT_WAVE_NORM);
return h;
}
export function easeOutCubic(t: number): number {
const x = Math.max(0, Math.min(1, t));
return 1 - Math.pow(1 - x, 3);
}
export function binsToHeights(src: number[]): Float32Array {
const h = new Float32Array(BAR_COUNT);
const n = src.length;
if (n === WAVE_BIN_COUNT * 2) {
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
const maxNorm = Number(src[idx]) / 255;
const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255;
const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm;
h[i] = Math.max(0.08, Math.min(1, v));
}
return h;
}
if (n === WAVE_BIN_COUNT) {
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
export function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > eps) return false;
}
return true;
}
export function waveformBarThickness(logicalH: number, norm: number): number {
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
return Math.max(1, safeNorm * logicalH);
}
export function quantizeProgressByBars(progress: number): number {
const clamped = Math.max(0, Math.min(1, progress));
return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT));
}
export function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean {
return style === 'truewave' || style === 'pseudowave';
}
@@ -0,0 +1,38 @@
import type { SeekbarStyle } from '../../store/authStoreTypes';
import { bumpPerfCounter } from '../perf/perfTelemetry';
import { AnimState, makeAnimState } from './waveformSeekHelpers';
import {
drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform,
} from './waveformSeekRenderersStatic';
import {
drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape,
} from './waveformSeekRenderersAnimated';
export function drawSeekbar(
canvas: HTMLCanvasElement,
style: SeekbarStyle,
heights: Float32Array | null,
progress: number,
buffered: number,
animState?: AnimState,
) {
bumpPerfCounter('waveformDraws');
const anim = animState ?? makeAnimState();
switch (style) {
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break;
case 'linedot': drawLineDot(canvas, progress, buffered); break;
case 'bar': drawBar(canvas, progress, buffered); break;
case 'thick': drawThick(canvas, progress, buffered); break;
case 'segmented': drawSegmented(canvas, progress, buffered); break;
case 'neon': drawNeon(canvas, progress, buffered); break;
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
// Safety net: if a legacy or tampered persisted style sneaks past the
// authStore migration, fall back to the truewave renderer instead of
// leaving a blank canvas.
default: drawWaveform(canvas, heights, progress, buffered); break;
}
}
@@ -0,0 +1,324 @@
import { AnimState, BAR_COUNT, getColors, setShadowBlur, setupCanvas } from './waveformSeekHelpers';
export function drawPulseWave(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const px = progress * w;
const t = animState.time;
// Base line
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Animated pulse centered at playhead
const pulseR = Math.min(38, w * 0.13);
const amp = Math.min(h * 0.42, 5.5);
const sigma = pulseR * 0.42;
const startX = Math.max(0, px - pulseR);
const endX = Math.min(w, px + pulseR);
// Flat played line up to where the wave envelope starts
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 3);
ctx.fillRect(0, cy - 1, startX, 2);
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1.5;
ctx.shadowColor = played;
setShadowBlur(ctx, 7);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startX, cy);
for (let x = startX; x <= endX; x += 0.75) {
const dx = x - px;
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
ctx.lineTo(x, cy - wave);
}
ctx.stroke();
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
export function drawParticleTrail(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const px = progress * w;
// Spawn particles at playhead based on movement
const prevPx = animState.lastProgress * w;
const moved = Math.abs(px - prevPx);
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
for (let i = 0; i < spawnN; i++) {
animState.particles.push({
x: px + (Math.random() - 0.5) * 3,
y: cy + (Math.random() - 0.5) * (h * 0.55),
vx: -(Math.random() * 1.0 + 0.3),
vy: (Math.random() - 0.5) * 0.6,
life: 1,
maxLife: 25 + Math.random() * 35,
size: Math.random() * 1.8 + 0.8,
});
}
animState.lastProgress = progress;
// Update + cull
for (const p of animState.particles) {
p.x += p.vx;
p.y += p.vy;
p.vy *= 0.97;
p.life -= 1 / p.maxLife;
}
animState.particles = animState.particles.filter(p => p.life > 0);
if (animState.particles.length > 180) {
animState.particles = animState.particles.slice(-180);
}
// Background line
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Played line
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px, 2);
setShadowBlur(ctx, 0);
}
// Particles
ctx.shadowColor = played;
for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85;
setShadowBlur(ctx, 5);
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
setShadowBlur(ctx, 0);
// Playhead dot
if (progress > 0) {
const dx = Math.max(5, Math.min(w - 5, px));
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill();
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
export function drawLiquidFill(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const t = animState.time;
const tubeH = Math.min(13, Math.max(6, h * 0.62));
const tubeR = tubeH / 2;
const y0 = (h - tubeH) / 2;
const y1 = y0 + tubeH;
// Glass tube background
ctx.globalAlpha = 0.18;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.fill();
ctx.globalAlpha = 0.3;
ctx.strokeStyle = unplayed;
ctx.lineWidth = 0.8;
ctx.stroke();
if (buffered > 0) {
ctx.save();
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.clip();
ctx.globalAlpha = 0.3;
ctx.fillStyle = buffCol;
ctx.fillRect(0, y0, buffered * w, tubeH);
ctx.restore();
}
if (progress > 0) {
const px = progress * w;
ctx.save();
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.clip();
// Liquid body with animated wave on top surface
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
const waveAmp = Math.min(2.0, tubeH * 0.14);
const waveFreq = 0.09;
ctx.beginPath();
ctx.moveTo(-1, y1 + 1);
ctx.lineTo(-1, surfaceY);
for (let x = 0; x <= px + 1; x += 1) {
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
ctx.lineTo(x, surfaceY + wave);
}
ctx.lineTo(px + 1, y1 + 1);
ctx.closePath();
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 9);
ctx.fill();
setShadowBlur(ctx, 0);
// Glass highlight on top
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.globalAlpha = 0.6;
ctx.fillStyle = hl;
ctx.fillRect(0, y0, px, tubeH * 0.45);
ctx.restore();
}
// Tube outline (on top)
ctx.globalAlpha = 0.5;
ctx.strokeStyle = unplayed;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.stroke();
ctx.globalAlpha = 1;
}
export function drawRetroTape(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
animState.angle += 0.055;
const reelR = Math.min(h / 2 - 0.5, 9);
// Map progress to a center x that keeps the reel fully within the canvas
const px = reelR + (w - 2 * reelR) * progress;
// Background track
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Played portion — up to the left edge of the reel
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px - reelR, 2);
setShadowBlur(ctx, 0);
}
// Spinning reel at playhead
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
setShadowBlur(ctx, 7);
// Outer ring
ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke();
setShadowBlur(ctx, 0);
// Hub
const hubR = Math.max(1.5, reelR * 0.28);
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(px, cy, hubR, 0, Math.PI * 2);
ctx.fill();
// Spokes
if (reelR > hubR + 2) {
ctx.lineWidth = 0.9;
ctx.strokeStyle = played;
for (let s = 0; s < 3; s++) {
const a = animState.angle + (s * Math.PI * 2) / 3;
ctx.beginPath();
ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
ctx.stroke();
}
}
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
// ── dispatcher ────────────────────────────────────────────────────────────────
@@ -0,0 +1,290 @@
import {
BAR_COUNT, SEG_COUNT,
getColors, setShadowBlur, setupCanvas, waveformBarThickness,
} from './waveformSeekHelpers';
export function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
progress: number,
buffered: number,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const pNorm = Math.max(0, Math.min(1, progress));
const bNorm = Math.max(pNorm, Math.min(1, buffered));
if (!heights) {
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
// before played/buffered — otherwise there is no visible playhead.
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w));
ctx.shadowColor = played;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
return;
}
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < pNorm || frac >= bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
if (pNorm > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= pNorm) break;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
}
ctx.globalAlpha = 1;
}
export function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
}
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
export function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = 4;
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 5);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
export function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = Math.min(14, h);
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.25;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
export function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const gap = 2;
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
const segH = h * 0.65;
const y = (h - segH) / 2;
const playedIdx = Math.floor(progress * SEG_COUNT);
for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT;
const x = i * (segW + gap);
setShadowBlur(ctx, 0);
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
setShadowBlur(ctx, 5);
}
} else if (frac < buffered) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
} else {
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
}
ctx.beginPath();
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill();
}
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
// ── new styles ────────────────────────────────────────────────────────────────
export function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, unplayed } = getColors();
const cy = h / 2;
// Ghost track — barely visible
ctx.globalAlpha = 0.07;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.12;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
if (progress <= 0) return;
const px = progress * w;
// Wide outer glow
ctx.globalAlpha = 0.18;
ctx.fillStyle = played;
ctx.shadowColor = played;
setShadowBlur(ctx, 22);
ctx.fillRect(0, cy - 5, px, 10);
// Mid glow
ctx.globalAlpha = 0.45;
setShadowBlur(ctx, 12);
ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow
ctx.globalAlpha = 0.85;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = played;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare
setShadowBlur(ctx, 16);
ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}