254 lines
7.8 KiB
TypeScript
254 lines
7.8 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useRef, useEffect } from 'react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
|
||
interface VoiceRecorderProps {
|
||
onSendVoice: (audioUrl: string, duration: number) => void
|
||
}
|
||
|
||
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
|
||
const { user } = useAuth()
|
||
const [isRecording, setIsRecording] = useState(false)
|
||
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
|
||
const [duration, setDuration] = useState(0)
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
|
||
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||
const audioChunksRef = useRef<Blob[]>([])
|
||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||
|
||
useEffect(() => {
|
||
// Проверяем доступность микрофона
|
||
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
||
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
|
||
setPermission(result.state as 'granted' | 'denied' | 'prompt')
|
||
}).catch(() => {
|
||
// Если permissions API недоступен, оставляем prompt
|
||
})
|
||
}
|
||
}, [])
|
||
|
||
const startRecording = async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||
setPermission('granted')
|
||
|
||
const mediaRecorder = new MediaRecorder(stream, {
|
||
mimeType: 'audio/webm;codecs=opus'
|
||
})
|
||
|
||
mediaRecorderRef.current = mediaRecorder
|
||
audioChunksRef.current = []
|
||
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) {
|
||
audioChunksRef.current.push(event.data)
|
||
}
|
||
}
|
||
|
||
mediaRecorder.onstop = () => {
|
||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||
const audioUrl = URL.createObjectURL(audioBlob)
|
||
setRecordedAudio(audioUrl)
|
||
|
||
// Останавливаем все треки для освобождения микрофона
|
||
stream.getTracks().forEach(track => track.stop())
|
||
|
||
// Останавливаем таймер
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current)
|
||
intervalRef.current = null
|
||
}
|
||
}
|
||
|
||
mediaRecorder.start()
|
||
setIsRecording(true)
|
||
setDuration(0)
|
||
|
||
// Запускаем таймер записи
|
||
intervalRef.current = setInterval(() => {
|
||
setDuration(prev => prev + 1)
|
||
}, 1000)
|
||
|
||
} catch (error) {
|
||
console.error('Error accessing microphone:', error)
|
||
setPermission('denied')
|
||
}
|
||
}
|
||
|
||
const stopRecording = () => {
|
||
if (mediaRecorderRef.current && isRecording) {
|
||
mediaRecorderRef.current.stop()
|
||
setIsRecording(false)
|
||
}
|
||
}
|
||
|
||
const cancelRecording = () => {
|
||
if (mediaRecorderRef.current && isRecording) {
|
||
mediaRecorderRef.current.stop()
|
||
setIsRecording(false)
|
||
}
|
||
setRecordedAudio(null)
|
||
setDuration(0)
|
||
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current)
|
||
intervalRef.current = null
|
||
}
|
||
}
|
||
|
||
const playRecording = () => {
|
||
if (recordedAudio) {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause()
|
||
audioRef.current.currentTime = 0
|
||
}
|
||
|
||
audioRef.current = new Audio(recordedAudio)
|
||
audioRef.current.play()
|
||
setIsPlaying(true)
|
||
|
||
audioRef.current.onended = () => {
|
||
setIsPlaying(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
const sendVoiceMessage = async () => {
|
||
if (!recordedAudio || !user?.id) return
|
||
|
||
try {
|
||
// Конвертируем Blob в File для загрузки
|
||
const response = await fetch(recordedAudio)
|
||
const blob = await response.blob()
|
||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||
|
||
// Загружаем в S3
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('userId', user.id)
|
||
|
||
const uploadResponse = await fetch('/api/upload-voice', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
if (!uploadResponse.ok) {
|
||
throw new Error('Failed to upload voice message')
|
||
}
|
||
|
||
const result = await uploadResponse.json()
|
||
|
||
// Отправляем голосовое сообщение
|
||
onSendVoice(result.url, duration)
|
||
|
||
// Очищаем состояние
|
||
setRecordedAudio(null)
|
||
setDuration(0)
|
||
|
||
} catch (error) {
|
||
console.error('Error sending voice message:', error)
|
||
}
|
||
}
|
||
|
||
const formatDuration = (seconds: number) => {
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = seconds % 60
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
if (permission === 'denied') {
|
||
return (
|
||
<div className="text-white/60 text-xs text-center p-2">
|
||
Доступ к микрофону запрещен
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center space-x-2">
|
||
{!recordedAudio ? (
|
||
// Состояние записи
|
||
<>
|
||
{!isRecording ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={startRecording}
|
||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||
>
|
||
<Mic className="h-4 w-4" />
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
|
||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||
<span className="text-white text-sm font-mono">
|
||
{formatDuration(duration)}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={stopRecording}
|
||
className="text-white/80 hover:text-white p-1"
|
||
>
|
||
<Square className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={cancelRecording}
|
||
className="text-red-400 hover:text-red-300 p-1"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
// Состояние воспроизведения и отправки
|
||
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={playRecording}
|
||
className="text-blue-300 hover:text-blue-200 p-1"
|
||
>
|
||
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
|
||
</Button>
|
||
<span className="text-white text-sm font-mono">
|
||
{formatDuration(duration)}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={sendVoiceMessage}
|
||
className="text-green-400 hover:text-green-300 p-1"
|
||
>
|
||
<Send className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setRecordedAudio(null)}
|
||
className="text-red-400 hover:text-red-300 p-1"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|