|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import React from 'react' |
4 | | -import { cn, Mic, Tooltip } from '@sim/emcn' |
| 3 | +import { memo, type RefObject, useEffect, useRef } from 'react' |
| 4 | +import { Button, cn, Tooltip, usePrefersReducedMotion } from '@sim/emcn' |
| 5 | +import { Mic } from '@sim/emcn/icons' |
| 6 | + |
| 7 | +const WAVEFORM_BAR_COUNT = 5 |
| 8 | +const WAVEFORM_MIN_HEIGHT = 3 |
| 9 | +const WAVEFORM_MAX_HEIGHT = 14 |
| 10 | +const WAVEFORM_CENTER = 9 |
| 11 | +const WAVEFORM_EASING = 0.24 |
5 | 12 |
|
6 | 13 | interface MicButtonProps { |
7 | 14 | isListening: boolean |
| 15 | + audioLevelsRef: RefObject<Float32Array> |
8 | 16 | onToggle: () => void |
9 | 17 | } |
10 | 18 |
|
11 | | -export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) { |
| 19 | +interface VoiceWaveformProps { |
| 20 | + audioLevelsRef: RefObject<Float32Array> |
| 21 | + isListening: boolean |
| 22 | +} |
| 23 | + |
| 24 | +function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) { |
| 25 | + const prefersReducedMotion = usePrefersReducedMotion() |
| 26 | + const barRefs = useRef<Array<SVGLineElement | null>>([]) |
| 27 | + |
| 28 | + useEffect(() => { |
| 29 | + if (!isListening || prefersReducedMotion) return |
| 30 | + |
| 31 | + const heights = new Float32Array(WAVEFORM_BAR_COUNT).fill(WAVEFORM_MIN_HEIGHT) |
| 32 | + let animationFrameId = 0 |
| 33 | + |
| 34 | + const draw = () => { |
| 35 | + const levels = audioLevelsRef.current |
| 36 | + |
| 37 | + for (let index = 0; index < WAVEFORM_BAR_COUNT; index++) { |
| 38 | + const level = levels?.[index] ?? 0 |
| 39 | + const targetHeight = |
| 40 | + WAVEFORM_MIN_HEIGHT + |
| 41 | + Math.sqrt(Math.max(0, level)) * (WAVEFORM_MAX_HEIGHT - WAVEFORM_MIN_HEIGHT) |
| 42 | + heights[index] += (targetHeight - heights[index]) * WAVEFORM_EASING |
| 43 | + |
| 44 | + const bar = barRefs.current[index] |
| 45 | + if (!bar) continue |
| 46 | + const halfHeight = heights[index] / 2 |
| 47 | + bar.setAttribute('y1', String(WAVEFORM_CENTER - halfHeight)) |
| 48 | + bar.setAttribute('y2', String(WAVEFORM_CENTER + halfHeight)) |
| 49 | + } |
| 50 | + |
| 51 | + animationFrameId = window.requestAnimationFrame(draw) |
| 52 | + } |
| 53 | + |
| 54 | + animationFrameId = window.requestAnimationFrame(draw) |
| 55 | + return () => window.cancelAnimationFrame(animationFrameId) |
| 56 | + }, [audioLevelsRef, isListening, prefersReducedMotion]) |
| 57 | + |
| 58 | + return ( |
| 59 | + <svg aria-hidden viewBox='0 0 18 18' className='size-[18px] overflow-hidden'> |
| 60 | + {Array.from({ length: WAVEFORM_BAR_COUNT }, (_, index) => { |
| 61 | + const x = 3 + index * 3 |
| 62 | + return ( |
| 63 | + <line |
| 64 | + key={x} |
| 65 | + ref={(element) => { |
| 66 | + barRefs.current[index] = element |
| 67 | + }} |
| 68 | + x1={x} |
| 69 | + x2={x} |
| 70 | + y1={WAVEFORM_CENTER - WAVEFORM_MIN_HEIGHT / 2} |
| 71 | + y2={WAVEFORM_CENTER + WAVEFORM_MIN_HEIGHT / 2} |
| 72 | + stroke='currentColor' |
| 73 | + strokeLinecap='round' |
| 74 | + strokeWidth='1.7' |
| 75 | + /> |
| 76 | + ) |
| 77 | + })} |
| 78 | + </svg> |
| 79 | + ) |
| 80 | +} |
| 81 | + |
| 82 | +export const MicButton = memo(function MicButton({ |
| 83 | + isListening, |
| 84 | + audioLevelsRef, |
| 85 | + onToggle, |
| 86 | +}: MicButtonProps) { |
12 | 87 | return ( |
13 | 88 | <Tooltip.Root> |
14 | 89 | <Tooltip.Trigger asChild> |
15 | | - <button |
| 90 | + <Button |
16 | 91 | type='button' |
| 92 | + variant={isListening ? 'active' : 'ghost'} |
17 | 93 | onClick={onToggle} |
18 | 94 | aria-label={isListening ? 'Stop listening' : 'Voice input'} |
| 95 | + aria-pressed={isListening} |
19 | 96 | className={cn( |
20 | | - 'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors', |
21 | | - isListening |
22 | | - ? 'bg-red-500 text-white hover:bg-red-600' |
23 | | - : 'text-[var(--text-icon)] hover:bg-[#F7F7F7] dark:hover:bg-[#303030]' |
| 97 | + 'relative size-[28px] overflow-hidden rounded-full p-0 transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100', |
| 98 | + !isListening && |
| 99 | + 'text-[var(--text-icon)] hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-icon)]' |
24 | 100 | )} |
25 | 101 | > |
26 | | - <Mic className='h-[16px] w-[16px]' /> |
27 | | - </button> |
| 102 | + <span |
| 103 | + className={cn( |
| 104 | + 'absolute inset-0 flex items-center justify-center transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none', |
| 105 | + isListening ? 'scale-100 opacity-100 blur-0' : 'scale-[0.25] opacity-0 blur-[4px]' |
| 106 | + )} |
| 107 | + > |
| 108 | + <VoiceWaveform audioLevelsRef={audioLevelsRef} isListening={isListening} /> |
| 109 | + </span> |
| 110 | + <Mic |
| 111 | + className={cn( |
| 112 | + 'size-[16px] transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none', |
| 113 | + isListening ? 'scale-[0.25] opacity-0 blur-[4px]' : 'scale-100 opacity-100 blur-0' |
| 114 | + )} |
| 115 | + /> |
| 116 | + </Button> |
28 | 117 | </Tooltip.Trigger> |
29 | 118 | <Tooltip.Content side='top'>{isListening ? 'Stop listening' : 'Voice input'}</Tooltip.Content> |
30 | 119 | </Tooltip.Root> |
|
0 commit comments