675 lines
28 KiB
JavaScript
675 lines
28 KiB
JavaScript
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 (
|
|
<div className={`bg-base flex flex-col relative ${className}`}>
|
|
<audio ref={remoteAudioRef} autoPlay className="hidden" />
|
|
|
|
{/* Header */}
|
|
<div className="h-14 shadow-sm flex items-center px-4 border-b border-surface gap-2 shrink-0 bg-panel">
|
|
<span className="font-bold text-text">Call: {targetProfile.displayName}</span>
|
|
<span className="ml-2 text-xs font-bold uppercase tracking-widest text-muted flex items-center gap-1">
|
|
{status === 'ringing' ? (
|
|
<>
|
|
Ringing
|
|
<span className="flex gap-0.5 items-center mt-1">
|
|
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0s' }}></span>
|
|
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span>
|
|
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span>
|
|
</span>
|
|
</>
|
|
) : status === 'connecting' ? (
|
|
'Connecting...'
|
|
) : (
|
|
<span className="text-green-500">Connected</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Main Call Area */}
|
|
<div className={`flex-1 flex ${isVideoActive ? 'flex-col' : 'items-center justify-center'} gap-8 p-8 overflow-hidden relative`}>
|
|
|
|
{/* Video Area */}
|
|
{isVideoActive && (
|
|
<div
|
|
className={isFullscreen
|
|
? "fixed inset-0 z-50 bg-black flex items-center justify-center"
|
|
: "flex-1 w-full bg-black rounded-lg overflow-hidden relative shadow-lg border border-surface cursor-pointer group"
|
|
}
|
|
onClick={() => !isFullscreen && setIsFullscreen(true)}
|
|
>
|
|
{hasRemoteVideo && (
|
|
<video ref={remoteVideoRef} autoPlay playsInline className="w-full h-full object-contain" />
|
|
)}
|
|
{isLocalVideoActive && !hasRemoteVideo && (
|
|
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
|
|
)}
|
|
|
|
{/* Small PiP if both are sharing */}
|
|
{hasRemoteVideo && isLocalVideoActive && (
|
|
<div className={`absolute bottom-4 right-4 aspect-video bg-black rounded border-2 border-surface overflow-hidden shadow-xl ${isFullscreen ? 'w-80' : 'w-48'}`}>
|
|
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-cover" />
|
|
</div>
|
|
)}
|
|
|
|
{!isFullscreen && (
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<span className="text-white font-bold bg-black/50 px-4 py-2 rounded-full backdrop-blur-sm flex items-center gap-2">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>
|
|
Click to Enlarge
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{isFullscreen && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setIsFullscreen(false); }}
|
|
className="absolute top-6 right-6 bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-full font-bold shadow-lg transition-colors z-50 flex items-center gap-2"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path></svg>
|
|
Exit Fullscreen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* User Squares Grid */}
|
|
<div className={`flex justify-center gap-6 ${isVideoActive ? 'shrink-0 h-40' : 'w-full max-w-3xl'}`}>
|
|
|
|
{/* Remote User Square */}
|
|
<div className={`bg-surface rounded-xl flex flex-col items-center justify-center gap-3 transition-all duration-300 shadow-lg border border-panel relative overflow-hidden ${isVideoActive ? 'w-48 h-full' : 'w-72 h-72'} ${status === 'ringing' ? 'opacity-50' : ''} ${remoteVoiceActive ? 'ring-2 ring-green-500' : 'ring-2 ring-transparent'}`}>
|
|
{status === 'ringing' && (
|
|
<div className="absolute inset-0 bg-black/20 flex items-center justify-center z-10">
|
|
<div className="w-full h-full animate-pulse bg-white/5"></div>
|
|
</div>
|
|
)}
|
|
<div className={`rounded-md flex items-center justify-center text-white font-bold overflow-hidden ${isVideoActive ? 'w-16 h-16 text-2xl' : 'w-28 h-28 text-4xl'} ${targetProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
|
|
{targetProfile.avatar ? (
|
|
<img src={targetProfile.avatar} alt="avatar" className="w-full h-full object-cover" />
|
|
) : (
|
|
targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'
|
|
)}
|
|
</div>
|
|
<span className={`text-text font-bold ${isVideoActive ? 'text-sm' : 'text-lg'}`}>{targetProfile.displayName || 'Unknown'}</span>
|
|
</div>
|
|
|
|
{/* Local User Square */}
|
|
<div className={`bg-surface rounded-xl flex flex-col items-center justify-center gap-3 transition-all duration-300 shadow-lg border border-panel ${isVideoActive ? 'w-48 h-full' : 'w-72 h-72'} ${localVoiceActive && !isMuted ? 'ring-2 ring-green-500' : 'ring-2 ring-transparent'}`}>
|
|
<div className={`rounded-md flex items-center justify-center text-white font-bold overflow-hidden ${isVideoActive ? 'w-16 h-16 text-2xl' : 'w-28 h-28 text-4xl'} ${myProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
|
|
{myProfile.avatar ? (
|
|
<img src={myProfile.avatar} alt="avatar" className="w-full h-full object-cover" />
|
|
) : (
|
|
myProfile.displayName?.substring(0, 2).toUpperCase() || '?'
|
|
)}
|
|
</div>
|
|
<span className={`text-text font-bold ${isVideoActive ? 'text-sm' : 'text-lg'}`}>{myProfile.displayName} (You)</span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Bottom Controls */}
|
|
<div className="h-20 bg-surface flex items-center justify-center gap-4 shrink-0 rounded-t-2xl mx-4 border-t border-x border-panel">
|
|
<button
|
|
onClick={toggleMute}
|
|
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-panel text-text hover:bg-base'}`}
|
|
title={isMuted ? "Unmute" : "Mute"}
|
|
>
|
|
{isMuted ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
|
) : (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleVideo}
|
|
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${!isVideoOn ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-panel text-text hover:bg-base'}`}
|
|
title={isVideoOn ? "Turn Off Camera" : "Turn On Camera"}
|
|
>
|
|
{isVideoOn ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
|
|
) : (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={onToggleChat}
|
|
className="px-6 h-10 rounded bg-panel hover:bg-base text-text font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
|
Chat
|
|
</button>
|
|
|
|
{isScreenSharing ? (
|
|
<button
|
|
onClick={stopScreenShare}
|
|
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
|
Stop Sharing
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowScreenShareModal(true)}
|
|
disabled={status !== 'connected'}
|
|
className="px-6 h-10 rounded bg-panel hover:bg-base text-text font-medium flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
|
Share Screen
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
End Call
|
|
</button>
|
|
</div>
|
|
|
|
{showScreenShareModal && (
|
|
<ScreenShareModal
|
|
onClose={() => setShowScreenShareModal(false)}
|
|
onStart={startScreenShare}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |