Files
forkmessager/apps/web/src/components/CallModal.tsx
Халимов Рустам 8c7ca3b869 Ф2
2026-03-11 17:15:23 +03:00

1879 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 hasRemoteVideoRef = useRef(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 [needsInteraction, setNeedsInteraction] = 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]);
// Ensure remote stream is bound to video element when it becomes available
useEffect(() => {
if (hasRemoteVideo && remoteVideoRef.current && remoteStreamRef.current) {
const video = remoteVideoRef.current;
const stream = remoteStreamRef.current;
if (video.srcObject !== stream) {
console.log('[WebRTC] Effect: Binding stream to video element');
video.srcObject = stream;
video.muted = false;
video.play().catch(e => {
if (e.name === 'NotAllowedError') setNeedsInteraction(true);
});
}
}
}, [hasRemoteVideo]);
// 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);
if (hasVideo !== hasRemoteVideoRef.current) {
hasRemoteVideoRef.current = hasVideo;
setHasRemoteVideo(hasVideo);
}
if (hasVideo && callType !== 'video') {
setCallType('video');
}
};
// Force binding to the SINGLE stable video element
// Manage playback via startPlayback helper
if (remoteVideoRef.current) {
const video = remoteVideoRef.current as any;
const startPlayback = async () => {
// If a play request is already in progress, don't start a new one (as per Chrome docs)
if (video._playPromise) {
console.log('[WebRTC] Play is already in progress, waiting...');
return;
}
try {
// Small stabilization delay (100ms)
await new Promise(r => setTimeout(r, 100));
if (video.srcObject !== stream) {
console.log('[WebRTC] Binding stream to video element');
video.srcObject = stream;
}
video.muted = false;
console.log('[WebRTC] Calling video.play()...');
video._playPromise = video.play();
await video._playPromise;
console.log('[WebRTC] Playback confirmed');
video._playPromise = null;
setNeedsInteraction(false);
} catch (e: any) {
video._playPromise = null;
if (e.name === 'NotAllowedError') {
console.warn('[WebRTC] Autoplay blocked, showing UI');
setNeedsInteraction(true);
} else if (e.name === 'AbortError') {
console.log('[WebRTC] Play aborted (likely concurrent track), will be retried automatically');
} else {
console.error('[WebRTC] Fatal playback error:', e);
}
}
};
startPlayback();
}
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 bg-black min-h-[400px]">
{showVideoArea ? (
<div
ref={videoContainerRef}
className="absolute inset-0 flex items-center justify-center z-0"
>
<video
ref={remoteVideoRef}
autoPlay
playsInline
className={`w-full h-full object-contain transition-opacity duration-300 ${hasRemoteVideo ? 'opacity-100' : 'opacity-0'}`}
onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}
/>
{/* Placeholder shown ONLY when track is not yet ready */}
{!hasRemoteVideo && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900 z-10">
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-vortex-500/20 animate-ping" />
{displayAvatar ? (
<img src={displayAvatar} alt="" className="relative w-32 h-32 rounded-full object-cover border-4 border-vortex-500/30" />
) : (
<div className="relative w-32 h-32 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-4xl font-bold border-4 border-vortex-500/30">
{initials}
</div>
)}
</div>
<VideoOff size={48} className="text-zinc-500 mb-2" />
<span className="text-xl text-white font-medium mb-1">{displayName}</span>
<div className="flex items-center gap-2 text-zinc-500">
<div className="w-2 h-2 rounded-full bg-vortex-500 animate-pulse" />
<span className="text-sm animate-pulse">Ожидание потока...</span>
</div>
</div>
)}
{/* Autoplay block overlay */}
{needsInteraction && hasRemoteVideo && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 backdrop-blur-md z-30">
<button
onClick={() => {
if (remoteVideoRef.current) {
remoteVideoRef.current.muted = false;
remoteVideoRef.current.play()
.then(() => {
setNeedsInteraction(false);
})
.catch(err => {
console.error('[WebRTC] Manual play failed:', err);
});
}
}}
className="px-6 py-3 rounded-full bg-vortex-500 hover:bg-vortex-600 text-white font-bold shadow-2xl transition-all hover:scale-105 flex items-center gap-2"
>
<Volume2 size={24} />
Включить звук и видео
</button>
<p className="mt-4 text-zinc-300 text-sm">Браузер заблокировал автоматический запуск</p>
</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>
);
}