/
nayvok
/
livecoding
Обзор
Документация
Войти
/
nayvok
/
livecoding
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
frontend/src/components/VideoPanel.tsx
230 строк
7 KB
nayvok
fix: harden sessions and WebRTC recovery
17 июн 2026, 22:02
17 июн 2026, 22:02
37b9a92
Код
Авторство
О чём код?
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react' import { Camera, CameraOff, Mic, MicOff } from 'lucide-react' import { useWebRTC } from '@/hooks/useWebRTC' import { useActiveSpeaker } from '@/hooks/useActiveSpeaker' import { Button } from '@/components/ui/button' import { startAutomaticRemotePlayback } from '@/lib/remotePlayback' export interface VideoPanelHandle { closeRemotePeer: () => void } export const VideoPanel = forwardRef<VideoPanelHandle>(function VideoPanel(_props, ref) { const { localStream, remoteStream, mediaError, hasLocalVideoTrack, isLocalCameraOn, isLocalMicrophoneOn, isRemoteCameraOn, isRemoteMicrophoneOn, isRemotePeerDisconnected, connectionState, iceConnectionState, toggleCamera, toggleMicrophone, closeRemotePeer, } = useWebRTC() useImperativeHandle(ref, () => ({ closeRemotePeer }), [closeRemotePeer]) const localVideoRef = useRef<HTMLVideoElement>(null) const remoteVideoRef = useRef<HTMLVideoElement>(null) const [isRemoteAudioEnabled, setIsRemoteAudioEnabled] = useState(false) const isLocalSpeaking = useActiveSpeaker(localStream) const isRemoteSpeaking = useActiveSpeaker(remoteStream) useEffect(() => { if (localVideoRef.current) localVideoRef.current.srcObject = localStream }, [localStream]) useEffect(() => { const video = remoteVideoRef.current if (!video) return setIsRemoteAudioEnabled(false) if (!remoteStream) { video.srcObject = null video.muted = true return } return startAutomaticRemotePlayback( video, remoteStream, window, (result) => { setIsRemoteAudioEnabled(result === 'playing-with-audio') }, ) }, [remoteStream]) const statusLabel = describeStatus(connectionState, iceConnectionState, !!remoteStream) return ( <div className="h-full flex flex-col gap-2 p-2 min-h-0"> <VideoTile videoRef={remoteVideoRef} muted={!isRemoteAudioEnabled} label="Собеседник" empty={remoteStream ? null : 'Ожидание собеседника'} cameraOn={isRemoteCameraOn !== false} microphoneOn={isRemoteMicrophoneOn !== false} speaking={isRemoteSpeaking && isRemoteMicrophoneOn !== false} disconnected={isRemotePeerDisconnected} /> <VideoTile videoRef={localVideoRef} muted={true} label="Вы" empty={ localStream ? null : mediaError ? 'Камера и микрофон недоступны' : 'Подключение камеры' } cameraOn={isLocalCameraOn} microphoneOn={isLocalMicrophoneOn} speaking={isLocalSpeaking && isLocalMicrophoneOn} disconnected={false} /> <div className="text-xs text-muted-foreground px-1 flex items-center justify-between gap-2 min-h-4"> <span>{statusLabel}</span> </div> <div className="flex gap-2"> <Button variant={isLocalCameraOn ? 'default' : 'outline'} size="sm" className="flex-1" onClick={() => { void toggleCamera() }} disabled={!localStream} aria-pressed={isLocalCameraOn} > {isLocalCameraOn ? <Camera /> : <CameraOff />} <span> {isLocalCameraOn ? 'Камера' : hasLocalVideoTrack ? 'Камера выкл' : 'Включить камеру'} </span> </Button> <Button variant={isLocalMicrophoneOn ? 'default' : 'outline'} size="sm" className="flex-1" onClick={() => { void toggleMicrophone() }} disabled={!localStream} aria-pressed={isLocalMicrophoneOn} > {isLocalMicrophoneOn ? <Mic /> : <MicOff />} <span>{isLocalMicrophoneOn ? 'Микрофон' : 'Микрофон выкл'}</span> </Button> </div> {mediaError && localStream === null && ( <p className="px-1 text-xs leading-relaxed text-destructive"> {mediaError} </p> )} </div> ) }) interface VideoTileProps { videoRef: React.RefObject<HTMLVideoElement | null> muted: boolean label: string empty: string | null cameraOn: boolean microphoneOn: boolean speaking: boolean disconnected: boolean } function VideoTile({ videoRef, muted, label, empty, cameraOn, microphoneOn, speaking, disconnected, }: VideoTileProps) { const borderClass = speaking && !disconnected ? 'ring-2 ring-emerald-500 ring-offset-0' : 'ring-0' return ( <div className={`relative rounded-md overflow-hidden bg-black aspect-video shrink-0 transition-shadow ${borderClass}`} > <video ref={videoRef} autoPlay playsInline muted={muted} className={`w-full h-full object-cover ${cameraOn && !disconnected ? '' : 'opacity-0'}`} /> {disconnected ? ( <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/70 text-sm text-white/90"> <span className="font-medium">Собеседник отключился</span> <span className="text-[11px] text-white/60"> ожидание восстановления соединения </span> </div> ) : ( <> {!cameraOn && ( <div className="absolute inset-0 flex items-center justify-center text-xs text-white/70"> {empty ?? 'Камера отключена'} </div> )} {cameraOn && empty && ( <div className="absolute inset-0 flex items-center justify-center text-xs text-white/70"> {empty} </div> )} </> )} <div className="absolute bottom-1 left-1 right-1 flex items-center justify-between gap-1 text-[10px] text-white/90 pointer-events-none"> <span className="bg-black/50 rounded px-1.5 py-0.5">{label}</span> {!microphoneOn && !disconnected && ( <span className="bg-black/60 rounded px-1.5 py-0.5 flex items-center gap-1"> <MicOff className="size-3" /> <span>микрофон выкл</span> </span> )} </div> </div> ) } function describeStatus( connection: RTCPeerConnectionState | null, ice: RTCIceConnectionState | null, hasRemote: boolean, ): string { if (!connection) return 'Видеосвязь не инициализирована' if (connection === 'connected') { return hasRemote ? 'Соединение установлено' : 'Сигналинг готов, ожидание медиа' } if (connection === 'connecting' || ice === 'checking') return 'Установка соединения' if (connection === 'disconnected' || ice === 'disconnected') return 'Соединение прервано, восстановление' if (connection === 'failed' || ice === 'failed') return 'Не удалось установить соединение' if (connection === 'closed') return 'Соединение закрыто' return 'Ожидание сигналинга' }