- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
(function () {
|
|
const R = window.React;
|
|
const { useRef, useEffect } = R;
|
|
|
|
function rgb(hex) {
|
|
const h = hex.replace('#', '');
|
|
const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
|
const mix = (a, b, k) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k].map(Math.round);
|
|
|
|
function drawOrb(ctx, cx, cy, r, s, t) {
|
|
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
|
|
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.10 + 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];
|
|
for (let i = 0; i < blobs.length; 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(blobs[i], 0.55));
|
|
g.addColorStop(1, rgba(blobs[i], 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);
|
|
x === -r ? ctx.moveTo(cx + x, y) : 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();
|
|
}
|
|
|
|
function drawRings(ctx, cx, cy, r, s, t) {
|
|
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
|
|
const N = 8;
|
|
for (let i = 0; i < N; i++) {
|
|
const k = ((t * 0.12 * s.speed + i / N) % 1);
|
|
const rr = r * (0.58 + k * 0.95);
|
|
const a = Math.pow(1 - k, 1.6) * (0.5 + 0.3 * s.amp);
|
|
ctx.beginPath();
|
|
for (let j = 0; j <= 120; j++) {
|
|
const th = (j / 120) * Math.PI * 2;
|
|
const pull = s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 4) * r * 0.08 * (1 - k);
|
|
const d = Math.sin(th * 6 + t * 2 + i) * r * 0.012 * (1 + s.amp * 2 + s.pulse * 4) + pull;
|
|
const x = cx + Math.cos(th) * (rr + d), y = cy + Math.sin(th) * (rr + d);
|
|
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
}
|
|
ctx.strokeStyle = rgba(mix(c1, c3, k), a);
|
|
ctx.lineWidth = 1.5 + (1 - k) * 2;
|
|
ctx.stroke();
|
|
}
|
|
const cr = r * (0.58 + s.pulse * 0.07 + 0.015 * Math.sin(t * 1.4));
|
|
const glow = ctx.createRadialGradient(cx, cy, cr * 0.5, cx, cy, cr * 1.9);
|
|
glow.addColorStop(0, rgba(c1, 0.45 + s.pulse * 0.3));
|
|
glow.addColorStop(1, rgba(c1, 0));
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath(); ctx.arc(cx, cy, cr * 1.9, 0, Math.PI * 2); ctx.fill();
|
|
const g = ctx.createLinearGradient(cx - cr, cy - cr, cx + cr, cy + cr);
|
|
g.addColorStop(0, rgba(mix(c1, [255, 255, 255], 0.15), 1));
|
|
g.addColorStop(0.6, rgba(c1, 1));
|
|
g.addColorStop(1, rgba(c2, 1));
|
|
ctx.fillStyle = g;
|
|
ctx.beginPath(); ctx.arc(cx + s.mx * 4 * s.hover, cy + s.my * 4 * s.hover, cr, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
|
|
function drawLiquid(ctx, cx, cy, r, s, t) {
|
|
const cols = [rgb(s.color), rgb(s.color2), rgb(s.color3)];
|
|
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, y = cy + Math.sin(th) * rr;
|
|
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
}
|
|
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3, 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';
|
|
}
|
|
|
|
function WaveButton(props) {
|
|
const size = props.size || 360;
|
|
const canvasRef = useRef(null), labelRef = useRef(null), 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 });
|
|
const p = st.current;
|
|
p.variant = props.variant || 'orb';
|
|
p.playing = !!props.playing;
|
|
p.bpm = props.bpm || 100;
|
|
p.energy = props.energy == null ? 0.6 : props.energy;
|
|
p.color = props.color || '#2F80FF';
|
|
p.color2 = props.color2 || '#7C5CFF';
|
|
p.color3 = props.color3 || '#35D3FF';
|
|
p.dark = props.theme !== 'light';
|
|
p.reduced = !!props.reduced;
|
|
p.size = size;
|
|
|
|
useEffect(() => {
|
|
let raf, last = performance.now();
|
|
const cv = canvasRef.current, ctx = cv.getContext('2d');
|
|
const loop = (now) => {
|
|
const dt = Math.min(0.05, (now - last) / 1000); last = now;
|
|
const s = st.current;
|
|
const S = s.size * 1.6, dpr = window.devicePixelRatio || 1;
|
|
if (cv.width !== Math.round(S * dpr)) { cv.width = Math.round(S * dpr); cv.height = Math.round(S * dpr); }
|
|
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;
|
|
}
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, S, S);
|
|
const sc = 1 - s.press * 0.07 + s.hover * 0.02;
|
|
const r = s.size * 0.5 * sc;
|
|
const cx = S / 2 + s.mx * s.hover * 6, cy = S / 2 + s.my * s.hover * 6;
|
|
if (s.variant === 'rings') drawRings(ctx, cx, cy, r * 0.62, s, s.t);
|
|
else if (s.variant === 'liquid') drawLiquid(ctx, cx, cy, r, s, s.t);
|
|
else drawOrb(ctx, cx, cy, r, s, s.t);
|
|
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + s.pulse * 0.03})`;
|
|
raf = requestAnimationFrame(loop);
|
|
};
|
|
raf = requestAnimationFrame(loop);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, []);
|
|
|
|
const onMove = (e) => {
|
|
const b = wrapRef.current.getBoundingClientRect();
|
|
const x = (e.clientX - b.left) / b.width * 2 - 1, y = (e.clientY - b.top) / b.height * 2 - 1;
|
|
p.mx = x; p.my = y; p.ang = Math.atan2(y, x);
|
|
};
|
|
const interactive = props.interactive !== false;
|
|
return R.createElement('div', {
|
|
ref: wrapRef,
|
|
role: interactive ? 'button' : undefined,
|
|
tabIndex: interactive ? 0 : undefined,
|
|
'aria-label': props.ariaLabel || 'Моя волна',
|
|
onMouseMove: interactive ? onMove : undefined,
|
|
onMouseEnter: interactive ? () => { p.hoverT = 1; } : undefined,
|
|
onMouseLeave: interactive ? () => { p.hoverT = 0; p.pressT = 0; } : undefined,
|
|
onMouseDown: interactive ? () => { p.pressT = 1; } : undefined,
|
|
onMouseUp: interactive ? () => { p.pressT = 0; p.pressV -= 0.12; } : undefined,
|
|
onClick: interactive ? props.onClick : undefined,
|
|
onKeyDown: interactive ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onClick && props.onClick(); } } : undefined,
|
|
style: { position: 'relative', width: size, height: size, borderRadius: '50%', cursor: interactive ? 'pointer' : 'default', outline: 'none', flexShrink: 0, pointerEvents: interactive ? 'auto' : 'none' }
|
|
},
|
|
R.createElement('canvas', { ref: canvasRef, style: { position: 'absolute', left: -size * 0.3, top: -size * 0.3, width: size * 1.6, height: size * 1.6, pointerEvents: 'none' } }),
|
|
R.createElement('div', { ref: labelRef, style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' } }, props.children)
|
|
);
|
|
}
|
|
|
|
window.WaveButton = WaveButton;
|
|
if (typeof module !== 'undefined') module.exports = { WaveButton };
|
|
})();
|