95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useRef, type MouseEvent } from 'react';
|
|
import { fileToBase64 } from '@/lib/copilot';
|
|
|
|
interface VoiceInputProps {
|
|
onVoice: (audioBase64: string, mimeType: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
SpeechRecognition?: new () => SpeechRecognitionLike;
|
|
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
|
|
}
|
|
}
|
|
|
|
interface SpeechRecognitionLike {
|
|
start: () => void;
|
|
stop: () => void;
|
|
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
|
onerror: (() => void) | null;
|
|
onend: (() => void) | null;
|
|
}
|
|
|
|
interface SpeechRecognitionEventLike {
|
|
results: { length: number; [key: number]: { 0: { transcript: string } } };
|
|
}
|
|
|
|
export function VoiceInput({ onVoice, disabled }: VoiceInputProps) {
|
|
const [recording, setRecording] = useState(false);
|
|
const [supported, setSupported] = useState(true);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
|
|
const handleStartRecording = useCallback(async () => {
|
|
if (disabled) return;
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const recorder = new MediaRecorder(stream);
|
|
chunksRef.current = [];
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
recorder.onstop = async () => {
|
|
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
|
const base64 = await fileToBase64(blob);
|
|
onVoice(base64, 'audio/webm');
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
};
|
|
recorder.start();
|
|
mediaRecorderRef.current = recorder;
|
|
setRecording(true);
|
|
} catch {
|
|
setSupported(false);
|
|
}
|
|
}, [disabled, onVoice]);
|
|
|
|
const handleStopRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current && recording) {
|
|
mediaRecorderRef.current.stop();
|
|
setRecording(false);
|
|
}
|
|
}, [recording]);
|
|
|
|
const handleToggle = useCallback(
|
|
(e: MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault();
|
|
if (recording) handleStopRecording();
|
|
else handleStartRecording();
|
|
},
|
|
[recording, handleStartRecording, handleStopRecording]
|
|
);
|
|
|
|
if (!supported) {
|
|
return (
|
|
<button disabled className="bg-gray-300 text-gray-500 px-3 py-2 rounded-lg cursor-not-allowed" data-testid="voice-button">
|
|
🎤
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleToggle}
|
|
disabled={disabled}
|
|
className={`${recording ? 'bg-red-600 animate-pulse' : 'bg-gray-200 hover:bg-gray-300'} text-gray-800 px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
title={recording ? 'Aufnahme stoppen' : 'Sprachnachricht aufnehmen'}
|
|
data-testid="voice-button"
|
|
>
|
|
🎤
|
|
</button>
|
|
);
|
|
}
|