1819 lines
74 KiB
TypeScript
1819 lines
74 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)
|
||
let cachedIceConfig: RTCConfiguration | null = null;
|
||
let iceCacheFetchedAt = 0;
|
||
const ICE_CACHE_TTL = 3600_000; // 1 hour
|
||
|
||
const FALLBACK_ICE: RTCConfiguration = {
|
||
iceServers: [
|
||
{ urls: 'stun:stun.yandex.ru:3478' },
|
||
{ urls: 'stun:stun.sipnet.net:3478' },
|
||
{ urls: 'stun:stun.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 remote media via the remoteVideoRef
|
||
if (remoteVideoRef.current) {
|
||
if (remoteVideoRef.current.srcObject !== stream) {
|
||
console.log('[WebRTC] Binding stream to video element');
|
||
remoteVideoRef.current.srcObject = stream;
|
||
remoteVideoRef.current.load();
|
||
}
|
||
// Important: Unmute for remote audio to work
|
||
remoteVideoRef.current.muted = false;
|
||
remoteVideoRef.current.play().catch(e => {
|
||
console.warn('[WebRTC] Play failed, waiting for user interaction:', 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();
|
||
}
|
||
};
|
||
|
||
pc.oniceconnectionstatechange = () => {
|
||
console.log('[WebRTC] ICE Connection state:', pc.iceConnectionState);
|
||
};
|
||
}, [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 {
|
||
// Security context check (critical for KeenDNS)
|
||
if (!window.isSecureContext) {
|
||
alert('Критическая ошибка: Совершение аудио/видео звонков требует безопасного соединения (HTTPS).\n\nЗайдите в настройки KeenDNS и убедитесь, что включено HTTPS (SSL), а в браузере используйте https:// вместо http://');
|
||
setCallState('ended');
|
||
return;
|
||
}
|
||
|
||
// 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>
|
||
{/* Remote audio is handled by the video element below */}
|
||
|
||
{/* === 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
|
||
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>
|
||
);
|
||
}
|