фикс багов
This commit is contained in:
@@ -220,20 +220,26 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
remoteStreamRef.current = stream;
|
||||
|
||||
// Check if remote has video tracks
|
||||
// Include !t.muted so we detect when the remote stops sending
|
||||
// (replaceTrack(null) or direction change causes muted=true)
|
||||
const checkVideo = () => {
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
// hasRemoteVideo stays true if we have ANY live video track.
|
||||
const hasVideo = videoTracks.length > 0 && videoTracks.some(
|
||||
t => t.readyState === 'live' && t.enabled && !t.muted
|
||||
);
|
||||
|
||||
console.log('[checkVideo] videoTracks:', videoTracks.map(t =>
|
||||
`${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}`
|
||||
), '→ hasRemoteVideo:', hasVideo);
|
||||
|
||||
setHasRemoteVideo(hasVideo);
|
||||
// Fallback: if we detect a live video track but signalling hasn't updated us, switch to video mode
|
||||
if (hasVideo && callType !== 'video') {
|
||||
console.log('[checkVideo] Auto-switching to video mode');
|
||||
setCallType('video');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for unmute/mute on every video track (muted→unmuted when data starts flowing)
|
||||
// Listen for unmute/mute/ended on video tracks
|
||||
const attachTrackListeners = (track: MediaStreamTrack) => {
|
||||
if (track.kind !== 'video') return;
|
||||
track.onunmute = checkVideo;
|
||||
@@ -245,6 +251,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
checkVideo();
|
||||
|
||||
if (remoteVideoRef.current) {
|
||||
// Force re-assignment to ensure browser picks up new track
|
||||
remoteVideoRef.current.srcObject = null;
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
if (remoteAudioRef.current) {
|
||||
@@ -336,9 +344,11 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
// Add all tracks from the stream
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
// If this is a video call but we only have audio, add a recvonly video transceiver
|
||||
// so we can receive video from the other side and potentially add video later
|
||||
if (effectiveCallType === 'video' && stream.getVideoTracks().length === 0) {
|
||||
// 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' });
|
||||
}
|
||||
|
||||
@@ -429,17 +439,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
pc.addTrack(track, stream);
|
||||
});
|
||||
|
||||
// If this is a video call but we only have audio, we need a recvonly video transceiver.
|
||||
// After setRemoteDescription, one already exists from the offer — but only if the offer
|
||||
// contained a video m-line. If it did, the transceiver is already recvonly.
|
||||
// If there's no video transceiver at all and we want to receive video, add one.
|
||||
if (incoming.callType === 'video' && !hasVideo) {
|
||||
const hasVideoTransceiver = pc.getTransceivers().some(
|
||||
t => t.receiver?.track?.kind === 'video'
|
||||
);
|
||||
if (!hasVideoTransceiver) {
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
}
|
||||
// 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:',
|
||||
@@ -888,6 +894,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
}
|
||||
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);
|
||||
@@ -937,6 +945,8 @@ 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' });
|
||||
} catch (err) {
|
||||
console.error('Could not start camera:', err);
|
||||
}
|
||||
@@ -1038,6 +1048,8 @@ 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' });
|
||||
}
|
||||
|
||||
setIsScreenSharing(false);
|
||||
@@ -1165,11 +1177,15 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1287,6 +1303,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
socket.on('call_answered', onCallAnswered);
|
||||
socket.on('ice_candidate', onIceCandidate);
|
||||
socket.on('call_ended', onCallEnded);
|
||||
@@ -1294,6 +1316,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
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);
|
||||
@@ -1303,6 +1326,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
socket.off('call_unavailable', onCallUnavailable);
|
||||
socket.off('renegotiate', onRenegotiate);
|
||||
socket.off('renegotiate_answer', onRenegotiateAnswer);
|
||||
socket.off('call_type_changed', onCallTypeChanged);
|
||||
};
|
||||
}, [cleanup, scheduleClose]);
|
||||
|
||||
@@ -1332,11 +1356,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
|
||||
// Sync remote video ref with remote stream (only when srcObject actually changes)
|
||||
useEffect(() => {
|
||||
if (!remoteVideoRef.current) return;
|
||||
if (remoteStreamRef.current && remoteVideoRef.current.srcObject !== remoteStreamRef.current) {
|
||||
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(() => {
|
||||
@@ -1523,24 +1549,25 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
className="relative bg-black w-full"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
{/* Remote video — fills container, preserves natural aspect ratio */}
|
||||
{hasRemoteVideo ? (
|
||||
<div className="absolute inset-0 bg-black flex items-center justify-center">
|
||||
{/* Unified Remote video — always rendered if area is shown to keep stream alive & prevent ref-swapping issues */}
|
||||
<video
|
||||
ref={remoteVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-contain bg-black"
|
||||
className={`absolute inset-0 w-full h-full object-contain bg-black transition-opacity duration-300 ${hasRemoteVideo ? 'opacity-100' : 'opacity-0'}`}
|
||||
onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<VideoOff size={48} className="text-zinc-500 mb-2" />
|
||||
<span className="text-sm text-zinc-500">{displayName}</span>
|
||||
{/* Hidden video to keep stream alive */}
|
||||
<video ref={remoteVideoRef} autoPlay playsInline muted className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder shown when video track is muted or not yet arrived */}
|
||||
{!hasRemoteVideo && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-sm z-10">
|
||||
<VideoOff size={48} className="text-zinc-500 mb-2" />
|
||||
<span className="text-sm text-zinc-500">{displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local video PIP (bottom-right) */}
|
||||
{hasLocalVideo && (
|
||||
@@ -1691,26 +1718,22 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
<ChevronUp size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Camera toggle — only for video calls */}
|
||||
{initialCallType === 'video' && (
|
||||
<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 — only for video calls */}
|
||||
{initialCallType === '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>
|
||||
)}
|
||||
{/* 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'
|
||||
|
||||
Reference in New Issue
Block a user