{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "visualizer-button", "type": "registry:ui", "title": "Visualizer Button", "description": "iOS 26 liquid-glass audio pill with canvas equalizer and Web Audio API integration.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/visualizer-button.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/* ── sound ── */\nlet _sa: AudioContext, _sb: AudioBuffer;\nconst tick = () => {\n if (typeof window === \"undefined\") return;\n if (!_sa) {\n _sa = new AudioContext();\n _sb = _sa.createBuffer(1, (_sa.sampleRate * 0.003) | 0, _sa.sampleRate);\n const d = _sb.getChannelData(0);\n for (let i = 0; i < d.length; i++)\n d[i] = (Math.random() * 2 - 1) * (1 - i / d.length) ** 4;\n }\n const s = _sa.createBufferSource();\n s.buffer = _sb;\n const g = _sa.createGain();\n g.gain.value = 0.08;\n s.connect(g).connect(_sa.destination);\n s.start();\n};\n\n/* ── theme ── */\nconst CSS = `\n.vb{\n --vb-glass:linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.62));\n --vb-border:rgba(0,0,0,0.06);\n --vb-shadow:0 0 1px rgba(0,0,0,0.04),0 2px 8px rgba(0,0,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);\n --vb-hi:rgba(0,0,0,0.88);\n --vb-dim:rgba(0,0,0,0.35)\n}\n.dark .vb,[data-theme=\"dark\"] .vb{\n --vb-glass:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));\n --vb-border:rgba(255,255,255,0.07);\n --vb-shadow:0 1px 3px rgba(0,0,0,0.08),inset 0 1px 0 rgba(255,255,255,0.04);\n --vb-hi:rgba(255,255,255,0.88);\n --vb-dim:rgba(255,255,255,0.3)\n}`;\n\n/* ── canvas pill helper ── */\nfunction pill(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n h: number,\n r: number,\n) {\n const radius = Math.min(r, w / 2, h / 2);\n ctx.beginPath();\n ctx.moveTo(x + radius, y);\n ctx.lineTo(x + w - radius, y);\n ctx.arcTo(x + w, y, x + w, y + radius, radius);\n ctx.lineTo(x + w, y + h - radius);\n ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);\n ctx.lineTo(x + radius, y + h);\n ctx.arcTo(x, y + h, x, y + h - radius, radius);\n ctx.lineTo(x, y + radius);\n ctx.arcTo(x, y, x + radius, y, radius);\n ctx.closePath();\n ctx.fill();\n}\n\n/* ── component ── */\nexport interface VisualizerButtonProps {\n audioSrc: string;\n bars?: number;\n barWidth?: number;\n barGap?: number;\n barRadius?: number;\n height?: number;\n sound?: boolean;\n style?: React.CSSProperties;\n}\n\nexport default function VisualizerButton({\n audioSrc,\n bars = 14,\n barWidth = 3,\n barGap = 2,\n barRadius = 1.5,\n height = 20,\n sound = true,\n style,\n}: VisualizerButtonProps) {\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(0);\n const audioRef = React.useRef(null);\n const analyserRef = React.useRef(null);\n const actxRef = React.useRef(null);\n const srcRef = React.useRef(null);\n const dataRef = React.useRef | null>(null);\n const [playing, setPlaying] = React.useState(false);\n const cw = bars * barWidth + (bars - 1) * barGap;\n\n React.useEffect(() => {\n const el = new Audio(audioSrc);\n el.crossOrigin = \"anonymous\";\n audioRef.current = el;\n el.addEventListener(\"ended\", () => setPlaying(false));\n return () => {\n el.pause();\n el.src = \"\";\n };\n }, [audioSrc]);\n\n React.useEffect(() => {\n const cvs = canvasRef.current;\n if (!cvs) return;\n const ctx = cvs.getContext(\"2d\");\n if (!ctx) return;\n const dpr = window.devicePixelRatio || 1;\n cvs.width = Math.round(cw * dpr);\n cvs.height = Math.round(height * dpr);\n const t0 = performance.now();\n\n const paint = (now: number) => {\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, cw, height);\n const computed = getComputedStyle(cvs);\n ctx.fillStyle = computed.color;\n const t = (now - t0) / 1000;\n\n let hasFreq = false;\n if (playing && analyserRef.current) {\n const an = analyserRef.current;\n if (!dataRef.current || dataRef.current.length !== an.frequencyBinCount)\n dataRef.current = new Uint8Array(\n an.frequencyBinCount,\n ) as Uint8Array;\n an.getByteFrequencyData(dataRef.current);\n for (let j = 0; j < dataRef.current.length; j++) {\n if (dataRef.current[j] > 0) {\n hasFreq = true;\n break;\n }\n }\n }\n\n for (let i = 0; i < bars; i++) {\n const bx = i * (barWidth + barGap);\n let bh = 3;\n let alpha = 1;\n if (playing && hasFreq && dataRef.current) {\n const idx = Math.floor((i / bars) * dataRef.current.length);\n bh = Math.max(3, (dataRef.current[idx] / 255) * height * 0.95);\n } else if (playing) {\n bh = Math.max(\n 3,\n Math.abs(Math.sin(t * 3.2 + i * 0.7)) * height * 0.92,\n );\n } else {\n alpha = 0.3;\n }\n ctx.globalAlpha = alpha;\n pill(ctx, bx, height - bh, barWidth, bh, barRadius);\n }\n ctx.globalAlpha = 1;\n frameRef.current = requestAnimationFrame(paint);\n };\n\n frameRef.current = requestAnimationFrame(paint);\n return () => cancelAnimationFrame(frameRef.current);\n }, [playing, bars, barWidth, barGap, barRadius, height, cw]);\n\n const toggle = () => {\n const audio = audioRef.current;\n if (!audio) return;\n if (playing) {\n audio.pause();\n setPlaying(false);\n if (sound) tick();\n return;\n }\n if (!actxRef.current) {\n try {\n const actx = new AudioContext();\n actxRef.current = actx;\n const source = actx.createMediaElementSource(audio);\n srcRef.current = source;\n const analyser = actx.createAnalyser();\n analyser.fftSize = 256;\n analyser.smoothingTimeConstant = 0.8;\n source.connect(analyser);\n analyser.connect(actx.destination);\n analyserRef.current = analyser;\n } catch {}\n }\n audio.play().catch(() => {});\n setPlaying(true);\n if (sound) tick();\n };\n\n return (\n <>\n