feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s

This commit is contained in:
mrleo1nid committed 2026-10-21 16:44:04 +03:00
1 parent 9223516547
commit 4c2286e70d
8 files changed
+144 -7

No files matched your search

+3 -1
View File
@@ -11,8 +11,9 @@
.ambient span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
will-change: transform;
scale: var(--music-scale, 1);
filter: blur(30px) brightness(var(--music-light, 1));
}
.a {
@@ -104,5 +105,6 @@
.ambient,
.ambient span {
animation: none;
scale: 1;
}
}
+26 -3
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react';
import { useEffect, useRef, type CSSProperties } from 'react';
import s from './Ambient.module.css';
@@ -6,9 +6,32 @@ import s from './Ambient.module.css';
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
*/
export function Ambient({ color }: { color: string }) {
export function Ambient({ color, readAudio }: {
color: string;
readAudio?: () => { bass: number; mid: number; treble: number };
}) {
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!readAudio) return;
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
let raf = 0;
let last = performance.now();
let level = 0;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const audio = !mq?.matches && !document.hidden ? readAudio() : undefined;
const target = audio ? audio.bass * 0.65 + audio.mid * 0.25 + audio.treble * 0.1 : 0;
level += (target - level) * (1 - Math.exp(-dt * 7));
root.current?.style.setProperty('--music-scale', String(1 + level * 0.16));
root.current?.style.setProperty('--music-light', String(1 + level * 0.3));
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [readAudio]);
return (
<div className={s.ambient} aria-hidden>
<div ref={root} className={s.ambient} aria-hidden>
<span className={s.a} />
<span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
+3 -2
View File
@@ -29,7 +29,7 @@ import {
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayerState } from '../player/context';
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
@@ -258,8 +258,9 @@ function Shell() {
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const player = usePlayer();
const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} readAudio={player.readAudio} />;
}
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
+16 -1
View File
@@ -5,6 +5,7 @@ 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];
@@ -28,6 +29,8 @@ function prefersReducedMotion(): boolean {
*/
export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320;
const audioRef = useRef(props.readAudio);
audioRef.current = props.readAudio;
const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@@ -55,6 +58,7 @@ export function WaveButton(props: WaveButtonProps) {
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);
@@ -67,12 +71,23 @@ export function WaveButton(props: WaveButtonProps) {
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;
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})`;
+1
View File
@@ -77,6 +77,7 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
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.018 * s.amp * Math.sin(11 * th + t * 18 + 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;