"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; import { useAudioAnalyser } from "@/hooks/use-audio-analyser"; import { adaptPalette, cyclicColor, isDarkTheme, readPalette, rgba, } from "@/lib/visualizer-palette"; export type NebulaVisualizerProps = { /** 是否激活:true 时采集麦克风并随音频律动,false 时显示静态呼吸态 */ active?: boolean; /** 外部分析器;提供后组件不再自行申请麦克风 */ analyser?: AnalyserNode | null; /** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */ stream?: MediaStream | null; /** 画布直径(px) */ size?: number; /** 粒子数量 */ particleCount?: number; /** 申请麦克风失败时回调 */ onError?: (error: unknown) => void; className?: string; }; type Particle = { /** 当前角度(rad) */ ang: number; /** 角速度(rad/s,带方向) */ vel: number; /** 基础轨道半径(占画布尺寸比例) */ baseR: number; /** 呼吸相位偏移 */ phase: number; /** 基础粒径(px @220 画布) */ sz: number; /** 在调色板上的取色位置 */ hue: number; /** 平滑后的所在频段能量 */ v: number; }; /** * 星云:一群沿环形轨道缓慢漂移的发光粒子,带运动拖尾。 * 静态时如星环般缓慢呼吸流转;激活后粒子按所在方位 * 对应的频段能量加速、外扩、增亮。 */ export function NebulaVisualizer({ active = false, analyser = null, stream = null, size = 220, particleCount = 140, onError, className, }: NebulaVisualizerProps) { const canvasRef = React.useRef(null); const particlesRef = React.useRef([]); const analyserRef = useAudioAnalyser({ active, analyser, stream, onError }); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = size * dpr; canvas.height = size * dpr; ctx.scale(dpr, dpr); const TAU = Math.PI * 2; if (particlesRef.current.length !== particleCount) { particlesRef.current = Array.from({ length: particleCount }, () => ({ ang: Math.random() * TAU, vel: (0.08 + Math.random() * 0.22) * (Math.random() < 0.5 ? -1 : 1), baseR: 0.27 + Math.random() * 0.15, phase: Math.random() * TAU, sz: 0.7 + Math.random() * 1.5, hue: Math.random(), v: 0, })); } const particles = particlesRef.current; const cx = size / 2; const cy = size / 2; const scale = size / 220; const freq = new Uint8Array(256); const dt = 0.016; let raf = 0; let t = 0; let energy = 0; const draw = () => { t += dt; const dark = isDarkTheme(); const palette = adaptPalette(readPalette(canvas), dark); const { sky, lav } = palette; const node = analyserRef.current; let level = 0; if (node) { node.getByteFrequencyData(freq); const bins = Math.floor(freq.length * 0.6); let sum = 0; for (let i = 0; i < bins; i++) sum += freq[i]; level = sum / bins / 255; } energy += (level - energy) * (level > energy ? 0.3 : 0.08); const breathe = 0.5 + 0.5 * Math.sin(t * 0.8); // 用 destination-out 让上一帧整体淡出,留下运动拖尾 ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; ctx.fillRect(0, 0, size, size); ctx.globalCompositeOperation = "source-over"; // 中心柔光逐帧低强度补画,与淡出达到稳态平衡 const glowR = size * (0.16 + 0.02 * breathe) * (1 + energy * 0.6); const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR * 2); glow.addColorStop(0, rgba(sky, 0.045 + energy * 0.09)); glow.addColorStop(0.6, rgba(lav, 0.02 + energy * 0.04)); glow.addColorStop(1, rgba(lav, 0)); ctx.fillStyle = glow; ctx.fillRect(0, 0, size, size); for (const p of particles) { p.ang += p.vel * dt * (1 + energy * 2.2); // 粒子方位映射到频段(左右镜像,低频在顶部) const a01 = (((p.ang + Math.PI / 2) % TAU) + TAU) % TAU / TAU; const m = a01 < 0.5 ? a01 * 2 : (1 - a01) * 2; let target = 0; if (node) { const bin = Math.floor(Math.pow(m, 1.5) * freq.length * 0.6); target = Math.pow(freq[bin] / 255, 1.3); } p.v += (target - p.v) * (target > p.v ? 0.3 : 0.1); const wobble = 0.016 * Math.sin(t * 0.9 + p.phase) + 0.014 * (breathe - 0.5); const rad = (p.baseR + wobble + p.v * 0.1) * size; const x = cx + Math.cos(p.ang) * rad; const y = cy + Math.sin(p.ang) * rad; const color = cyclicColor(palette, p.hue + t * 0.02); const lum = 0.3 + 0.2 * (0.5 + 0.5 * Math.sin(t * 1.3 + p.phase)) + 0.55 * p.v; ctx.fillStyle = rgba(color, Math.min(1, lum + (dark ? 0 : 0.12))); ctx.shadowColor = rgba(color, 0.7); ctx.shadowBlur = 3 + p.v * 12; ctx.beginPath(); ctx.arc(x, y, p.sz * scale * (1 + p.v * 1.4), 0, TAU); ctx.fill(); } ctx.shadowBlur = 0; raf = requestAnimationFrame(draw); }; raf = requestAnimationFrame(draw); return () => cancelAnimationFrame(raf); }, [size, particleCount, analyserRef]); return ( ); }