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; readAudio?: () => { bass: number; mid: number; treble: number }; 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 audioRef = useRef(props.readAudio); audioRef.current = props.readAudio; const canvasRef = useRef(null); const labelRef = useRef(null); const wrapRef = useRef(null); const st = useRef({ 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 livePulse = 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); const audio = !w.reduced && w.playing ? audioRef.current?.() : undefined; if (audioRef.current && !w.reduced) { const target = audio ? Math.min(1, audio.bass * 0.7 + audio.mid * 0.2) : 0; livePulse += (target - livePulse) * (1 - Math.exp(-dt * (target > livePulse ? 35 : 9))); w.pulse = livePulse; w.amp = audio ? Math.min(1, audio.mid * 0.8 + audio.treble * 0.7) : 0; } 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 * (1 + w.pulse * 0.055); const cx = S / 2 + w.mx * w.hover * 6; const cy = S / 2 + w.my * w.hover * 6; const ripple = audio ? w.amp * 0.025 * Math.sin(w.t * 13) : 0; ctx.translate(cx, cy); ctx.scale(1 + ripple, 1 - ripple); ctx.translate(-cx, -cy); 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 (
(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', }} >
{props.children}
); }