фикс багов

This commit is contained in:
Халимов Рустам
2026-03-10 23:16:44 +03:00
parent be0c3812fc
commit 540ded7009
12 changed files with 346 additions and 148 deletions

View File

@@ -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'

View File

@@ -186,33 +186,83 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
}, [chatMessages.length, user?.id, scrollToBottom]);
// Read receipts — debounced via ref to avoid excessive emits
// Read receipts using IntersectionObserver
const sentReadIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!activeChat || !user?.id) return;
// Reset tracked IDs when switching chats
sentReadIdsRef.current.clear();
}, [activeChat, user?.id]);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!activeChat || !user?.id) return;
const unread = chatMessages.filter(
(m) => m.senderId !== user.id && !m.readBy?.some((r) => r.userId === user.id) && !sentReadIdsRef.current.has(m.id)
);
if (unread.length > 0) {
const ids = unread.map((m) => m.id);
ids.forEach((id) => sentReadIdsRef.current.add(id));
const socket = getSocket();
if (socket) {
socket.emit('read_messages', {
chatId: activeChat,
messageIds: ids,
// Cleanup previous observer
if (observerRef.current) observerRef.current.disconnect();
sentReadIdsRef.current.clear();
const socket = getSocket();
if (!socket) return;
const observer = new IntersectionObserver(
(entries) => {
const newlyReadIds: string[] = [];
entries.forEach((entry) => {
if (entry.isIntersecting) {
const msgId = entry.target.getAttribute('data-message-id');
if (msgId && !sentReadIdsRef.current.has(msgId)) {
newlyReadIds.push(msgId);
sentReadIdsRef.current.add(msgId);
// Stop observing once read
observer.unobserve(entry.target);
}
}
});
if (newlyReadIds.length > 0) {
console.log('[IntersectionObserver] Marking as read:', newlyReadIds);
socket.emit('read_messages', {
chatId: activeChat,
messageIds: newlyReadIds,
});
// Update local store immediately for current user
useChatStore.getState().markRead(activeChat, user.id, newlyReadIds);
}
},
{
root: messagesContainerRef.current,
threshold: 0.1, // Message must be 10% visible
}
// Update local store immediately for current user
useChatStore.getState().markRead(activeChat, user.id, ids);
);
observerRef.current = observer;
// Initial observation of unread messages
const observeUnread = () => {
if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observer.observe(el);
}
});
};
observeUnread();
return () => observer.disconnect();
}, [activeChat, user?.id]);
// Re-run observation when messages change (to catch new arrivals)
useEffect(() => {
if (observerRef.current && !isLoadingMessages) {
if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observerRef.current?.observe(el);
}
});
}
}, [chatMessages.length, activeChat, user?.id]);
}, [chatMessages.length, isLoadingMessages]);
// Scroll detection
const handleScroll = () => {
@@ -769,7 +819,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
return (
<div key={msg.id} id={`msg-${msg.id}`} className="transition-colors duration-500">
<div
key={msg.id}
id={`msg-${msg.id}`}
data-message-id={msg.id}
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
>
{showDate && (
<div className="flex justify-center my-4">
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">

View File

@@ -125,6 +125,12 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!));
}
// Always ensure a video m-line exists for future camera/screen share stability
const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video');
if (!hasVideoTransceiver) {
pc.addTransceiver('video', { direction: 'recvonly' });
}
pc.onicecandidate = (e) => {
if (e.candidate) {
@@ -137,15 +143,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
if (!remoteStream.getTracks().includes(e.track)) {
remoteStream.addTrack(e.track);
}
const hasVid = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
peerState.hasVideo = hasVid;
const checkHasVideo = () => {
return remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
};
peerState.hasVideo = checkHasVideo();
e.track.onunmute = () => {
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
peerState.hasVideo = checkHasVideo();
forceUpdate(n => n + 1);
};
e.track.onmute = () => {
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
peerState.hasVideo = checkHasVideo();
forceUpdate(n => n + 1);
};
@@ -246,10 +254,12 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
if (videoTrack && localStreamRef.current) {
localStreamRef.current.addTrack(videoTrack);
// Add to all peer connections
for (const [, peer] of peersRef.current) {
for (const [targetUserId, peer] of peersRef.current) {
peer.pc.addTrack(videoTrack, localStreamRef.current);
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 });
}
setIsVideoOff(false);
}
@@ -864,15 +874,13 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
<ChevronUp size={10} />
</button>
</div>
{/* Camera — 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 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}
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'}`}

View File

@@ -210,6 +210,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
clearAttachment();
} catch (e) {
console.error('Ошибка загрузки файла:', e);
alert(t('uploadError'));
} finally {
setIsSending(false);
}