Files
forkmessager/apps/web/src/components/CallModal.tsx
Халимов Рустам 6fa63854c2 Видео
2026-03-11 01:48:15 +03:00

1802 lines
73 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
import { getSocket } from '../lib/socket';
import { api } from '../lib/api';
import { useLang } from '../lib/i18n';
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds';
type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended';
interface CallModalProps {
isOpen: boolean;
onClose: () => void;
targetUser: { id: string; displayName?: string; username: string; avatar?: string | null } | null;
callType: 'voice' | 'video';
incoming?: {
from: string;
offer: RTCSessionDescriptionInit;
callType: 'voice' | 'video';
callerInfo?: { displayName?: string; username?: string; avatar?: string | null } | null;
} | null;
}
// ICE servers cache (fetched from server, includes TURN credentials when configured)
let cachedIceConfig: RTCConfiguration | null = null;
let iceCacheFetchedAt = 0;
const ICE_CACHE_TTL = 3600_000; // 1 hour
const FALLBACK_ICE: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
async function getIceServers(): Promise<RTCConfiguration> {
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) {
return cachedIceConfig;
}
try {
const data = await api.getIceServers();
console.log('[WebRTC] Received ICE config:', data);
if (data.iceServers && data.iceServers.length > 0) {
cachedIceConfig = { iceServers: data.iceServers };
iceCacheFetchedAt = Date.now();
return cachedIceConfig;
}
} catch (e) {
console.warn('Failed to fetch ICE servers, using fallback STUN:', e);
}
return FALLBACK_ICE;
}
// Try to get video+audio, falling back to audio-only
// If preferDeviceId is given, try that camera first
async function getMediaWithCameraFallback(
wantVideo: boolean,
preferDeviceId?: string
): Promise<{ stream: MediaStream; hasVideo: boolean }> {
if (!wantVideo) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
return { stream, hasVideo: false };
}
// 1) If we have a preferred camera, try it first
if (preferDeviceId) {
try {
console.log('[getMedia] Trying preferred camera:', preferDeviceId.slice(0, 12));
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
video: { deviceId: { exact: preferDeviceId } },
});
console.log('[getMedia] Preferred camera success');
return { stream, hasVideo: true };
} catch (e) {
console.warn('[getMedia] Preferred camera failed:', e);
}
}
// 2) Try default audio+video
try {
console.log('[getMedia] Requesting audio+video (default camera)...');
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: true });
console.log('[getMedia] Success —', stream.getVideoTracks().map(t => `${t.label}:${t.readyState}`));
return { stream, hasVideo: true };
} catch (e) {
console.warn('[getMedia] audio+video failed:', e);
}
// 3) Final fallback: audio only
console.warn('[getMedia] Camera unavailable, falling back to audio only');
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
return { stream, hasVideo: false };
}
// Find a video sender on the peer connection, even if sender.track is null (recvonly transceiver)
function findVideoSender(pc: RTCPeerConnection): RTCRtpSender | undefined {
// First try sender with an active video track
const withTrack = pc.getSenders().find(s => s.track?.kind === 'video');
if (withTrack) return withTrack;
// Fall back to sender from a video transceiver (may have null track)
const videoTransceiver = pc.getTransceivers().find(
t => t.receiver?.track?.kind === 'video'
);
return videoTransceiver?.sender;
}
export default function CallModal({ isOpen, onClose, targetUser, callType: initialCallType, incoming }: CallModalProps) {
const { t } = useLang();
const [callState, setCallState] = useState<CallState>('idle');
const [callType, setCallType] = useState<'voice' | 'video'>(initialCallType);
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [hasRemoteVideo, setHasRemoteVideo] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [duration, setDuration] = useState(0);
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
const [showCameraMenu, setShowCameraMenu] = useState(false);
const [activeCameraId, setActiveCameraId] = useState<string>('');
const [remoteVolume, setRemoteVolume] = useState(1);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [noiseSuppression, setNoiseSuppression] = useState(false);
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
const [activeMicId, setActiveMicId] = useState<string>('');
const [showMicMenu, setShowMicMenu] = useState(false);
const peerRef = useRef<RTCPeerConnection | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const remoteAudioRef = useRef<HTMLAudioElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const callTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const targetUserIdRef = useRef<string>('');
const iceCandidateBufferRef = useRef<RTCIceCandidateInit[]>([]);
const callEndedRef = useRef(false);
const videoContainerRef = useRef<HTMLDivElement>(null);
const remoteStreamRef = useRef<MediaStream | null>(null);
// Track whether video was active before screen share to decide restoration behavior
const hadVideoBeforeScreenShareRef = useRef(false);
// Noise gate refs
const noiseGateCtxRef = useRef<AudioContext | null>(null);
const noiseGateGainRef = useRef<GainNode | null>(null);
const noiseGateRafRef = useRef<number>(0);
const noiseGateTrackRef = useRef<MediaStreamTrack | null>(null);
const cleanup = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
stopCallRingtone();
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current = null;
}
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(track => track.stop());
screenStreamRef.current = null;
}
if (peerRef.current) {
peerRef.current.onconnectionstatechange = null;
peerRef.current.onicecandidate = null;
peerRef.current.ontrack = null;
peerRef.current.close();
peerRef.current = null;
}
iceCandidateBufferRef.current = [];
remoteStreamRef.current = null;
// Clean up noise gate
if (noiseGateRafRef.current) cancelAnimationFrame(noiseGateRafRef.current);
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
noiseGateGainRef.current = null;
setDuration(0);
setIsMuted(false);
setIsVideoOff(false);
setIsScreenSharing(false);
setHasRemoteVideo(false);
setIsFullscreen(false);
setIsMinimized(false);
setShowCameraMenu(false);
setShowVolumeSlider(false);
setShowMicMenu(false);
setNoiseSuppression(false);
}, []);
// Schedule a tracked close timeout
const scheduleClose = useCallback((delay = 1500) => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => {
closeTimeoutRef.current = undefined;
onClose();
}, delay);
}, [onClose]);
// End call (safe to call multiple times)
const endCallSafe = useCallback(() => {
if (callEndedRef.current) return;
callEndedRef.current = true;
const socket = getSocket();
socket?.emit('call_end', { targetUserId: targetUserIdRef.current });
stopCallRingtone();
setCallState('ended');
cleanup();
scheduleClose();
}, [cleanup, scheduleClose]);
// Setup common peer connection event handlers
const setupPeerHandlers = useCallback((pc: RTCPeerConnection) => {
pc.onicecandidate = (e) => {
if (e.candidate) {
const socket = getSocket();
socket?.emit('ice_candidate', {
targetUserId: targetUserIdRef.current,
candidate: e.candidate,
});
}
};
pc.ontrack = (event) => {
const track = event.track;
const streams = event.streams;
console.log(`[WebRTC] Received track: ${track.kind} state: ${track.readyState} streams: ${streams.length}`);
// Ensure we have a stable remote stream object
if (!remoteStreamRef.current) {
remoteStreamRef.current = streams[0] || new MediaStream([track]);
} else if (!remoteStreamRef.current.getTracks().includes(track)) {
remoteStreamRef.current.addTrack(track);
}
const stream = remoteStreamRef.current;
// Helper to update video UI state
const checkVideo = () => {
const stream = remoteStreamRef.current;
if (!stream) {
setHasRemoteVideo(false);
return;
}
const videoTracks = stream.getVideoTracks();
// Be more lenient: if track is 'live', trust it even if it reports 'muted' initially.
// Some browsers report muted while the buffer is filling.
const hasVideo = videoTracks.length > 0 && videoTracks.some(
t => t.readyState === 'live' && t.enabled
);
console.log('[checkVideo] remote videoTracks:', videoTracks.map(t =>
`${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}`
), '→ hasRemoteVideo:', hasVideo);
setHasRemoteVideo(hasVideo);
if (hasVideo && callType !== 'video') {
setCallType('video');
}
};
// Force binding to the SINGLE stable video element
if (remoteVideoRef.current) {
if (remoteVideoRef.current.srcObject !== stream) {
console.log('[WebRTC] Binding stream to video element');
remoteVideoRef.current.srcObject = stream;
// Force load to ensure browser picks up streams properly
remoteVideoRef.current.load();
}
// Try to play. If fails, it's usually autoplay policy
remoteVideoRef.current.play().catch(() => {
console.warn('[WebRTC] Autoplay blocked, waiting for interaction');
const retryPlay = () => {
if (!remoteVideoRef.current) return;
remoteVideoRef.current.play().then(() => {
console.log('[WebRTC] Video started after retry');
}).catch(() => {
// If blocked, ensure it's muted and try again
if (remoteVideoRef.current) remoteVideoRef.current.muted = true;
setTimeout(retryPlay, 2000);
});
};
retryPlay();
});
}
// Handle audio via stable audio element
if (remoteAudioRef.current) {
if (remoteAudioRef.current.srcObject !== stream) {
remoteAudioRef.current.srcObject = stream;
}
remoteAudioRef.current.play().catch(e => console.warn('[WebRTC] Audio play failed:', e));
}
track.onunmute = checkVideo;
track.onmute = checkVideo;
track.onended = checkVideo;
checkVideo();
setTimeout(checkVideo, 500);
setTimeout(checkVideo, 2000);
};
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState);
if (callEndedRef.current) return;
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
endCallSafe();
}
};
}, [endCallSafe]); // REMOVED callType dependency to prevent handler reset during call
// Start outgoing call
const startCall = useCallback(async () => {
if (!targetUser) return;
targetUserIdRef.current = targetUser.id;
callEndedRef.current = false;
setCallState('calling');
try {
// Get media (video with camera enumeration fallback)
console.log('[startCall] Getting media, wantVideo:', callType === 'video');
const { stream, hasVideo } = await getMediaWithCameraFallback(callType === 'video');
console.log('[startCall] Got media — hasVideo:', hasVideo,
'audioTracks:', stream.getAudioTracks().length,
'videoTracks:', stream.getVideoTracks().length);
let effectiveCallType = callType;
if (callType === 'video' && !hasVideo) {
effectiveCallType = 'voice';
setCallType('voice');
}
if (callEndedRef.current) {
stream.getTracks().forEach(t => t.stop());
return;
}
localStreamRef.current = stream;
// Track active camera & mic
const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings();
if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId);
const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings();
if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId);
refreshCameras();
refreshMicrophones();
const iceConfig = await getIceServers();
const pc = new RTCPeerConnection(iceConfig);
peerRef.current = pc;
// Add all tracks from the stream
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Always add a video transceiver (even for voice calls)
// This ensures we have a video m-line for future camera/screen share,
// making renegotiation much more stable across browsers.
const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video');
if (!hasVideoTransceiver) {
pc.addTransceiver('video', { direction: 'recvonly' });
}
setupPeerHandlers(pc);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('call_offer', {
targetUserId: targetUser.id,
offer: pc.localDescription,
callType: effectiveCallType,
});
// 15 second no-answer timeout
callTimeoutRef.current = setTimeout(async () => {
if (callEndedRef.current) return;
callEndedRef.current = true;
stopCallRingtone();
const s = getSocket();
s?.emit('call_end', { targetUserId: targetUserIdRef.current });
cleanup();
setCallState('ended');
await playUnavailableSound();
onClose();
}, 15000);
} catch (err: any) {
console.error('Error starting call:', err);
if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') {
alert(callType === 'video'
? 'Разрешите доступ к камере и микрофону в настройках браузера для совершения звонков'
: 'Разрешите доступ к микрофону в настройках браузера для совершения звонков');
}
setCallState('ended');
cleanup();
}
}, [targetUser, callType, cleanup, onClose, setupPeerHandlers]);
// Accept incoming call
const acceptCall = useCallback(async () => {
if (!incoming) return;
targetUserIdRef.current = incoming.from;
callEndedRef.current = false;
stopCallRingtone();
try {
console.log('[acceptCall] Getting media, wantVideo:', incoming.callType === 'video');
const { stream, hasVideo } = await getMediaWithCameraFallback(incoming.callType === 'video');
console.log('[acceptCall] Got media — hasVideo:', hasVideo,
'audioTracks:', stream.getAudioTracks().length,
'videoTracks:', stream.getVideoTracks().length,
'videoEnabled:', stream.getVideoTracks().map(t => t.enabled));
let effectiveCallType = incoming.callType;
if (incoming.callType === 'video' && !hasVideo) {
effectiveCallType = 'voice';
}
if (callEndedRef.current) {
stream.getTracks().forEach(t => t.stop());
return;
}
localStreamRef.current = stream;
// Track active camera & mic
const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings();
if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId);
const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings();
if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId);
refreshCameras();
refreshMicrophones();
const iceConfig = await getIceServers();
const pc = new RTCPeerConnection(iceConfig);
peerRef.current = pc;
// Setup handlers first so ontrack is ready when setRemoteDescription fires
setupPeerHandlers(pc);
// Standard answerer pattern: set remote description FIRST
// This creates transceivers from the offer's m-lines
await pc.setRemoteDescription(new RTCSessionDescription(incoming.offer));
// Now add local tracks — they reuse the transceivers created from the offer,
// changing direction from recvonly to sendrecv
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
// Always ensure we have a video transceiver.
// After setRemoteDescription, one usually exists from the offer if it was a video call or if the caller added one.
const hasVideoTransceiver = pc.getTransceivers().some(
t => t.receiver?.track?.kind === 'video'
);
if (!hasVideoTransceiver) {
pc.addTransceiver('video', { direction: 'recvonly' });
}
console.log('[acceptCall] Transceivers after setup:',
pc.getTransceivers().map(t => ({
mid: t.mid,
direction: t.direction,
senderTrack: t.sender?.track?.kind ?? null,
receiverTrack: t.receiver?.track?.kind ?? null,
})));
if (callEndedRef.current) {
pc.onconnectionstatechange = null;
pc.close();
peerRef.current = null;
stream.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
return;
}
// Flush buffered ICE candidates
for (const candidate of iceCandidateBufferRef.current) {
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error);
}
iceCandidateBufferRef.current = [];
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log('[acceptCall] Answer created, transceivers:',
pc.getTransceivers().map(t => ({
mid: t.mid,
direction: t.direction,
currentDirection: t.currentDirection,
senderTrack: t.sender?.track?.kind ?? null,
})));
if (callEndedRef.current) {
pc.onconnectionstatechange = null;
pc.close();
peerRef.current = null;
stream.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
return;
}
const socket = getSocket();
socket?.emit('call_answer', {
targetUserId: incoming.from,
answer: pc.localDescription,
});
setCallType(effectiveCallType);
setCallState('connected');
console.log('[acceptCall] Call connected, effectiveCallType:', effectiveCallType);
timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
} catch (err: any) {
console.error('Error accepting call:', err);
if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') {
alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков');
}
if (!callEndedRef.current) {
setCallState('ended');
cleanup();
}
}
}, [incoming, cleanup, setupPeerHandlers]);
// Decline incoming call
const declineCall = useCallback(() => {
if (incoming) {
const socket = getSocket();
socket?.emit('call_decline', { targetUserId: incoming.from });
}
callEndedRef.current = true;
stopCallRingtone();
setCallState('ended');
cleanup();
scheduleClose();
}, [incoming, cleanup, scheduleClose]);
// Toggle mic
const toggleMic = () => {
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach((t) => {
t.enabled = !t.enabled;
});
setIsMuted(!isMuted);
}
};
// Volume control for remote audio
const handleVolumeChange = useCallback((vol: number) => {
const v = Math.max(0, Math.min(1, vol));
setRemoteVolume(v);
if (remoteAudioRef.current) remoteAudioRef.current.volume = v;
}, []);
// Noise gate with look-ahead: analyse undelayed signal, gate delayed signal
// This prevents clipping word beginnings and rejects short transients (clicks)
const applyNoiseGate = useCallback(async () => {
const pc = peerRef.current;
if (!pc || !localStreamRef.current) return;
const rawTrack = localStreamRef.current.getAudioTracks()[0];
if (!rawTrack) return;
try {
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(new MediaStream([rawTrack]));
// Look-ahead delay: signal is delayed so gate can open before audio arrives
const delayNode = ctx.createDelay(0.2);
delayNode.delayTime.value = 0.05; // 50ms look-ahead
// Analysis path: bandpass on voice fundamentals (undelayed, sees audio early)
const analysisHP = ctx.createBiquadFilter();
analysisHP.type = 'highpass'; analysisHP.frequency.value = 100; analysisHP.Q.value = 0.7;
const analysisLP = ctx.createBiquadFilter();
analysisLP.type = 'lowpass'; analysisLP.frequency.value = 4000; analysisLP.Q.value = 0.7;
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.3;
const gainNode = ctx.createGain();
gainNode.gain.value = 0;
const dest = ctx.createMediaStreamDestination();
// Signal path: source → delay → gain → output (full quality, just gated)
source.connect(delayNode);
delayNode.connect(gainNode);
gainNode.connect(dest);
// Analysis path (no delay): source → bandpass → analyser
source.connect(analysisHP);
analysisHP.connect(analysisLP);
analysisLP.connect(analyser);
const dataArray = new Float32Array(analyser.fftSize);
const OPEN_THRESHOLD = -38; // dB in voice band to trigger pending
const CLOSE_THRESHOLD = -48; // dB hysteresis / transient rejection
const CONFIRM_MS = 22; // ms energy must persist to confirm voice (rejects clicks)
const HOLD_TIME = 150; // ms keep gate open after speech stops
const ATTACK = 0.002; // fast open (gate already decided ahead of time)
const RELEASE = 0.02; // smooth close
// State machine: 0=closed, 1=pending (waiting to confirm voice), 2=open
let state = 0;
let pendingSince = 0;
let holdUntil = 0;
const check = () => {
analyser.getFloatTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
const rms = Math.sqrt(sum / dataArray.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
const now = performance.now();
if (state === 0) { // closed
if (db > OPEN_THRESHOLD) {
state = 1; // pending
pendingSince = now;
}
} else if (state === 1) { // pending — wait to confirm sustained energy (voice) vs transient (click)
if (db <= CLOSE_THRESHOLD) {
state = 0; // energy dropped fast → was a click, stay closed
} else if (now - pendingSince >= CONFIRM_MS) {
state = 2; // sustained → voice confirmed, open gate
holdUntil = now + HOLD_TIME;
gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK);
}
} else { // open
if (db > CLOSE_THRESHOLD) {
holdUntil = now + HOLD_TIME;
}
if (now >= holdUntil) {
state = 0;
gainNode.gain.setTargetAtTime(0, ctx.currentTime, RELEASE);
}
}
noiseGateRafRef.current = requestAnimationFrame(check);
};
check();
const gatedTrack = dest.stream.getAudioTracks()[0];
gatedTrack.enabled = !isMuted;
// Replace in PeerConnection
const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(gatedTrack);
noiseGateCtxRef.current = ctx;
noiseGateGainRef.current = gainNode;
noiseGateTrackRef.current = gatedTrack;
setNoiseSuppression(true);
} catch (err) {
console.error('Failed to apply noise gate:', err);
}
}, [isMuted]);
const removeNoiseGate = useCallback(async () => {
const pc = peerRef.current;
// Restore raw mic track directly to PeerConnection
if (pc && localStreamRef.current) {
const rawTrack = localStreamRef.current.getAudioTracks()[0];
if (rawTrack) {
const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(rawTrack);
}
}
if (noiseGateRafRef.current) { cancelAnimationFrame(noiseGateRafRef.current); noiseGateRafRef.current = 0; }
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
noiseGateGainRef.current = null;
setNoiseSuppression(false);
}, []);
const toggleNoiseSuppression = useCallback(async () => {
if (noiseSuppression) {
await removeNoiseGate();
} else {
await applyNoiseGate();
}
}, [noiseSuppression, applyNoiseGate, removeNoiseGate]);
// Enumerate microphones
const refreshMicrophones = useCallback(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
setMicrophones(audioInputs);
return audioInputs;
} catch { return []; }
}, []);
// Switch microphone
const switchMicrophone = useCallback(async (deviceId: string) => {
const pc = peerRef.current;
if (!pc) return;
setShowMicMenu(false);
try {
const newStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) return;
newTrack.enabled = !isMuted;
// Remove noise gate if active (will re-apply after switch if needed)
const wasGated = noiseSuppression;
if (wasGated) await removeNoiseGate();
// Replace in local stream
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
localStreamRef.current.addTrack(newTrack);
}
// Replace in PeerConnection
const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(newTrack);
setActiveMicId(deviceId);
// Re-apply noise gate if it was active
if (wasGated) await applyNoiseGate();
} catch (err) {
console.error('Switch mic failed:', err);
}
}, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]);
// Enumerate cameras
const refreshCameras = useCallback(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter(d => d.kind === 'videoinput');
setCameras(videoInputs);
return videoInputs;
} catch (e) {
console.warn('Could not enumerate cameras:', e);
return [];
}
}, []);
// Open camera menu (await enumeration, then show)
const openCameraMenu = useCallback(async () => {
if (showCameraMenu) {
setShowCameraMenu(false);
return;
}
const cams = await refreshCameras();
if (cams.length > 0) {
setShowCameraMenu(true);
}
}, [showCameraMenu, refreshCameras]);
// Switch to a specific camera by deviceId
const switchCamera = useCallback(async (deviceId: string) => {
const pc = peerRef.current;
if (!pc) return;
setShowCameraMenu(false);
const prevCameraId = activeCameraId;
try {
// Strategy: try to acquire new camera FIRST without stopping the old one.
// If that fails (some devices can't have 2 cameras open), stop old then retry.
let newStream: MediaStream | null = null;
try {
newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } },
});
} catch (e1) {
console.warn('Could not open new camera while old is active, releasing old first:', e1);
// Stop old camera tracks, then retry
const currentSender = findVideoSender(pc);
if (currentSender?.track) currentSender.track.stop();
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
}
newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } },
});
}
const newTrack = newStream.getVideoTracks()[0];
if (!newTrack) {
console.error('No video track from selected camera');
return;
}
// Now stop old tracks (if they weren't stopped already in the fallback path above)
const currentSender = findVideoSender(pc);
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
if (t !== newTrack) {
localStreamRef.current!.removeTrack(t);
t.stop();
}
});
}
if (currentSender?.track && currentSender.track !== newTrack) {
currentSender.track.stop();
}
// Replace track on sender or add new one
if (currentSender) {
await currentSender.replaceTrack(newTrack);
// If direction was recvonly, change to sendrecv
const transceiver = pc.getTransceivers().find(t => t.sender === currentSender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
transceiver.direction = 'sendrecv';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
} else {
pc.addTrack(newTrack, localStreamRef.current || newStream);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
// Update local stream
if (localStreamRef.current) {
localStreamRef.current.addTrack(newTrack);
} else {
localStreamRef.current = new MediaStream([newTrack]);
}
setActiveCameraId(deviceId);
setIsVideoOff(false);
setCallType('video');
} catch (err) {
console.error('Switch camera failed:', err);
// Try to restore previous camera so video isn't permanently lost
try {
let restoreStream: MediaStream | undefined;
// Try the exact previous camera first
if (prevCameraId) {
try {
restoreStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: prevCameraId } },
});
} catch { /* previous camera also unavailable */ }
}
// Fall back to any camera
if (!restoreStream) {
try {
restoreStream = await navigator.mediaDevices.getUserMedia({ video: true });
} catch { /* no camera at all */ }
}
if (restoreStream) {
const restoreTrack = restoreStream.getVideoTracks()[0];
if (restoreTrack) {
const sender = findVideoSender(pc);
if (sender) {
await sender.replaceTrack(restoreTrack);
// Ensure direction allows sending + renegotiate
const transceiver = pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
transceiver.direction = 'sendrecv';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
}
if (localStreamRef.current) {
localStreamRef.current.addTrack(restoreTrack);
} else {
localStreamRef.current = restoreStream;
}
setIsVideoOff(false);
}
}
} catch { /* nothing we can do */ }
}
}, [activeCameraId]);
// Toggle video: enable/disable video track, or get camera if none
const toggleVideo = useCallback(async () => {
const pc = peerRef.current;
if (!pc) return;
if (!isVideoOff) {
// Turn off: disable video track on sender and revert to voice
const sender = findVideoSender(pc);
if (sender?.track) {
sender.track.enabled = false;
}
setIsVideoOff(true);
setCallType('voice');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
} else {
// Turn on: re-enable existing track or get new camera
const sender = findVideoSender(pc);
if (sender?.track) {
sender.track.enabled = true;
setIsVideoOff(false);
} else {
try {
const { stream: camStream, hasVideo } = await getMediaWithCameraFallback(true);
if (!hasVideo) {
console.warn('No camera available');
return;
}
const videoTrack = camStream.getVideoTracks()[0];
// Find a video sender (may have null track from transceiver)
const existingSender = findVideoSender(pc);
if (existingSender) {
await existingSender.replaceTrack(videoTrack);
// If direction was recvonly, change to sendrecv and renegotiate
const transceiver = pc.getTransceivers().find(t => t.sender === existingSender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
transceiver.direction = 'sendrecv';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
} else {
pc.addTrack(videoTrack, localStreamRef.current || camStream);
// Renegotiate since new track added
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
if (localStreamRef.current) {
localStreamRef.current.addTrack(videoTrack);
}
setIsVideoOff(false);
setCallType('video');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
} catch (err) {
console.error('Could not start camera:', err);
}
}
}
}, [isVideoOff]);
// Screen sharing
const toggleScreenShare = useCallback(async () => {
const pc = peerRef.current;
if (!pc) return;
if (isScreenSharing) {
// Stop screen share
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
const hadVideo = hadVideoBeforeScreenShareRef.current;
if (hadVideo) {
// Restore camera — request video only (no audio) to avoid interfering with existing mic
let cameraRestored = false;
try {
const constraints: MediaStreamConstraints = activeCameraId
? { video: { deviceId: { exact: activeCameraId } } }
: { video: true };
const camStream = await navigator.mediaDevices.getUserMedia(constraints);
const cameraTrack = camStream.getVideoTracks()[0];
if (cameraTrack) {
const sender = findVideoSender(pc);
if (sender) {
await sender.replaceTrack(cameraTrack);
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
localStreamRef.current.addTrack(cameraTrack);
}
cameraRestored = true;
}
} catch (err) {
console.error('Error restoring camera:', err);
}
if (!cameraRestored) {
// Null out sender and go recvonly
const sender = findVideoSender(pc);
if (sender) {
await sender.replaceTrack(null);
const transceiver = pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && transceiver.direction !== 'recvonly') {
transceiver.direction = 'recvonly';
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
} catch (e) { console.error('Renegotiation after screen share stop failed:', e); }
}
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
}
setIsVideoOff(true);
}
} else {
// Was voice call — don't restore camera, just null out the video sender
const sender = findVideoSender(pc);
if (sender) {
await sender.replaceTrack(null);
const transceiver = pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && transceiver.direction !== 'recvonly') {
transceiver.direction = 'recvonly';
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
} catch (e) { console.error('Renegotiation after screen share stop failed:', e); }
}
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
}
setCallType('voice');
setIsVideoOff(false);
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
}
setIsScreenSharing(false);
} else {
// Start screen share — remember current video state
hadVideoBeforeScreenShareRef.current = callType === 'video' && !isVideoOff;
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } },
audio: false,
});
screenStreamRef.current = screenStream;
const screenTrack = screenStream.getVideoTracks()[0];
// Find video sender and replace track
const sender = findVideoSender(pc);
if (sender) {
await sender.replaceTrack(screenTrack);
const transceiver = pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
transceiver.direction = 'sendrecv';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
} else {
// No video sender — add track associated with localStream to keep audio on same stream
pc.addTrack(screenTrack, localStreamRef.current || screenStream);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
});
}
screenTrack.onended = async () => {
setIsScreenSharing(false);
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
const hadVideo = hadVideoBeforeScreenShareRef.current;
const snd = peerRef.current ? findVideoSender(peerRef.current) : undefined;
if (hadVideo) {
// Restore camera — video only, no audio
let cameraRestored = false;
try {
const constraints: MediaStreamConstraints = activeCameraId
? { video: { deviceId: { exact: activeCameraId } } }
: { video: true };
const camStream = await navigator.mediaDevices.getUserMedia(constraints);
const camTrack = camStream.getVideoTracks()[0];
if (camTrack) {
if (snd) await snd.replaceTrack(camTrack);
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
localStreamRef.current.addTrack(camTrack);
}
cameraRestored = true;
}
} catch (e) {
console.error('Error restoring camera:', e);
}
if (!cameraRestored && snd) {
await snd.replaceTrack(null);
const currentPc = peerRef.current;
if (currentPc) {
const transceiver = currentPc.getTransceivers().find(t => t.sender === snd);
if (transceiver && transceiver.direction !== 'recvonly') {
transceiver.direction = 'recvonly';
try {
const offer = await currentPc.createOffer();
await currentPc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: currentPc.localDescription,
});
} catch (e) { console.error('Renegotiation after screen share onended failed:', e); }
}
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
}
}
} else {
// Was voice call — just null out video sender
if (snd) {
await snd.replaceTrack(null);
const currentPc = peerRef.current;
if (currentPc) {
const transceiver = currentPc.getTransceivers().find(t => t.sender === snd);
if (transceiver && transceiver.direction !== 'recvonly') {
transceiver.direction = 'recvonly';
try {
const offer = await currentPc.createOffer();
await currentPc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: currentPc.localDescription,
});
} catch (e) { console.error('Renegotiation after screen share onended failed:', e); }
}
}
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
}
setCallType('voice');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
}
};
setIsScreenSharing(true);
setCallType('video');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
} catch (err) {
console.error('Error starting screen share:', err);
}
}
}, [isScreenSharing, activeCameraId, callType, isVideoOff]);
// Fullscreen toggle
const toggleFullscreen = useCallback(async () => {
if (!videoContainerRef.current) return;
try {
if (!document.fullscreenElement) {
await videoContainerRef.current.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
} catch (e) {
console.error('Fullscreen error:', e);
}
}, []);
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
// Socket event listeners
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const onCallAnswered = async (data: { from: string; answer: RTCSessionDescriptionInit }) => {
if (!peerRef.current || data.from !== targetUserIdRef.current) return;
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
stopCallRingtone();
console.log('[onCallAnswered] Setting remote description (answer)');
await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer));
console.log('[onCallAnswered] Transceivers after answer:',
peerRef.current.getTransceivers().map(t => ({
mid: t.mid,
direction: t.direction,
currentDirection: t.currentDirection,
senderTrack: t.sender?.track?.kind ?? null,
receiverTrack: t.receiver?.track?.kind ?? null,
})));
for (const candidate of iceCandidateBufferRef.current) {
peerRef.current?.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error);
}
iceCandidateBufferRef.current = [];
setCallState('connected');
timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
};
const onIceCandidate = (data: { from: string; candidate: RTCIceCandidateInit }) => {
if (peerRef.current && peerRef.current.remoteDescription) {
peerRef.current.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error);
} else {
iceCandidateBufferRef.current.push(data.candidate);
}
};
const onCallEnded = (data: { from: string }) => {
if (callEndedRef.current) return;
if (data.from !== targetUserIdRef.current) return;
callEndedRef.current = true;
stopCallRingtone();
setCallState('ended');
cleanup();
scheduleClose();
};
const onCallDeclined = (data: { from: string }) => {
if (callEndedRef.current) return;
if (data.from !== targetUserIdRef.current) return;
callEndedRef.current = true;
stopCallRingtone();
setCallState('ended');
cleanup();
scheduleClose();
};
const onCallUnavailable = () => {
if (callEndedRef.current) return;
callEndedRef.current = true;
stopCallRingtone();
setCallState('ended');
cleanup();
scheduleClose();
};
// Renegotiation (e.g. when remote adds screen share to audio-only call)
const onRenegotiate = async (data: { from: string; offer: RTCSessionDescriptionInit }) => {
if (!peerRef.current || data.from !== targetUserIdRef.current) return;
try {
await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.offer));
const answer = await peerRef.current.createAnswer();
await peerRef.current.setLocalDescription(answer);
socket.emit('renegotiate_answer', {
targetUserId: targetUserIdRef.current,
answer: peerRef.current.localDescription,
});
} catch (err) {
console.error('Renegotiation error:', err);
}
};
const onRenegotiateAnswer = async (data: { from: string; answer: RTCSessionDescriptionInit }) => {
if (!peerRef.current || data.from !== targetUserIdRef.current) return;
try {
await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer));
} catch (err) {
console.error('Renegotiation answer error:', err);
}
};
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video' }) => {
if (data.from !== targetUserIdRef.current) return;
console.log('[onCallTypeChanged] New call type:', data.callType);
setCallType(data.callType);
// If remote switched to video, nudge the video element
if (data.callType === 'video') {
setTimeout(() => {
if (remoteVideoRef.current && remoteStreamRef.current) {
remoteVideoRef.current.srcObject = remoteStreamRef.current;
remoteVideoRef.current.play().catch(() => {});
}
}, 500);
}
};
socket.on('call_answered', onCallAnswered);
socket.on('ice_candidate', onIceCandidate);
socket.on('call_ended', onCallEnded);
socket.on('call_declined', onCallDeclined);
socket.on('call_unavailable', onCallUnavailable);
socket.on('renegotiate', onRenegotiate);
socket.on('renegotiate_answer', onRenegotiateAnswer);
socket.on('call_type_changed', onCallTypeChanged);
return () => {
socket.off('call_answered', onCallAnswered);
socket.off('ice_candidate', onIceCandidate);
socket.off('call_ended', onCallEnded);
socket.off('call_declined', onCallDeclined);
socket.off('call_unavailable', onCallUnavailable);
socket.off('renegotiate', onRenegotiate);
socket.off('renegotiate_answer', onRenegotiateAnswer);
socket.off('call_type_changed', onCallTypeChanged);
};
}, [cleanup, scheduleClose]);
// Start call on mount (outgoing)
useEffect(() => {
if (isOpen && !incoming && targetUser && callState === 'idle') {
startCall();
}
if (isOpen && incoming && callState === 'idle') {
targetUserIdRef.current = incoming.from;
setCallState('incoming');
setCallType(incoming.callType);
playCallRingtone();
}
}, [isOpen, incoming, targetUser, callState, startCall]);
// Sync local video ref with stream (only when srcObject actually changes)
useEffect(() => {
if (!localVideoRef.current) return;
const desired = isScreenSharing && screenStreamRef.current
? screenStreamRef.current
: localStreamRef.current;
if (desired && localVideoRef.current.srcObject !== desired) {
localVideoRef.current.srcObject = desired;
}
});
// Sync remote video ref with remote stream (only when srcObject actually changes)
useEffect(() => {
if (!remoteVideoRef.current || !remoteStreamRef.current) return;
if (remoteVideoRef.current.srcObject !== remoteStreamRef.current) {
console.log('[useEffect] Syncing remote video srcObject');
remoteVideoRef.current.srcObject = remoteStreamRef.current;
remoteVideoRef.current.play().catch(() => { });
}
}, [hasRemoteVideo, callType]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopCallRingtone();
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
cleanup();
};
}, [cleanup]);
const formatDuration = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const displayName = incoming
? incoming.callerInfo?.displayName || incoming.callerInfo?.username || '...'
: targetUser?.displayName || targetUser?.username || '...';
const displayAvatar = incoming
? incoming.callerInfo?.avatar
: targetUser?.avatar;
const initials = displayName
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
if (!isOpen) return null;
const showVideoArea = callState === 'connected' && (hasRemoteVideo || (callType === 'video' && (!isVideoOff || isScreenSharing)));
const hasLocalVideo = !!(
localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing
);
return (
<AnimatePresence>
<audio key="remote-audio" ref={remoteAudioRef} autoPlay playsInline />
{/* === MINIMIZED VIEW === */}
{isMinimized && callState === 'connected' ? (
<motion.div
key="call-minimized"
initial={{ opacity: 0, y: 50, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.8 }}
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
onClick={() => setIsMinimized(false)}
>
{/* Avatar */}
<div className="relative">
<div className="absolute inset-0 rounded-full bg-vortex-500/30 animate-call-wave" />
{displayAvatar ? (
<img src={displayAvatar} alt="" className="relative w-10 h-10 rounded-full object-cover" />
) : (
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
{initials}
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm text-white font-medium truncate max-w-[120px]">{displayName}</p>
<p className="text-xs text-zinc-400 font-mono">{formatDuration(duration)}</p>
</div>
{/* Quick controls */}
<div className="flex items-center gap-2 ml-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={toggleMic}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={14} /> : <Mic size={14} />}
</button>
<button
onClick={endCallSafe}
className="w-8 h-8 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
>
<PhoneOff size={14} />
</button>
</div>
{/* No hidden elements here - they are managed in the main content area */}
</motion.div>
) : (
/* === FULL VIEW === */
<motion.div
key="call-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-label="Call"
className="fixed inset-0 z-[100] flex items-center justify-center bg-surface/90 backdrop-blur-xl overflow-hidden"
onClick={() => { setShowCameraMenu(false); setShowVolumeSlider(false); setShowMicMenu(false); }}
>
{/* Ambient background glow for call modal */}
<div className="absolute inset-0 pointer-events-none opacity-40">
<div className="absolute top-[10%] left-[20%] w-[50vh] h-[50vh] bg-vortex-500/30 rounded-full blur-[120px] animate-float" />
<div className="absolute bottom-[10%] right-[20%] w-[50vh] h-[50vh] bg-emerald-500/20 rounded-full blur-[120px] animate-float-delayed" />
</div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className={`relative w-full mx-4 rounded-[2.5rem] glass-strong shadow-2xl shadow-black/50 overflow-hidden border border-white/5 ${showVideoArea ? 'max-w-5xl' : 'max-w-md'
}`}
>
{/* Mic selector popup */}
{showMicMenu && microphones.length > 0 && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowMicMenu(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[320px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl py-2">
<div className="px-3 py-1.5 text-xs text-zinc-400 uppercase tracking-wider border-b border-zinc-700 mb-1">{t('selectMicrophone')}</div>
{microphones.map((mic, i) => (
<button
key={mic.deviceId}
onClick={() => switchMicrophone(mic.deviceId)}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${activeMicId === mic.deviceId
? 'text-vortex-400 bg-vortex-500/20 font-medium'
: 'text-zinc-200 hover:bg-zinc-700'
}`}
>
{mic.label || `${t('microphone')} ${i + 1}`}
</button>
))}
</div>
</>
)}
{/* Camera selector popup - rendered outside overflow-hidden container */}
{showCameraMenu && cameras.length > 0 && (
<>
{/* Backdrop to close on click outside */}
<div className="fixed inset-0 z-[200]" onClick={() => setShowCameraMenu(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[300px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl py-2">
<div className="px-3 py-1.5 text-xs text-zinc-400 uppercase tracking-wider border-b border-zinc-700 mb-1">{t('switchCamera')}</div>
{cameras.map((cam, i) => (
<button
key={cam.deviceId}
onClick={() => switchCamera(cam.deviceId)}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${activeCameraId === cam.deviceId
? 'text-vortex-400 bg-vortex-500/20 font-medium'
: 'text-zinc-200 hover:bg-zinc-700'
}`}
>
{cam.label || `Camera ${i + 1}`}
</button>
))}
</div>
</>
)}
{/* Volume slider popup */}
{showVolumeSlider && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowVolumeSlider(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[250px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl p-4" onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-zinc-400 uppercase tracking-wider mb-3">{t('volume')}</div>
<div className="flex items-center gap-3">
<Volume2 size={16} className="text-zinc-400 shrink-0" />
<input
type="range"
min="0"
max="1"
step="0.05"
value={remoteVolume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none bg-zinc-600 accent-vortex-500 cursor-pointer"
/>
<span className="text-xs text-zinc-300 w-8 text-right">{Math.round(remoteVolume * 100)}%</span>
</div>
</div>
</>
)}
{/* === Main Content Area === */}
<div className="flex-1 flex flex-col relative overflow-hidden">
{/*
CRITICAL: Single stable remote video element.
We keep it always mounted so WebRTC srcObject is never lost
during transitions between voice and video modes.
*/}
<video
ref={remoteVideoRef}
autoPlay
playsInline
muted
className={`
bg-black transition-all duration-500
${showVideoArea
? 'w-full h-full object-contain'
: 'w-0 h-0 opacity-0 absolute pointer-events-none'
}
`}
onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}
/>
{showVideoArea ? (
<div
ref={videoContainerRef}
className="absolute inset-0 flex items-center justify-center bg-black"
>
{/* Placeholder shown when video track is not yet arrived or muted */}
{!hasRemoteVideo && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-sm z-10">
<VideoOff size={48} className="text-zinc-500 mb-2" />
<span className="text-sm text-zinc-500">{displayName}</span>
</div>
)}
{/* Local video PIP (bottom-right) */}
{hasLocalVideo && (
<div className="absolute bottom-3 right-3 w-48 rounded-xl overflow-hidden border-2 border-white/20 shadow-lg bg-black z-20"
style={{ aspectRatio: '16 / 9' }}
>
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="w-full h-full object-contain bg-black"
/>
</div>
)}
{/* Duration badge */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-black/50 backdrop-blur-sm z-20">
<span className="text-sm text-white font-mono">{formatDuration(duration)}</span>
</div>
{/* Top-right buttons */}
<div className="absolute top-4 right-4 flex items-center gap-2 z-20">
<button
onClick={() => setIsMinimized(true)}
className="w-8 h-8 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white/70 hover:text-white transition-colors"
>
<Minimize2 size={16} />
</button>
<button
onClick={toggleFullscreen}
className="w-8 h-8 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white/70 hover:text-white transition-colors"
>
{isFullscreen ? <Minimize size={16} /> : <Maximize size={16} />}
</button>
</div>
</div>
) : (
/* === Voice / calling / incoming layout === */
<div className="flex-1 px-8 py-12 flex flex-col items-center justify-center relative z-10">
{/* Minimize button (top-right) */}
{callState === 'connected' && (
<button
onClick={() => setIsMinimized(true)}
className="absolute top-4 right-4 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/70 hover:text-white transition-colors"
title={t('minimize')}
>
<Minimize2 size={16} />
</button>
)}
<div className="relative mb-8 mt-4">
{(callState === 'calling' || callState === 'connected') && (
<>
<div className="absolute inset-0 rounded-full bg-vortex-500/30 animate-call-wave" />
<div className="absolute inset-0 rounded-full bg-vortex-500/20 animate-call-wave-delayed" />
</>
)}
{callState === 'incoming' && (
<>
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave-delayed" />
</>
)}
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl cursor-pointer">
{displayAvatar ? (
<img src={displayAvatar} alt="" className="w-32 h-32 rounded-full object-cover shadow-inner" />
) : (
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
{initials}
</div>
)}
</div>
</div>
<h3 className="text-2xl font-bold text-white mb-2 tracking-tight">{displayName}</h3>
<p className="text-sm text-zinc-400 mb-8">
{callState === 'calling' && t('calling')}
{callState === 'incoming' && (callType === 'video' ? t('incomingVideoCall') : t('incomingCall'))}
{callState === 'connected' && formatDuration(duration)}
{callState === 'ended' && t('callEnded')}
</p>
{/* Local video preview (during calling with camera) */}
{hasLocalVideo && callState === 'calling' && (
<div className="mb-6 rounded-2xl overflow-hidden bg-zinc-900 w-48 shadow-2xl border border-white/10">
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="w-full h-auto"
/>
</div>
)}
</div>
)}
</div>
{/* === Controls === */}
<div className="px-8 pb-10 flex items-center justify-center gap-4 relative z-10 flex-wrap">
{callState === 'incoming' && (
<>
<button
onClick={declineCall}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105"
>
<PhoneOff size={24} />
</button>
<div className="w-8" />
<button
onClick={acceptCall}
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white shadow-xl shadow-emerald-500/30 transition-all hover:scale-105 animate-pulse"
>
{callType === 'video' ? <Video size={24} /> : <Phone size={24} className="animate-bounce" />}
</button>
</>
)}
{callState === 'connected' && (
<>
{/* Mic button with dropdown */}
<div className="relative">
<button
onClick={toggleMic}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
}`}
title={isMuted ? t('unmute') : t('mute')}
>
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
</button>
<button
onClick={async (e) => { e.stopPropagation(); await refreshMicrophones(); setShowMicMenu(!showMicMenu); setShowCameraMenu(false); setShowVolumeSlider(false); }}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-zinc-700 hover:bg-zinc-600 flex items-center justify-center text-white/70 hover:text-white transition-colors border border-zinc-600"
>
<ChevronUp size={10} />
</button>
</div>
{/* Camera toggle */}
<button
onClick={toggleVideo}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
}`}
>
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
</button>
{/* Camera selector */}
<button
onClick={openCameraMenu}
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
title={t('switchCamera')}
>
<SwitchCamera size={18} />
</button>
<button
onClick={toggleScreenShare}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'
}`}
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</button>
{/* Volume control */}
<button
onClick={(e) => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
title={t('volume')}
>
<Volume2 size={18} />
</button>
{/* Noise suppression */}
<button
onClick={toggleNoiseSuppression}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${noiseSuppression ? 'bg-emerald-500/20 text-emerald-400' : 'bg-white/10 text-white hover:bg-white/20'
}`}
title={noiseSuppression ? t('noiseSuppressionOn') : t('noiseSuppressionOff')}
>
{noiseSuppression ? <ShieldCheck size={18} /> : <ShieldOff size={18} />}
</button>
<button
onClick={endCallSafe}
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105 ml-2"
>
<PhoneOff size={22} />
</button>
</>
)}
{callState === 'calling' && (
<button
onClick={endCallSafe}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105"
>
<PhoneOff size={24} />
</button>
)}
{callState === 'ended' && (
<p className="text-sm text-zinc-500">{t('callEnded')}</p>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}