VoiceOrb.tsx
1,664 bytes
| 1 | import { useEffect, useRef } from 'react' |
|---|---|
| 2 | |
| 3 | export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking' |
| 4 | |
| 5 | interface VoiceOrbProps { |
| 6 | state: VoiceState |
| 7 | levelRef?: React.RefObject<number> |
| 8 | } |
| 9 | |
| 10 | export function VoiceOrb({ state, levelRef }: VoiceOrbProps) { |
| 11 | const ringRef = useRef<HTMLDivElement>(null) |
| 12 | |
| 13 | useEffect(() => { |
| 14 | if (state !== 'listening' || !levelRef) return |
| 15 | let rafId = 0 |
| 16 | const tick = () => { |
| 17 | const ring = ringRef.current |
| 18 | if (ring) { |
| 19 | const level = levelRef.current ?? 0 |
| 20 | ring.style.transform = `scale(${1 + level * 0.3})` |
| 21 | ring.style.opacity = String(0.25 + level * 0.6) |
| 22 | } |
| 23 | rafId = requestAnimationFrame(tick) |
| 24 | } |
| 25 | rafId = requestAnimationFrame(tick) |
| 26 | return () => { |
| 27 | cancelAnimationFrame(rafId) |
| 28 | const ring = ringRef.current |
| 29 | if (ring) { |
| 30 | ring.style.transform = '' |
| 31 | ring.style.opacity = '' |
| 32 | } |
| 33 | } |
| 34 | }, [state, levelRef]) |
| 35 | |
| 36 | return ( |
| 37 | <div className={`orb-wrap orb-${state}`} aria-hidden="true"> |
| 38 | <div ref={ringRef} className="orb-level-ring" /> |
| 39 | <div className="orb-ripple orb-ripple-1" /> |
| 40 | <div className="orb-ripple orb-ripple-2" /> |
| 41 | <div className="orb"> |
| 42 | <div className="orb-face"> |
| 43 | <div className="orb-eyes"> |
| 44 | <span className="orb-eye" /> |
| 45 | <span className="orb-eye" /> |
| 46 | </div> |
| 47 | <div className="orb-mouth"> |
| 48 | <span className="orb-mouth-bar" /> |
| 49 | <span className="orb-mouth-bar" /> |
| 50 | <span className="orb-mouth-bar" /> |
| 51 | <span className="orb-mouth-bar" /> |
| 52 | </div> |
| 53 | </div> |
| 54 | </div> |
| 55 | </div> |
| 56 | ) |
| 57 | } |
| 58 | |