Починенные групповые звонки

This commit is contained in:
Халимов Рустам
2026-03-15 01:18:09 +03:00
parent 924bf87cf4
commit fd467210f2
3 changed files with 69 additions and 41 deletions

View File

@@ -342,6 +342,7 @@ public sealed class ChatHub : Hub
{
from = _userContext.UserId.ToString(),
callType = request.CallType,
isScreenSharing = request.IsScreenSharing
});
}
@@ -625,7 +626,7 @@ public sealed class ChatHub : Hub
public record IceCandidateRequest(string TargetUserId, object Candidate);
public record RenegotiateRequest(string TargetUserId, object Offer);
public record RenegotiateAnswerRequest(string TargetUserId, object Answer);
public record CallTypeChangedRequest(string TargetUserId, string CallType);
public record CallTypeChangedRequest(string TargetUserId, string CallType, bool IsScreenSharing = false);
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);

View File

@@ -113,6 +113,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenSharing, setRemoteScreenSharing] = useState(false);
const [hasRemoteVideo, setHasRemoteVideo] = useState(false);
const hasRemoteVideoRef = useRef(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -183,6 +184,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
setIsVideoOff(false);
setIsScreenSharing(false);
setHasRemoteVideo(false);
setRemoteScreenSharing(false);
setIsFullscreen(false);
setIsMinimized(false);
setShowCameraMenu(false);
@@ -1131,7 +1133,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
setCallType('voice');
setIsVideoOff(false);
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false });
}
setIsScreenSharing(false);
@@ -1260,14 +1262,14 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
}
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', isScreenSharing: false });
}
};
setIsScreenSharing(true);
setCallType('video');
const socket = getSocket();
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true });
} catch (err) {
console.error('Error starting screen share:', err);
}
@@ -1402,10 +1404,17 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
}
};
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video' }) => {
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: boolean }) => {
if (data.from !== targetUserIdRef.current) return;
console.log('[onCallTypeChanged] New call type:', data.callType);
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') {
hasRemoteVideoRef.current = false;
setHasRemoteVideo(false);
}
// If remote switched to video, nudge the video element
if (data.callType === 'video' || data.callType === 'voice') {
@@ -1540,7 +1549,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
if (!isOpen) return null;
const showVideoArea = callState === 'connected' && (hasRemoteVideo || (callType === 'video' && (!isVideoOff || isScreenSharing)));
const showVideoArea = callState === 'connected' && callType === 'video' && (hasRemoteVideo || !isVideoOff || isScreenSharing);
const hasLocalVideo = !!(
localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing
);
@@ -1908,9 +1917,15 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
</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')}
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-vortex-500/30 text-vortex-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>

View File

@@ -304,35 +304,45 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: newVideoOff });
}, [isVideoOff, isMuted, chatId]);
// Stop screensharing helper
const stopScreenShareCore = useCallback(async () => {
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with camera track on all peers
for (const [targetUserId, peer] of peersRef.current) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
const camTrack = localStreamRef.current?.getVideoTracks().find(t => t.enabled);
await transceiver.sender.replaceTrack(camTrack || null).catch(() => {});
if (!camTrack) {
transceiver.direction = 'recvonly';
} else {
transceiver.direction = 'sendrecv';
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
}
setMaximizedUserId(null);
setIsScreenSharing(false);
// Check if we actually have an active camera track
const hasLiveCam = !!localStreamRef.current?.getVideoTracks().some(t => t.enabled);
setIsVideoOff(!hasLiveCam);
const socket = getSocket();
socket?.emit('screen_share_stopped', chatId);
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: !hasLiveCam });
}, [chatId, isMuted]);
// Toggle screen share
const toggleScreenShare = useCallback(async () => {
if (isScreenSharing) {
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with camera track on all peers
for (const [targetUserId, peer] of peersRef.current) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
const camTrack = localStreamRef.current?.getVideoTracks().find(t => t.enabled);
await transceiver.sender.replaceTrack(camTrack || null).catch(() => {});
if (!camTrack) {
transceiver.direction = 'recvonly';
} else {
transceiver.direction = 'sendrecv';
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
}
setMaximizedUserId(null);
setIsScreenSharing(false);
setIsVideoOff(!localStreamRef.current?.getVideoTracks().length);
const socket = getSocket();
socket?.emit('screen_share_stopped', chatId);
await stopScreenShareCore();
} else {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
@@ -359,7 +369,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
}
screenTrack.onended = () => {
toggleScreenShare(); // reuse functionality on OS level stop (e.g. Stop Sharing button natively)
stopScreenShareCore(); // use the helper to avoid stale state issues!
};
setMaximizedUserId('self');
setIsScreenSharing(true);
@@ -647,7 +657,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
}
};
const onScreenShareStarted = (data: { userId: string }) => {
const onScreenShareStarted = (data: { chatId: string; userId: string }) => {
if (data.chatId !== chatId) return;
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe) {
setSharingUserId(data.userId);
@@ -661,7 +672,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
});
};
const onScreenShareStopped = (data: { userId: string }) => {
const onScreenShareStopped = (data: { chatId: string; userId: string }) => {
if (data.chatId !== chatId) return;
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe && sharingUserId === data.userId) {
setSharingUserId(null);
@@ -918,9 +930,9 @@ 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-vortex-500 to-purple-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="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 ? <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-vortex-500 to-purple-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 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>
)}
<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" /> : ''}