feat: Waveform seekbar, MilkDrop visualizer, EQ bars, in-app browser, and UX improvements (v1.3.0)

- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern)
- MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions
- Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix
- Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser)
- Hero/Discover deduplication: single fetch of 20 random albums split between sections
- Update checker: runs every 10 minutes during runtime; version shown without v-prefix
- Settings version: now read from package.json instead of hardcoded
- Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling
- Bump version to 1.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-15 21:24:00 +01:00
parent 9bdd433a4b
commit c91fdd7e1d
28 changed files with 800 additions and 272 deletions
+180
View File
@@ -0,0 +1,180 @@
import React, { useEffect, useRef } from 'react';
import { usePlayerStore } from '../store/playerStore';
const BAR_COUNT = 500;
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;
}
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;
}
// Smooth for an organic look
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;
}
}
// Normalize to [0.12, 1.0]
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;
}
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
progress: number,
buffered: number,
) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return;
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);
const style = getComputedStyle(document.documentElement);
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
if (!heights) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = colorUnplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2);
ctx.globalAlpha = 1;
return;
}
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
// Pass 1 — unplayed (dim)
ctx.globalAlpha = 0.28;
ctx.fillStyle = colorUnplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
const barH = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
// Pass 2 — buffered (slightly brighter)
ctx.globalAlpha = 0.45;
ctx.fillStyle = colorBuffered;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue;
const barH = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
// Pass 3 — played (gradient + glow)
if (progress > 0) {
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
grad.addColorStop(0, colorBlue);
grad.addColorStop(1, colorMauve);
ctx.globalAlpha = 1;
ctx.fillStyle = grad;
ctx.shadowColor = colorMauve;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
const barH = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
interface Props {
trackId: string | undefined;
}
export default function WaveformSeek({ trackId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(0);
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
progressRef.current = progress;
bufferedRef.current = buffered;
useEffect(() => {
heightsRef.current = trackId ? makeHeights(trackId) : null;
}, [trackId]);
useEffect(() => {
if (canvasRef.current) {
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
}
}, [progress, buffered, trackId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(() => {
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
});
ro.observe(canvas);
return () => ro.disconnect();
}, []);
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackId) return;
const rect = canvas.getBoundingClientRect();
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
useEffect(() => {
const up = () => { isDragging.current = false; };
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
/>
);
}