File size: 4,267 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
import { useCallback, useEffect, useRef } from 'react';
import { Mic, Loader2 } from 'lucide-react';
import { useAudioRecorder } from '@/lib/hooks/use-audio-recorder';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
interface SpeechButtonProps {
onTranscription: (text: string) => void;
className?: string;
disabled?: boolean;
size?: 'sm' | 'md';
}
export function SpeechButton({
onTranscription,
className,
disabled,
size = 'sm',
}: SpeechButtonProps) {
const { t } = useI18n();
// Ref to always call the latest onTranscription, avoiding stale closures
const onTranscriptionRef = useRef(onTranscription);
useEffect(() => {
onTranscriptionRef.current = onTranscription;
}, [onTranscription]);
const stableOnTranscription = useCallback((text: string) => {
onTranscriptionRef.current(text);
}, []);
const handleError = useCallback((error: string) => {
toast.error(error);
}, []);
const { isRecording, isProcessing, startRecording, stopRecording } = useAudioRecorder({
onTranscription: stableOnTranscription,
onError: handleError,
});
const active = isRecording || isProcessing;
const handleClick = () => {
if (isRecording) {
stopRecording();
} else if (!isProcessing) {
startRecording();
}
};
const isMd = size === 'md';
const sizeClasses = isMd ? 'h-8 w-8' : 'h-6 w-6';
const iconSize = isMd ? 'w-4 h-4' : 'w-3.5 h-3.5';
const barH = isMd ? 14 : 10;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
disabled={disabled || isProcessing}
onClick={handleClick}
className={cn(
'relative flex items-center justify-center rounded-lg transition-all duration-200 shrink-0 cursor-pointer',
sizeClasses,
active
? 'bg-violet-500/90 dark:bg-violet-600/80 text-white shadow-[0_0_12px_rgba(139,92,246,0.45)] dark:shadow-[0_0_12px_rgba(139,92,246,0.3)]'
: 'text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/80',
disabled && 'opacity-40 pointer-events-none',
className,
)}
>
{/* Breathing ring when recording */}
{isRecording && (
<span
className="absolute inset-[-4px] rounded-[10px] border border-violet-400/40 dark:border-violet-400/25"
style={{
animation: 'speech-ring 2s ease-in-out infinite',
}}
/>
)}
{isProcessing ? (
<Loader2 className={cn(iconSize, 'animate-spin')} />
) : isRecording ? (
/* Mini equalizer bars */
<span className="flex items-center gap-[2.5px] relative z-10">
{[0, 1, 2].map((i) => (
<span
key={i}
className="rounded-full bg-white"
style={{
width: isMd ? 2.5 : 2,
animation: `speech-bar ${0.4 + i * 0.15}s ease-in-out ${i * 0.1}s infinite alternate`,
height: 3,
}}
/>
))}
</span>
) : (
<Mic className={cn(iconSize, 'relative z-10')} />
)}
{/* Injected keyframes */}
<style jsx>{`
@keyframes speech-bar {
0% {
height: 3px;
}
100% {
height: ${barH}px;
}
}
@keyframes speech-ring {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.08);
}
}
`}</style>
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isProcessing
? t('roundtable.processing')
: isRecording
? t('voice.stopListening')
: t('voice.startListening')}
</TooltipContent>
</Tooltip>
);
}
|