merimandi / src /components /CallOverlay.jsx
datamk's picture
Upload 65 files
57da3ff verified
import { useStore } from '../store';
import { Phone, PhoneOff, Mic, MicOff, Video, VideoOff } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useRef } from 'react';
import { socket } from '../socket';
export function CallOverlay() {
const { activeCall, endCall, currentUser, incrementMissedCalls } = useStore();
const [micMuted, setMicMuted] = useState(false);
const [videoMuted, setVideoMuted] = useState(false);
const [stream, setStream] = useState(null);
const [callAccepted, setCallAccepted] = useState(false);
const [videoActive, setVideoActive] = useState(false);
const [incomingVideoRequest, setIncomingVideoRequest] = useState(false);
const [isRequestingVideo, setIsRequestingVideo] = useState(false);
const localVideoRef = useRef(null);
const remoteVideoRef = useRef(null);
const connectionRef = useRef(null);
const leaveCall = () => {
if (connectionRef.current) connectionRef.current.close();
// Stop local media defensively via both state and active ref source
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
if (localVideoRef.current && localVideoRef.current.srcObject) {
localVideoRef.current.srcObject.getTracks().forEach(track => track.stop());
localVideoRef.current.srcObject = null;
}
// Notify peer
if (activeCall) {
const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
socket.emit('end-call', { to: peerId });
}
setStream(null);
setCallAccepted(false);
endCall();
};
// We need to listen to incoming calls globally even if not in an active call state
useEffect(() => {
socket.on('incoming-call', (data) => {
// Setup incoming state in store manually by injecting it
useStore.setState({
activeCall: {
callerId: data.from,
callerName: data.callerName,
callerSelfie: data.callerSelfie,
receiverId: currentUser?.id,
receiverName: currentUser?.name,
receiverSelfie: currentUser?.selfiePath,
status: 'ringing',
signalData: data.signal // Stashed for answering
}
});
if ("Notification" in window && Notification.permission === "granted") {
new Notification("Incoming Call \u260E\uFE0F", {
body: `${data.callerName} is calling you on Meri Mandi!`,
icon: data.callerSelfie || '/upiqr.jpeg'
});
}
});
socket.on('call-ended', () => {
leaveCall();
});
socket.on('missed-call', () => {
incrementMissedCalls();
leaveCall();
});
socket.on('video-requested', () => {
setIncomingVideoRequest(true);
});
socket.on('video-accepted', () => {
setIsRequestingVideo(false);
enableVideoTrack();
});
socket.on('video-rejected', () => {
setIsRequestingVideo(false);
alert("Video call request was declined.");
});
return () => {
socket.off('incoming-call');
socket.off('call-ended');
socket.off('missed-call');
socket.off('video-requested');
socket.off('video-accepted');
socket.off('video-rejected');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser]);
// Timeout logic (60 seconds)
useEffect(() => {
let timeoutId;
if (activeCall && activeCall.status === 'ringing') {
timeoutId = setTimeout(() => {
const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
if (activeCall.receiverId === currentUser?.id) {
// I didn't answer - increment my missed calls
incrementMissedCalls();
}
// Notify other party
socket.emit('missed-call', { to: peerId });
leaveCall();
}, 60000);
}
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCall]);
// When activeCall becomes truthy and we are the CALLER (initiating), setup streams.
useEffect(() => {
if (activeCall && !stream && !callAccepted && activeCall.callerId === currentUser?.id) {
navigator.mediaDevices.getUserMedia({ video: false, audio: true }).then((currentStream) => {
setStream(currentStream);
if (localVideoRef.current) localVideoRef.current.srcObject = currentStream;
// Setup Peer Connection
const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
currentStream.getTracks().forEach(track => peer.addTrack(track, currentStream));
peer.ontrack = (event) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = event.streams[0];
}
};
peer.onicecandidate = (event) => {
if (event.candidate) {
// ICE handling omitted for simplicity in this exact demo, or bundled in offer
}
};
peer.createOffer().then(offer => {
peer.setLocalDescription(offer);
socket.emit('call-user', {
userToCall: activeCall.receiverId,
signalData: offer,
from: currentUser.id,
callerName: currentUser.name,
callerSelfie: currentUser.selfiePath
});
});
socket.on('call-accepted', (signal) => {
setCallAccepted(true);
peer.setRemoteDescription(new RTCSessionDescription(signal));
});
connectionRef.current = peer;
}).catch(err => {
console.error("Failed to get media", err);
alert("Camera and Mic permissions are required for calling");
endCall();
});
}
// Cleanup when overlay completely unmounts
return () => {
if (!activeCall) {
socket.off('call-accepted');
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCall, stream, currentUser]);
const answerCall = () => {
if (!activeCall || !activeCall.signalData) return;
setCallAccepted(true);
useStore.setState({ activeCall: { ...activeCall, status: 'connected' } });
navigator.mediaDevices.getUserMedia({ video: false, audio: true }).then((currentStream) => {
setStream(currentStream);
if (localVideoRef.current) localVideoRef.current.srcObject = currentStream;
const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
currentStream.getTracks().forEach(track => peer.addTrack(track, currentStream));
peer.ontrack = (event) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = event.streams[0];
}
};
peer.setRemoteDescription(new RTCSessionDescription(activeCall.signalData)).then(() => {
peer.createAnswer().then(answer => {
peer.setLocalDescription(answer);
socket.emit('answer-call', { signal: answer, to: activeCall.callerId });
});
});
connectionRef.current = peer;
});
};
const toggleMic = () => {
if (stream) {
stream.getAudioTracks()[0].enabled = micMuted;
setMicMuted(!micMuted);
}
};
const requestVideo = () => {
const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
setIsRequestingVideo(true);
socket.emit('request-video', { to: peerId, from: currentUser.id });
};
const acceptVideo = () => {
const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
setIncomingVideoRequest(false);
socket.emit('accept-video', { to: peerId });
enableVideoTrack();
};
const rejectVideo = () => {
const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
setIncomingVideoRequest(false);
socket.emit('reject-video', { to: peerId });
};
const enableVideoTrack = async () => {
try {
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
const videoTrack = videoStream.getVideoTracks()[0];
if (stream) {
stream.addTrack(videoTrack);
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
}
if (connectionRef.current) {
const senders = connectionRef.current.getSenders();
const videoSender = senders.find(s => s.track && s.track.kind === 'video');
if (videoSender) {
videoSender.replaceTrack(videoTrack);
} else {
connectionRef.current.addTrack(videoTrack, stream);
}
}
setVideoActive(true);
setVideoMuted(false);
} catch (err) {
console.error("Failed to start video", err);
}
};
const toggleVideo = () => {
if (!videoActive) {
requestVideo();
return;
}
if (stream && stream.getVideoTracks().length > 0) {
const track = stream.getVideoTracks()[0];
track.enabled = videoMuted;
setVideoMuted(!videoMuted);
}
};
if (!activeCall) return null;
const isRinging = activeCall.status === 'ringing';
const isIncoming = activeCall.receiverId === currentUser?.id && isRinging;
const isOutgoing = activeCall.callerId === currentUser?.id;
const peerSelfie = isOutgoing ? activeCall.receiverSelfie : activeCall.callerSelfie;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
style={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(6, 9, 7, 0.95)',
backdropFilter: 'blur(20px)',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '24px'
}}
>
{/* Remote Video / Avatar Container */}
<div style={{ textAlign: 'center', width: '100%', maxWidth: '400px', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<div style={{
position: 'relative',
width: callAccepted ? '100%' : '140px',
height: callAccepted ? '60vh' : '140px',
borderRadius: callAccepted ? '24px' : '50%',
overflow: 'hidden',
background: 'linear-gradient(135deg, #10b981, #059669)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '48px', fontWeight: 700,
transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: callAccepted ? '0 20px 50px rgba(0,0,0,0.5)' : '0 0 50px var(--primary-glow)',
animation: isRinging ? 'pulse-ring 2s infinite' : 'none'
}}>
{/* Live Native Video */}
<video
playsInline
ref={remoteVideoRef}
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover', display: videoActive ? 'block' : 'none' }}
/>
{/* Fallback Static Avatar */}
{!videoActive && (peerSelfie ? (
<img src={peerSelfie} alt="Peer Selfie" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
isOutgoing ? activeCall.receiverName.charAt(0) : activeCall.callerName.charAt(0)
))}
{/* Picture-in-Picture Local Viewer */}
{videoActive && (
<div style={{
position: 'absolute', bottom: 16, right: 16,
width: '100px', height: '150px', borderRadius: '12px',
overflow: 'hidden', border: '2px solid white',
background: '#000'
}}>
<video playsInline ref={localVideoRef} autoPlay muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
</div>
<AnimatePresence>
{incomingVideoRequest && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
style={{
background: 'var(--primary-glow)', padding: '16px 24px', borderRadius: '16px',
marginTop: '20px', border: '1px solid var(--primary-color)', textAlign: 'center'
}}
>
<p style={{ fontWeight: 600, marginBottom: '12px' }}>Requesting Video Call...</p>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={rejectVideo} className="btn-secondary" style={{ padding: '8px 16px', fontSize: '14px' }}>Decline</button>
<button onClick={acceptVideo} className="btn-primary" style={{ padding: '8px 16px', fontSize: '14px' }}>Accept Video</button>
</div>
</motion.div>
)}
{isRequestingVideo && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
style={{
background: 'var(--surface-color)', padding: '12px 20px', borderRadius: '12px',
marginTop: '20px', border: '1px solid var(--border-color)', fontSize: '14px'
}}
>
Waiting for video call acceptance...
</motion.div>
)}
</AnimatePresence>
<h2 style={{ fontSize: '32px', marginBottom: '8px', marginTop: '24px' }}>
{isOutgoing ? activeCall.receiverName : activeCall.callerName}
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '18px' }}>
{isRinging ? (isIncoming ? 'Incoming Call...' : 'Ringing...') : 'Connected Securely'}
</p>
</div>
{/* Action Controls */}
<div style={{ display: 'flex', gap: '24px', alignItems: 'center', marginTop: 'auto', marginBottom: '40px' }}>
{(!isRinging || callAccepted) && (
<>
<button
className="btn-icon-circular"
onClick={toggleMic}
style={{ width: '56px', height: '56px', background: micMuted ? 'var(--danger-color)' : 'var(--surface-color)', color: 'white' }}
>
{micMuted ? <MicOff size={24} /> : <Mic size={24} />}
</button>
<button
className="btn-icon-circular btn-call-reject"
onClick={leaveCall}
style={{ width: '72px', height: '72px' }}
>
<PhoneOff size={32} />
</button>
<button
className="btn-icon-circular"
onClick={toggleVideo}
disabled={isRequestingVideo || incomingVideoRequest}
style={{
width: '56px', height: '56px',
background: !videoActive ? 'var(--surface-color)' : (videoMuted ? 'var(--danger-color)' : 'var(--primary-color)'),
color: 'white',
animation: !videoActive ? 'pulse-ring 2s infinite' : 'none'
}}
>
{videoMuted ? <VideoOff size={24} /> : <Video size={24} />}
</button>
</>
)}
{isIncoming && isRinging && (
<div style={{ display: 'flex', gap: '40px' }}>
<button
className="btn-icon-circular btn-call-reject"
onClick={leaveCall}
style={{ width: '72px', height: '72px' }}
>
<PhoneOff size={32} />
</button>
<button
className="btn-icon-circular btn-call-accept"
onClick={answerCall}
style={{ width: '72px', height: '72px' }}
>
<Phone size={32} />
</button>
</div>
)}
{isOutgoing && isRinging && !callAccepted && (
<button
className="btn-icon-circular btn-call-reject"
onClick={leaveCall}
style={{ width: '72px', height: '72px' }}
>
<PhoneOff size={32} />
</button>
)}
</div>
</motion.div>
</AnimatePresence>
);
}