feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation (orb and liquid), breathing at rest, pulsing to the track's BPM and energy in the cover palette, following the cursor, pressing with a spring; reduced motion and hidden tabs slow or stop it - home: the wave in the centre with activity and mood chips; changing them while playing rebuilds the upcoming tracks without stopping - player wave mode: asks for the next batch when two tracks are left, sends complete / skip / early skip / like / dislike as feedback and refreshes the queue right after a reaction or an early skip - tracks expose bpm, energy and loudness once analysed; library page shows analysis progress and 7-day wave metrics; wave style setting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3908814cda
commit
eaedd1fc62
24 files changed
+1193
-244
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
|
||||
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||
|
||||
export interface WaveButtonProps {
|
||||
variant?: 'orb' | 'liquid';
|
||||
playing?: boolean;
|
||||
bpm?: number;
|
||||
energy?: number;
|
||||
colors?: [string, string, string];
|
||||
theme?: 'dark' | 'light';
|
||||
interactive?: boolean;
|
||||
size?: number;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Моя волна» (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
|
||||
* тянется к курсору, при нажатии сжимается с отскоком. Enter / пробел — то же, что клик.
|
||||
*/
|
||||
export function WaveButton(props: WaveButtonProps) {
|
||||
const size = props.size ?? 320;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const st = useRef<WaveState>({
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
|
||||
dark: true, reduced: false, size,
|
||||
});
|
||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||
const s = st.current;
|
||||
s.variant = props.variant ?? 'orb';
|
||||
s.playing = !!props.playing;
|
||||
s.bpm = props.bpm ?? 100;
|
||||
s.energy = props.energy ?? 0.6;
|
||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
||||
s.dark = props.theme !== 'light';
|
||||
s.size = size;
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
const ctx = cv?.getContext('2d');
|
||||
if (!cv || !ctx) return;
|
||||
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
st.current.reduced = prefersReducedMotion();
|
||||
const onMq = () => (st.current.reduced = prefersReducedMotion());
|
||||
mq?.addEventListener?.('change', onMq);
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
const w = st.current;
|
||||
const S = w.size * 1.6;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (cv.width !== Math.round(S * dpr)) {
|
||||
cv.width = Math.round(S * dpr);
|
||||
cv.height = Math.round(S * dpr);
|
||||
}
|
||||
step(w, dt);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, S, S);
|
||||
const sc = 1 - w.press * 0.07 + w.hover * 0.02;
|
||||
const r = w.size * 0.5 * sc;
|
||||
const cx = S / 2 + w.mx * w.hover * 6;
|
||||
const cy = S / 2 + w.my * w.hover * 6;
|
||||
if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
|
||||
else drawOrb(ctx, cx, cy, r, w, w.t);
|
||||
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
// Во фоновой вкладке анимация не нужна — останавливаемся до возвращения.
|
||||
const onVisibility = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
if (document.visibilityState === 'visible') {
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(loop);
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
mq?.removeEventListener?.('change', onMq);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const interactive = props.interactive !== false;
|
||||
const onMove = (e: ReactMouseEvent) => {
|
||||
const b = wrapRef.current!.getBoundingClientRect();
|
||||
const x = ((e.clientX - b.left) / b.width) * 2 - 1;
|
||||
const y = ((e.clientY - b.top) / b.height) * 2 - 1;
|
||||
s.mx = x;
|
||||
s.my = y;
|
||||
s.ang = Math.atan2(y, x);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={interactive ? props.label : undefined}
|
||||
aria-pressed={interactive ? !!props.playing : undefined}
|
||||
aria-hidden={interactive ? undefined : true}
|
||||
onMouseMove={interactive ? onMove : undefined}
|
||||
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onMouseLeave={
|
||||
interactive
|
||||
? () => {
|
||||
s.hoverT = 0;
|
||||
s.pressT = 0;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={interactive ? () => (s.pressT = 1) : undefined}
|
||||
onMouseUp={
|
||||
interactive
|
||||
? () => {
|
||||
s.pressT = 0;
|
||||
s.pressV -= 0.12;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={interactive ? props.onClick : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // пробел здесь — кнопка волны, а не пауза плеера
|
||||
props.onClick?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
maxWidth: '78vw',
|
||||
maxHeight: '78vw',
|
||||
borderRadius: '50%',
|
||||
cursor: interactive ? 'pointer' : 'default',
|
||||
flexShrink: 0,
|
||||
pointerEvents: interactive ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-30%',
|
||||
top: '-30%',
|
||||
width: '160%',
|
||||
height: '160%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={labelRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Отрисовка кнопки «Моя волна» на Canvas 2D — порт прототипа docs/Разработка дизайна приложения/wave.js
|
||||
// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md).
|
||||
|
||||
export type Rgb = [number, number, number];
|
||||
|
||||
export interface WaveState {
|
||||
hover: number;
|
||||
hoverT: number;
|
||||
press: number;
|
||||
pressV: number;
|
||||
pressT: number;
|
||||
mx: number;
|
||||
my: number;
|
||||
ang: number;
|
||||
t: number;
|
||||
amp: number;
|
||||
pulse: number;
|
||||
speed: number;
|
||||
phase: number;
|
||||
variant: 'orb' | 'liquid';
|
||||
playing: boolean;
|
||||
bpm: number;
|
||||
energy: number;
|
||||
colors: [string, string, string];
|
||||
dark: boolean;
|
||||
reduced: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function rgb(hex: string): Rgb {
|
||||
const h = hex.replace('#', '');
|
||||
const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
|
||||
const n = parseInt(full, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
const rgba = (c: Rgb, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
||||
const mix = (a: Rgb, b: Rgb, k: number): Rgb => [
|
||||
Math.round(a[0] + (b[0] - a[0]) * k),
|
||||
Math.round(a[1] + (b[1] - a[1]) * k),
|
||||
Math.round(a[2] + (b[2] - a[2]) * k),
|
||||
];
|
||||
|
||||
export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
|
||||
const [c1, c2, c3] = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
|
||||
const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65);
|
||||
glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12));
|
||||
glow.addColorStop(0.5, rgba(c2, 0.1 + s.pulse * 0.12));
|
||||
glow.addColorStop(1, rgba(c1, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533';
|
||||
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const blobs = [c1, c2, c3, c1, c2];
|
||||
blobs.forEach((c, i) => {
|
||||
const a = t * (0.25 + i * 0.07) + i * 1.7;
|
||||
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
|
||||
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
|
||||
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
|
||||
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
|
||||
g.addColorStop(0, rgba(c, 0.55));
|
||||
g.addColorStop(1, rgba(c, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
|
||||
});
|
||||
const lines = 9;
|
||||
for (let l = 0; l < lines; l++) {
|
||||
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
|
||||
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
|
||||
ctx.beginPath();
|
||||
for (let x = -r; x <= r; x += 4) {
|
||||
const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4);
|
||||
if (x === -r) ctx.moveTo(cx + x, y);
|
||||
else ctx.lineTo(cx + x, y);
|
||||
}
|
||||
ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9);
|
||||
hl.addColorStop(0, 'rgba(255,255,255,0.18)');
|
||||
hl.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = hl;
|
||||
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
|
||||
ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
|
||||
const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
|
||||
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6);
|
||||
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3));
|
||||
glow.addColorStop(1, rgba(cols[0], 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
|
||||
for (let L = 0; L < 3; L++) {
|
||||
ctx.beginPath();
|
||||
const P = 140;
|
||||
for (let j = 0; j <= P; j++) {
|
||||
const th = (j / P) * Math.PI * 2;
|
||||
const w =
|
||||
1 +
|
||||
0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) +
|
||||
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) +
|
||||
0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
|
||||
0.08 * s.pulse * Math.sin(2 * th + L) +
|
||||
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
|
||||
const rr = r * (0.86 - L * 0.07) * w;
|
||||
const x = cx + Math.cos(th) * rr;
|
||||
const y = cy + Math.sin(th) * rr;
|
||||
if (j) ctx.lineTo(x, y);
|
||||
else ctx.moveTo(x, y);
|
||||
}
|
||||
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
|
||||
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
|
||||
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
|
||||
g.addColorStop(0, rgba(mix(cols[L]!, [255, 255, 255], 0.25), 0.85));
|
||||
g.addColorStop(1, rgba(cols[L]!, 0.25));
|
||||
ctx.fillStyle = g;
|
||||
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */
|
||||
export function step(s: WaveState, dt: number) {
|
||||
s.hover += (s.hoverT - s.hover) * 0.08;
|
||||
s.pressV += (s.pressT - s.press) * 0.35;
|
||||
s.pressV *= 0.62;
|
||||
s.press += s.pressV;
|
||||
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
|
||||
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
|
||||
s.t += dt * s.speed;
|
||||
if (s.playing && !s.reduced) {
|
||||
s.phase = (s.phase + (dt * s.bpm) / 60) % 1;
|
||||
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
|
||||
} else {
|
||||
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user