Звонки

This commit is contained in:
Халимов Рустам
2026-04-05 23:41:55 +03:00
parent e11240f78f
commit 65d5f5fee9
12 changed files with 400 additions and 228 deletions

View File

@@ -287,7 +287,7 @@ const translations = {
maxFileSize: 'Max size for message attachments.',
noCopy: 'Prevent text copying in clients.',
links: 'Make URLs clickable.',
webRtc: 'Enable real-time audio and video calls. This is a master switch for the WebRTC module.',
webRtc: 'Enable WebRTC module. This allows voice calls and voice conferences. Video calls and screen sharing are configured separately.',
turn: 'Required for calls behind NAT.',
klipy: 'Integration for stickers and GIFs.',
federation: 'Communication between different Knot instances.',
@@ -457,7 +457,7 @@ const translations = {
maxFileSize: 'Лимит файлов в сообщениях.',
noCopy: 'Мешать копированию текста.',
links: 'Автоматические ссылки.',
webRtc: 'Включить возможность аудио и видео звонков. Глобальный переключатель для модуля WebRTC.',
webRtc: 'Включить модуль WebRTC. Это позволяет совершать аудиозвонки и аудиоконференции. Видеозвонки и трансляция экрана настраиваются отдельно.',
turn: 'Нужно для звонков за NAT.',
klipy: 'Стикеры и GIF.',
federation: 'Связь с другими серверами Knot.',

View File

@@ -1,10 +1,13 @@
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 Avatar from '../../../../core/presentation/components/ui/Avatar';
import { getSocket } from '../../../../core/infrastructure/socket';
import { UserApi } from '../../../users/infrastructure/userApi';
import { useAuthStore } from '../../../auth/application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../../../../core/utils/sounds';
import { getMediaUrl } from '../../../../core/utils/utils';
type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended';
@@ -108,13 +111,15 @@ function findVideoSender(pc: RTCPeerConnection): RTCRtpSender | undefined {
export default function CallModal({ isOpen, onClose, targetUser, callType: initialCallType, incoming }: CallModalProps) {
const { t } = useLang();
const { config } = useAuthStore();
const [callState, setCallState] = useState<CallState>('idle');
const [callType, setCallType] = useState<'voice' | 'video'>(initialCallType);
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice');
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenSharing, setRemoteScreenSharing] = useState(false);
const [remoteIsMuted, setRemoteIsMuted] = useState(false);
const [remoteIsVideoOffSignal, setRemoteIsVideoOffSignal] = useState(false);
const [hasRemoteVideo, setHasRemoteVideo] = useState(false);
const hasRemoteVideoRef = useRef(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -255,14 +260,49 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
}, [hasRemoteVideo, callState]);
// Setup common peer connection event handlers
const checkVideo = useCallback(() => {
const stream = remoteStreamRef.current;
if (!stream) {
setHasRemoteVideo(false);
return;
}
const videoTracks = stream.getVideoTracks();
// Use !t.muted to detect if remote is actually sending frames.
const hasVideo = videoTracks.length > 0 && videoTracks.some(
t => t.readyState === 'live' && !t.muted
) && !remoteIsVideoOffSignal;
if (hasVideo !== hasRemoteVideo) {
hasRemoteVideoRef.current = hasVideo;
setHasRemoteVideo(hasVideo);
}
// Auto-switch UI mode
// If we have video but callType is voice - expand
// BUT only if the remote hasn't explicitly told us their video is off (trust SignalR over phantom tracks)
if (hasVideo && callType === 'voice' && !remoteScreenSharing && !remoteIsVideoOffSignal) {
setCallType('video');
}
// If we NO LONGER have video but callType is video - shrink
else if (!hasVideo && callType === 'video' && !remoteScreenSharing && !isScreenSharing) {
setCallType('voice');
}
}, [hasRemoteVideo, callType, remoteScreenSharing, isScreenSharing]);
const setupPeerHandlers = useCallback((pc: RTCPeerConnection) => {
pc.onicecandidate = (e) => {
if (e.candidate) {
// Detailed log of candidate types to debug NAT traversal
const type = e.candidate.type || (e.candidate.candidate.includes('typ relay') ? 'relay' : e.candidate.candidate.includes('typ srflx') ? 'srflx' : 'host');
console.log(`[WebRTC] Local candidate: ${type} - ${e.candidate.candidate.split(' ')[4]}:${e.candidate.candidate.split(' ')[5]}`);
const socket = getSocket();
socket?.emit('ice_candidate', {
targetUserId: targetUserIdRef.current,
candidate: e.candidate,
});
} else {
console.log('[WebRTC] Local candidate gathering finished');
}
};
@@ -287,40 +327,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
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) {
console.log(`[checkVideo] hasRemoteVideo changed: ${hasRemoteVideoRef.current} -> ${hasVideo}`);
hasRemoteVideoRef.current = hasVideo;
setHasRemoteVideo(hasVideo);
}
// Auto-switch UI mode if remote starts sending video or STOPS sending it
if (hasVideo && callType === 'voice') {
console.log('[checkVideo] Remote started video, switching UI to video mode');
setCallType('video');
} else if (!hasVideo && callType === 'video') {
// If we were in video mode but tracks are gone, revert to voice
console.log('[checkVideo] Remote stopped video, reverting UI to voice mode');
setCallType('voice');
}
};
// Force binding to the SINGLE stable video element
@@ -980,26 +986,56 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
const pc = peerRef.current;
if (!pc) return;
const socket = getSocket();
if (!isVideoOff) {
// Turn off: disable video track on sender and revert to voice
const sender = findVideoSender(pc);
if (sender?.track) {
sender.track.enabled = false;
sender.track.stop(); // Completely stop to trigger onmute/onended on remote
}
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => {
t.enabled = false;
t.stop();
localStreamRef.current?.removeTrack(t);
});
}
setIsVideoOff(true);
setCallType('voice');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
socket?.emit('call_type_changed', {
targetUserId: targetUserIdRef.current,
callType: 'voice',
isVideoOff: true
});
socket?.emit('call_status', {
targetUserId: targetUserIdRef.current,
isMuted: isMuted,
isVideoOff: true
});
} else {
// Turn on: re-enable existing track or get new camera
// Turn on: re-enable existing track OR get new camera if none/ended
const sender = findVideoSender(pc);
if (sender?.track) {
if (sender?.track && sender.track.readyState !== 'ended') {
sender.track.enabled = true;
setIsVideoOff(false);
setCallType('video');
socket?.emit('call_type_changed', {
targetUserId: targetUserIdRef.current,
callType: 'video',
isVideoOff: false
});
socket?.emit('call_status', {
targetUserId: targetUserIdRef.current,
isMuted: isMuted,
isVideoOff: false
});
setRemoteIsVideoOffSignal(false);
} else {
try {
const { stream: camStream, hasVideo } = await getMediaWithCameraFallback(true);
if (!hasVideo) {
const { stream: camStream, hasVideo: trackFound } = await getMediaWithCameraFallback(true);
if (!trackFound) {
console.warn('No camera available');
return;
}
@@ -1015,7 +1051,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
transceiver.direction = 'sendrecv';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('renegotiate', {
targetUserId: targetUserIdRef.current,
offer: pc.localDescription,
@@ -1026,7 +1061,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
// 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,
@@ -1039,14 +1073,23 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
setIsVideoOff(false);
setCallType('video');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
setRemoteIsVideoOffSignal(false);
socket?.emit('call_type_changed', {
targetUserId: targetUserIdRef.current,
callType: 'video',
isVideoOff: false
});
socket?.emit('call_status', {
targetUserId: targetUserIdRef.current,
isMuted: isMuted,
isVideoOff: false
});
} catch (err) {
console.error('Could not start camera:', err);
}
}
}
}, [isVideoOff]);
}, [isVideoOff, isMuted, callType]);
// Screen sharing
const toggleScreenShare = useCallback(async () => {
@@ -1141,9 +1184,9 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
});
}
setCallType('voice');
setIsVideoOff(false);
setIsVideoOff(true);
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false });
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false, isVideoOff: true });
}
setIsScreenSharing(false);
@@ -1271,17 +1314,24 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
});
}
setCallType('voice');
setIsVideoOff(true);
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false });
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false, isVideoOff: true });
}
};
setIsScreenSharing(true);
setCallType('video');
setIsVideoOff(false);
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true });
} catch (err) {
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true, isVideoOff: false });
} catch (err: any) {
console.error('Error starting screen share:', err);
setIsScreenSharing(false);
if (!hadVideoBeforeScreenShareRef.current) {
setCallType('voice');
setIsVideoOff(true);
}
}
}
}, [isScreenSharing, activeCameraId, callType, isVideoOff]);
@@ -1388,17 +1438,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
// Nudge video check after renegotiation
setTimeout(() => {
console.log('[onRenegotiate] Nudging video check');
// pc.ontrack should have fired by now, calling checkVideo via setHasRemoteVideo
// but we can trigger it manually if we find the stream
if (remoteStreamRef.current) {
const hasVideo = remoteStreamRef.current.getVideoTracks().some(t => t.readyState === 'live');
console.log('[onRenegotiate] Manually found video tracks:', hasVideo);
if (hasVideo && !hasRemoteVideoRef.current) {
hasRemoteVideoRef.current = true;
setHasRemoteVideo(true);
setCallType('video');
}
}
checkVideo();
}, 1000);
} catch (err) {
console.error('Renegotiation error:', err);
@@ -1414,36 +1454,60 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
}
};
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: boolean }) => {
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: any; isVideoOff?: any }) => {
if (data.from !== targetUserIdRef.current) return;
console.log('[onCallTypeChanged] New call type:', data.callType, 'isRemoteScreenSharing:', data.isScreenSharing);
setCallType(data.callType);
setRemoteScreenSharing(!!data.isScreenSharing);
// If remote switched to voice, clear their video flag so we collapse the UI
if (data.callType === 'voice') {
const isRemoteSharing = data.isScreenSharing === true || data.isScreenSharing === 'True' || data.isScreenSharing === 'true';
const isRemoteVideoOff = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true';
console.log('[onCallTypeChanged] New call type:', data.callType, 'isRemoteScreenSharing:', isRemoteSharing);
setCallType(data.callType);
setRemoteScreenSharing(isRemoteSharing);
if (data.isVideoOff !== undefined) {
setRemoteIsVideoOffSignal(isRemoteVideoOff);
if (isRemoteVideoOff) {
hasRemoteVideoRef.current = false;
setHasRemoteVideo(false);
}
}
// If remote switched to voice WITHOUT screen sharing, clear their video flag
if (data.callType === 'voice' && !isRemoteSharing) {
hasRemoteVideoRef.current = false;
setHasRemoteVideo(false);
setRemoteIsVideoOffSignal(true);
}
// If remote switched to video, nudge the video element
if (data.callType === 'video' || data.callType === 'voice') {
setTimeout(() => {
if (remoteVideoRef.current && remoteStreamRef.current) {
console.log(`[onCallTypeChanged] Nudging playback for ${data.callType}`);
remoteVideoRef.current.srcObject = remoteStreamRef.current;
remoteVideoRef.current.play().catch(e => {
if (e.name === 'NotAllowedError') setNeedsInteraction(true);
console.warn('Auto-play failed:', e);
});
}
}, 1000);
}
// Nudge video check
setTimeout(checkVideo, 500);
// Handle playback
setTimeout(() => {
if (remoteVideoRef.current && remoteStreamRef.current) {
console.log(`[onCallTypeChanged] Nudging playback for ${data.callType}`);
remoteVideoRef.current.srcObject = remoteStreamRef.current;
remoteVideoRef.current.play().catch(e => {
if (e.name === 'NotAllowedError') setNeedsInteraction(true);
});
}
}, 1000);
};
const onCallStatusUpdated = (data: { from: string; isMuted: boolean; isVideoOff: boolean }) => {
const onCallStatusUpdated = (data: { from: string; isMuted: any; isVideoOff: any }) => {
if (data.from !== targetUserIdRef.current) return;
setRemoteIsMuted(data.isMuted);
const isMutedVal = data.isMuted === true || data.isMuted === 'True' || data.isMuted === 'true';
const isVideoOffVal = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true';
setRemoteIsMuted(isMutedVal);
setRemoteIsVideoOffSignal(isVideoOffVal);
if (isVideoOffVal) {
if (callType === 'video' && !isScreenSharing && !remoteScreenSharing) {
setCallType('voice');
}
}
};
socket.on('call_answered', onCallAnswered);
@@ -1566,10 +1630,16 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
if (!isOpen) return null;
const showVideoArea = callState === 'connected' && (callType === 'video' || remoteScreenSharing || isScreenSharing) && (hasRemoteVideo || !isVideoOff || isScreenSharing || remoteScreenSharing);
const hasLocalVideo = !!(
localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing
);
const hasLocalVideoDisplay = hasLocalVideo && (callType === 'video' || isScreenSharing);
const showVideoArea = callState === 'connected' && (
(hasRemoteVideo && !remoteIsVideoOffSignal) ||
hasLocalVideoDisplay ||
isScreenSharing ||
remoteScreenSharing
);
return (
<AnimatePresence>
@@ -1723,34 +1793,43 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
ref={remoteVideoRef}
autoPlay
playsInline
className={`w-full h-full object-contain transition-opacity duration-300 ${hasRemoteVideo ? 'opacity-100' : 'opacity-0'}`}
className={`w-full h-full object-contain transition-opacity duration-300 ${(hasRemoteVideo && !remoteIsVideoOffSignal) ? '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">
{/* Placeholder shown when remote video is not active */}
{(!hasRemoteVideo || remoteIsVideoOffSignal) && !remoteScreenSharing && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-md z-10">
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-knot-500/20 animate-ping" />
{displayAvatar ? (
<img src={displayAvatar} alt="" className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-knot-500/30" />
) : (
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-3xl sm:text-4xl font-black border-4 border-primary/30 shadow-2xl">
{initials}
</div>
)}
<div className="absolute inset-0 rounded-full bg-knot-500/10 animate-pulse" />
<Avatar
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
name={displayName || '?'}
size="2xl"
className="relative shadow-2xl border-4 border-white/10"
/>
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-zinc-800 border-2 border-zinc-700 flex items-center justify-center text-red-400 shadow-xl z-20">
<VideoOff size={20} />
</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-knot-500 animate-pulse" />
<span className="text-sm animate-pulse">
{isScreenSharing ? 'Вы транслируете экран...' : 'Ожидание потока...'}
<div className="flex items-center gap-2 text-zinc-400">
<span className="text-sm">
{callState === 'connected' ? 'Камера собеседника выключена' : 'Ожидание подключения...'}
</span>
</div>
</div>
)}
{/* Show different placeholder for screen sharing if it hasn't loaded yet */}
{!hasRemoteVideo && remoteScreenSharing && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900 z-10">
<Monitor size={48} className="text-knot-500 mb-4 animate-pulse" />
<span className="text-xl text-white font-medium mb-1">{displayName}</span>
<span className="text-sm text-zinc-400">Трансляция экрана...</span>
</div>
)}
{/* Remote muted indicator for video call over remote stream */}
{hasRemoteVideo && remoteIsMuted && (
<div className="absolute top-4 left-4 z-20 flex items-center gap-2 bg-black/60 backdrop-blur-sm px-3 py-1.5 rounded-full text-red-400">
@@ -1785,7 +1864,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
)}
{/* Local video PIP (bottom-right) */}
{hasLocalVideo && (
{hasLocalVideoDisplay && (
<div className="absolute bottom-4 right-4 w-28 sm:w-48 rounded-xl overflow-hidden border-2 border-white/20 shadow-lg bg-black z-20"
style={{ aspectRatio: '16 / 9' }}
>
@@ -1848,13 +1927,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
</>
)}
<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-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-inner" />
) : (
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-3xl sm:text-4xl shadow-2xl border-4 border-primary/20">
{initials}
</div>
)}
<Avatar
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
name={displayName || '?'}
size="2xl"
className="relative shadow-inner"
/>
</div>
</div>
@@ -1931,36 +2009,45 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
<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'
{config?.webRtc?.enableVideoCalls && (
<>
{/* 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'
}`}
title={isVideoOff ? "Включить камеру" : "Выключить камеру"}
>
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
</button>
{/* Camera selector */}
{!isVideoOff && callType === 'video' && (
<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>
)}
</>
)}
{config?.webRtc?.enableScreenSharing && (
<button
onClick={toggleScreenShare}
disabled={remoteScreenSharing}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${
remoteScreenSharing
? 'bg-zinc-800 text-zinc-600 cursor-not-allowed hidden sm:flex'
: isScreenSharing
? 'bg-knot-500/30 text-knot-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}
disabled={remoteScreenSharing}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${
remoteScreenSharing
? 'bg-zinc-800 text-zinc-600 cursor-not-allowed hidden sm:flex'
: isScreenSharing
? 'bg-knot-500/30 text-knot-400'
: 'bg-white/10 text-white hover:bg-white/20'
}`}
title={remoteScreenSharing ? (t('remoteSharingScreen' as any) as string) : isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</button>
title={remoteScreenSharing ? (t('remoteSharingScreen' as any) as string) : isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</button>
)}
{/* Volume control */}
<button
onClick={(e) => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}

View File

@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
import { useChatStore } from '../../../chats/application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import { getSocket } from '../../../../core/infrastructure/socket';
import { getMediaUrl } from '../../../../core/utils/utils';
import { UserApi } from '../../../users/infrastructure/userApi';
@@ -58,6 +59,7 @@ async function getIceServers(): Promise<RTCConfiguration> {
export default function GroupCallModal({ isOpen, onClose, chatId, chatName, callType: initialCallType }: GroupCallModalProps) {
const { t } = useLang();
const { user, config } = useAuthStore();
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice');
const [isScreenSharing, setIsScreenSharing] = useState(false);
@@ -153,8 +155,13 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
pc.onicecandidate = (e) => {
if (e.candidate) {
const type = (e.candidate as any).type || (e.candidate.candidate.includes('typ relay') ? 'relay' : e.candidate.candidate.includes('typ srflx') ? 'srflx' : 'host');
console.log(`[GroupCall] Local candidate for ${targetUserId}: ${type} - ${e.candidate.candidate.split(' ')[4]}:${e.candidate.candidate.split(' ')[5]}`);
const socket = getSocket();
socket?.emit('group_ice_candidate', { chatId, targetUserId, candidate: e.candidate });
} else {
console.log(`[GroupCall] Local candidate gathering for ${targetUserId} finished`);
}
};
@@ -176,13 +183,20 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
forceUpdate(n => n + 1);
};
// Play audio through audio element
const audioEl = remoteAudioRefs.current.get(targetUserId);
if (audioEl && audioEl.srcObject !== remoteStream) {
audioEl.srcObject = remoteStream;
audioEl.volume = remoteVolume;
audioEl.play().catch(() => {});
// Play audio through audio element (dedicated per participant to ensure sound even if video is hidden/off)
let audioEl = remoteAudioRefs.current.get(targetUserId);
if (!audioEl) {
audioEl = new Audio();
audioEl.autoplay = true;
remoteAudioRefs.current.set(targetUserId, audioEl);
}
if (audioEl.srcObject !== remoteStream) {
console.log(`[GroupCall] Binding audio for ${targetUserId}`);
audioEl.srcObject = remoteStream;
}
audioEl.volume = remoteVolume;
audioEl.play().catch(err => console.warn(`[GroupCall] Audio play failed for ${targetUserId}:`, err));
forceUpdate(n => n + 1);
};
@@ -693,12 +707,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
});
};
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: boolean; isVideoOff: boolean }) => {
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: any; isVideoOff: any }) => {
if (data.chatId !== chatId) return;
const isMutedVal = data.isMuted === true || data.isMuted === 'True' || data.isMuted === 'true';
const isVideoOffVal = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true';
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isMuted: data.isMuted, isVideoOff: data.isVideoOff });
if (p) next.set(data.userId, { ...p, isMuted: isMutedVal, isVideoOff: isVideoOffVal });
return next;
});
};
@@ -916,10 +933,6 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
const theMaximized = mainId ? allParticipants.find(p => p.id === mainId) : null;
const renderParticipantBlock = (p: any, isMaximized = false) => {
const initials = p.isSelf
? (t('you')?.charAt(0).toUpperCase() || 'Я')
: (p.displayName || p.username).split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div
key={p.id}
@@ -930,12 +943,46 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
style={!isMaximized && theMaximized ? { width: '200px', height: '112px' } : {}}
>
{p.isSelf ? (
p.hasVideo ? <video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" /> : <div className="flex flex-col items-center"><div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
p.hasVideo ? (
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center">
<div className="relative mb-2">
<Avatar
src={p.avatar ? getMediaUrl(p.avatar) : null}
name={t('you') || '?'}
size="lg"
className="shadow-xl"
/>
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-zinc-400 z-10">
<VideoOff size={12} />
</div>
</div>
<span className="text-[10px] text-zinc-400 uppercase tracking-wider font-medium">{t('you')}</span>
</div>
)
) : (
p.hasVideo && (!p.isVideoOff || p.isSharingScreen) ? <video id={`video-${p.id}`} autoPlay playsInline muted ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" /> : <div className="flex flex-col items-center">{p.avatar ? <img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" /> : <div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>}{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
p.hasVideo && (!p.isVideoOff || p.isSharingScreen) ? (
<video id={`video-${p.id}`} autoPlay playsInline ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center">
<div className="relative mb-2">
<Avatar
src={p.avatar ? getMediaUrl(p.avatar) : null}
name={p.displayName || p.username || '?'}
size="lg"
className="shadow-xl"
/>
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-red-400/80 z-10">
<VideoOff size={12} />
</div>
</div>
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-medium">Камера выключена</span>
</div>
)
)}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/40 backdrop-blur-md text-[10px] text-white truncate max-w-[80%] flex items-center gap-1 border border-white/5">
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted && <MicOff size={10} className="text-red-400" />}
</div>
{isMaximized && p.hasVideo && (!p.isVideoOff || p.isSelf) && (
<button
@@ -1005,23 +1052,27 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
</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>
<button
onClick={toggleScreenShare}
disabled={!!sharingUserId && !isScreenSharing}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
: 'bg-white/10 text-white hover:bg-white/15'
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
>
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
</button>
{config?.webRtc?.enableVideoCalls && (
<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>
)}
{config?.webRtc?.enableScreenSharing && (
<button
onClick={toggleScreenShare}
disabled={!!sharingUserId && !isScreenSharing}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
: 'bg-white/10 text-white hover:bg-white/15'
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
>
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
</button>
)}
<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"

View File

@@ -812,17 +812,19 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('call')}>
<span className="material-symbols-outlined">call</span>
</button>
<button
onClick={() => {
if (chat.type === 'personal' && otherMember) {
onStartCall?.(otherMember.user, 'video');
} else if (chat.type === 'group') {
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
}
}}
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
<span className="material-symbols-outlined">videocam</span>
</button>
{config?.webRtc?.enableVideoCalls && (
<button
onClick={() => {
if (chat.type === 'personal' && otherMember) {
onStartCall?.(otherMember.user, 'video');
} else if (chat.type === 'group') {
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
}
}}
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
<span className="material-symbols-outlined">videocam</span>
</button>
)}
</>
)}
@@ -985,7 +987,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{/* Закреплённое сообщение */}
{/* Active group call banner */}
{chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && (
{chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && (
<button
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"

View File

@@ -355,17 +355,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<div className="relative group">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-full blur-[40px] pointer-events-none" />
<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">
{chat.avatar ? (
<img
src={getMediaUrl(chat.avatar)}
alt=""
className="w-32 h-32 rounded-full object-cover shadow-inner"
/>
) : (
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-4xl shadow-inner">
{initials}
</div>
)}
<Avatar
src={chat.avatar ? getMediaUrl(chat.avatar) : null}
name={chat.name || '?'}
size="2xl"
className="shadow-inner"
/>
</div>
{isAdmin && (
@@ -574,13 +569,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
onClick={() => handleAddMember(u.id)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
>
{u.avatar ? (
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{(u.displayName || u.username || '?')[0].toUpperCase()}
</div>
)}
<Avatar
src={u.avatar ? getMediaUrl(u.avatar) : null}
name={u.displayName || u.username || '?'}
size="sm"
online={u.isOnline}
/>
<div className="flex-1 text-left min-w-0">
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
<p className="text-xs text-zinc-500">@{u.username}</p>
@@ -613,16 +607,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
>
<div className="relative flex-shrink-0">
{member.user.avatar ? (
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
</div>
)}
{member.user.isOnline && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
<Avatar
src={member.user.avatar ? getMediaUrl(member.user.avatar) : null}
name={member.user.displayName || member.user.username || '?'}
size="sm"
online={member.user.isOnline}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">

View File

@@ -15,7 +15,8 @@ import { httpClient } from '../../../../core/infrastructure/httpClient';
import { Loader2 } from 'lucide-react';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
import { getInitials } from '../../../../core/utils/utils';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
interface UserProfileProps {
userId: string;
@@ -227,12 +228,13 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<div className="flex flex-col items-center mb-10">
<div className="relative mb-8 group">
<div className="absolute -inset-4 bg-primary/20 blur-3xl rounded-[3rem] opacity-40 group-hover:opacity-60 transition-opacity" />
<div className="relative w-40 h-40 rounded-[3rem] bg-zinc-900 border-2 border-white/10 overflow-hidden shadow-2xl">
{user.avatar ? (
<img src={resolveUrl(user.avatar)} className="w-full h-full object-cover" alt="" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/30 to-primary-container/10 text-5xl font-black text-primary">{getInitials(user.displayName)}</div>
)}
<div className="relative w-40 h-40">
<Avatar
src={user.avatar ? resolveUrl(user.avatar) : null}
name={user.displayName || '?'}
size="3xl"
className="shadow-2xl"
/>
</div>
</div>
<h2 className="text-4xl font-black text-white mb-3 tracking-tighter text-center">{user.displayName}</h2>