feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s
ci / ts / ts (packages + web) (push) Failing after 2m2s
This commit is contained in:
1 parent
9223516547
commit
4c2286e70d
8 files changed
+144
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user