import React, { useEffect, useRef, useState } from 'react'; import { network } from '../p2p/index.js'; import ScreenShareModal from './ScreenShareModal.jsx'; export default function CallView({ targetKey, targetProfile, myProfile, isCaller, status, onClose, onToggleChat, onConnected, className, initialVideoOn }) { const [isMuted, setIsMuted] = useState(false); const [isVideoOn, setIsVideoOn] = useState(initialVideoOn || false); const [localVoiceActive, setLocalVoiceActive] = useState(false); const [remoteVoiceActive, setRemoteVoiceActive] = useState(false); const [showScreenShareModal, setShowScreenShareModal] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false); const [hasRemoteVideo, setHasRemoteVideo] = useState(false); const [remoteVideoStream, setRemoteVideoStream] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const pcRef = useRef(null); const localStreamRef = useRef(null); const localClonedAudioStreamRef = useRef(null); const localScreenStreamRef = useRef(null); const localCameraStreamRef = useRef(null); const remoteAudioRef = useRef(null); const remoteVideoRef = useRef(null); const localVideoRef = useRef(null); const animationFrameRef = useRef(null); const audioCtxRef = useRef(null); const pendingCandidates = useRef([]); const pendingSignals = useRef([]); const isProcessingSignals = useRef(false); const drainPendingSignalsRef = useRef(null); const [mediaReady, setMediaReady] = useState(false); const isLocalVideoActive = isScreenSharing || isVideoOn; const isVideoActive = hasRemoteVideo || isLocalVideoActive; useEffect(() => { if (localVideoRef.current) { if (isScreenSharing && localScreenStreamRef.current) { localVideoRef.current.srcObject = localScreenStreamRef.current; } else if (isVideoOn && localCameraStreamRef.current) { localVideoRef.current.srcObject = localCameraStreamRef.current; } else { localVideoRef.current.srcObject = null; } } }, [isScreenSharing, isVideoOn, hasRemoteVideo, isFullscreen]); useEffect(() => { if (remoteVideoRef.current) { if (hasRemoteVideo && remoteVideoStream) { remoteVideoRef.current.srcObject = remoteVideoStream; } else { remoteVideoRef.current.srcObject = null; } } }, [hasRemoteVideo, isFullscreen, remoteVideoStream]); const initPC = async () => { const pc = new RTCPeerConnection({ iceServers:[{ urls: 'stun:stun.l.google.com:19302' }] }); pcRef.current = pc; if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current)); } if (localCameraStreamRef.current) { localCameraStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localCameraStreamRef.current)); } pc.onconnectionstatechange = () => { if (pc.connectionState === 'connected') { onConnected(); } }; pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') { onConnected(); } }; pc.onicecandidate = (e) => { if (e.candidate) { network.sendWebRTCSignal(targetKey, { type: 'webrtc-ice-candidate', candidate: e.candidate }); } }; pc.ontrack = (e) => { if (e.track.kind === 'video') { setRemoteVideoStream(e.streams[0]); setHasRemoteVideo(true); e.track.onended = () => { setHasRemoteVideo(false); setRemoteVideoStream(null); setIsFullscreen(false); }; e.track.onmute = () => { setHasRemoteVideo(false); setIsFullscreen(false); }; e.track.onunmute = () => { setHasRemoteVideo(true); }; } else if (e.track.kind === 'audio') { if (remoteAudioRef.current) { remoteAudioRef.current.srcObject = e.streams[0]; const outputId = localStorage.getItem('pear_audio_output'); if (outputId && outputId !== 'default' && remoteAudioRef.current.setSinkId) { remoteAudioRef.current.setSinkId(outputId).catch(console.error); } } } }; if (drainPendingSignalsRef.current) { await drainPendingSignalsRef.current(); } return pc; }; useEffect(() => { const setupMedia = async () => { try { const audioInputId = localStorage.getItem('pear_audio_input'); const noiseSuppression = localStorage.getItem('pear_noise_suppression') !== 'false'; const audioConstraints = { noiseSuppression: noiseSuppression, echoCancellation: true, autoGainControl: true }; if (audioInputId && audioInputId !== 'default') { audioConstraints.deviceId = { exact: audioInputId }; } let aStream; try { aStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }); } catch (err) { console.warn("Failed to get audio with specific constraints, falling back to default.", err); aStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } localStreamRef.current = aStream; if (initialVideoOn) { try { const videoInputId = localStorage.getItem('pear_video_input'); const vStream = await navigator.mediaDevices.getUserMedia({ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true }); localCameraStreamRef.current = vStream; } catch (err) { console.error("Failed to get video", err); setIsVideoOn(false); } } setMediaReady(true); const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); audioCtxRef.current = audioCtx; if (audioCtx.state === 'suspended') { await audioCtx.resume().catch(() => {}); } // Clone the stream for the analyser to prevent Web Audio API from interfering with WebRTC's internal audio processing pipeline const clonedAudioStream = new MediaStream(aStream.getAudioTracks().map(t => t.clone())); localClonedAudioStreamRef.current = clonedAudioStream; const source = audioCtx.createMediaStreamSource(clonedAudioStream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; // Connect to a muted gain node and then to destination. // This prevents Chrome from aggressively optimizing/suspending the audio graph which causes silent mics. const dummyGain = audioCtx.createGain(); dummyGain.gain.value = 0; source.connect(analyser); analyser.connect(dummyGain); dummyGain.connect(audioCtx.destination); const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); let lastSpeakingState = false; const checkAudio = () => { analyser.getByteFrequencyData(dataArray); let sum = 0; for (let i = 0; i < bufferLength; i++) sum += dataArray[i]; const average = sum / bufferLength; const isSpeaking = average > 15; if (isSpeaking !== lastSpeakingState) { network.sendWebRTCSignal(targetKey, { type: 'voice_activity', state: isSpeaking ? 'speaking' : 'silent' }); setLocalVoiceActive(isSpeaking); lastSpeakingState = isSpeaking; } animationFrameRef.current = requestAnimationFrame(checkAudio); }; checkAudio(); if (!isCaller) { initPC(); } } catch (err) { console.error("Failed to access microphone:", err); alert("Could not access microphone. Please check your permissions."); onClose(); } }; setupMedia(); return () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {}); if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop()); if (localClonedAudioStreamRef.current) localClonedAudioStreamRef.current.getTracks().forEach(t => t.stop()); if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop()); if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop()); if (pcRef.current) pcRef.current.close(); network.sendWebRTCSignal(targetKey, { type: 'webrtc-end' }); }; // eslint-disable-next-line react-hooks/exhaustive-deps },[]); useEffect(() => { if (isCaller && status === 'connecting' && !pcRef.current && mediaReady) { initPC().then(async (pc) => { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer }); }); } // eslint-disable-next-line react-hooks/exhaustive-deps },[status, isCaller, targetKey, mediaReady]); useEffect(() => { const processSignal = async (payload) => { const pc = pcRef.current; if (!pc) return; if (payload.type === 'webrtc-offer') { await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); for (const candidate of pendingCandidates.current) { await pc.addIceCandidate(candidate).catch(console.error); } pendingCandidates.current =[]; const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-answer', sdp: answer }); } else if (payload.type === 'webrtc-answer') { await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); for (const candidate of pendingCandidates.current) { await pc.addIceCandidate(candidate).catch(console.error); } pendingCandidates.current =[]; onConnected(); } else if (payload.type === 'webrtc-ice-candidate') { const candidate = new RTCIceCandidate(payload.candidate); if (pc.remoteDescription && pc.remoteDescription.type) { await pc.addIceCandidate(candidate).catch(console.error); } else { pendingCandidates.current.push(candidate); } } else if (payload.type === 'voice_activity') { setRemoteVoiceActive(payload.state === 'speaking'); } }; const drainPendingSignals = async () => { if (isProcessingSignals.current) return; isProcessingSignals.current = true; while (pendingSignals.current.length > 0) { const payload = pendingSignals.current.shift(); await processSignal(payload); } isProcessingSignals.current = false; }; drainPendingSignalsRef.current = drainPendingSignals; const handleSignal = async (peerKey, payload) => { if (peerKey !== targetKey) return; try { if (!pcRef.current && payload.type !== 'voice_activity') { pendingSignals.current.push(payload); return; } if (pcRef.current) { pendingSignals.current.push(payload); await drainPendingSignals(); } else if (payload.type === 'voice_activity') { setRemoteVoiceActive(payload.state === 'speaking'); } } catch (err) { console.error("Error handling WebRTC signal:", err); } }; network.addWebRTCListener(handleSignal); return () => network.removeWebRTCListener(handleSignal); }, [targetKey, onConnected]); const toggleMute = () => { if (localStreamRef.current) { const audioTrack = localStreamRef.current.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = !audioTrack.enabled; setIsMuted(!audioTrack.enabled); } } }; const toggleVideo = async () => { if (isVideoOn) { if (localCameraStreamRef.current) { const track = localCameraStreamRef.current.getVideoTracks()[0]; const sender = pcRef.current?.getSenders().find(s => s.track === track); if (sender && pcRef.current) pcRef.current.removeTrack(sender); track.stop(); localCameraStreamRef.current = null; } setIsVideoOn(false); if (pcRef.current && pcRef.current.signalingState !== 'closed') { const offer = await pcRef.current.createOffer(); await pcRef.current.setLocalDescription(offer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer }); } } else { try { const videoInputId = localStorage.getItem('pear_video_input'); const stream = await navigator.mediaDevices.getUserMedia({ video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true }); localCameraStreamRef.current = stream; const track = stream.getVideoTracks()[0]; if (pcRef.current) { pcRef.current.addTrack(track, stream); const offer = await pcRef.current.createOffer(); await pcRef.current.setLocalDescription(offer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer }); } setIsVideoOn(true); } catch (err) { console.error("Failed to start video", err); } } }; const startScreenShare = async (sourceId, res, fps) => { setShowScreenShareModal(false); let stream = null; try { if (sourceId === 'native') { stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); } else { try { stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId, maxWidth: res.width, maxHeight: res.height, maxFrameRate: fps } } }); } catch (initialErr) { console.warn("Optimal capture rejected. Using fallback.", initialErr); stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId } } }); } } const videoTrack = stream.getVideoTracks()[0]; videoTrack.contentHint = 'motion'; videoTrack.onended = () => { stopScreenShare(); }; localScreenStreamRef.current = stream; setIsScreenSharing(true); const sender = pcRef.current.addTrack(videoTrack, stream); try { const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) { params.encodings = [{}]; } params.encodings[0].maxFramerate = fps; let maxBitrate = 8000000; if (res.height <= 720) maxBitrate = 4000000; if (res.height <= 480) maxBitrate = 1500000; if (res.height <= 360) maxBitrate = 800000; params.encodings[0].maxBitrate = maxBitrate; if ('degradationPreference' in params) { params.degradationPreference = 'maintain-framerate'; } await sender.setParameters(params); } catch (paramErr) { console.warn("Could not set sender parameters:", paramErr); } const offer = await pcRef.current.createOffer(); await pcRef.current.setLocalDescription(offer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer }); } catch (err) { console.error("Screen share failed or cancelled", err); if (localVideoRef.current) localVideoRef.current.srcObject = null; if (stream) stream.getTracks().forEach(t => { t.enabled = false; t.stop(); }); if (localScreenStreamRef.current) { localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); }); localScreenStreamRef.current = null; } setIsScreenSharing(false); if (err.name !== 'NotAllowedError' && err.name !== 'AbortError') { alert(`Could not capture this window/screen.\nError: ${err.name} - ${err.message}`); } } }; const stopScreenShare = async () => { if (localVideoRef.current) { localVideoRef.current.srcObject = null; } let trackToRemove = null; if (localScreenStreamRef.current) { trackToRemove = localScreenStreamRef.current.getVideoTracks()[0]; localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); }); localScreenStreamRef.current = null; } setIsScreenSharing(false); setIsFullscreen(false); if (pcRef.current && trackToRemove) { const senders = pcRef.current.getSenders(); const videoSender = senders.find(s => s.track === trackToRemove); if (videoSender) { try { pcRef.current.removeTrack(videoSender); if (pcRef.current.signalingState !== 'closed') { const offer = await pcRef.current.createOffer(); await pcRef.current.setLocalDescription(offer); network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer }); } } catch (e) { console.warn("Failed to negotiate track removal", e); } } } }; return (