diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs index 0404ce4..8774d48 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs @@ -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 MessageIds, bool DeleteForAll); diff --git a/apps/web/src/components/CallModal.tsx b/apps/web/src/components/CallModal.tsx index afcbc69..8e4534b 100644 --- a/apps/web/src/components/CallModal.tsx +++ b/apps/web/src/components/CallModal.tsx @@ -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 diff --git a/apps/web/src/components/GroupCallModal.tsx b/apps/web/src/components/GroupCallModal.tsx index e797920..233cb89 100644 --- a/apps/web/src/components/GroupCallModal.tsx +++ b/apps/web/src/components/GroupCallModal.tsx @@ -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 ?