Починенные групповые звонки
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" /> : ''}
|
||||
|
||||
Reference in New Issue
Block a user