From 36bb393ae9a5957198558f908599db1fcc801caa Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 11 Dec 2025 20:53:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96webrtc=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useVoiceChat.ts | 170 +++++++++++++++++++++++++++++++---- src/lib/watch-room-socket.ts | 15 ++-- 2 files changed, 162 insertions(+), 23 deletions(-) diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index ccb4c34..e9de303 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -42,6 +42,7 @@ export function useVoiceChat({ const audioContextRef = useRef(null); const remoteAudioElementsRef = useRef>(new Map()); const nextPlayTimeRef = useRef>(new Map()); // 跟踪每个用户的下一个播放时间 + const disconnectionTimersRef = useRef>(new Map()); // 跟踪连接断开的定时器 // 服务器中转相关 const mediaRecorderRef = useRef(null); @@ -50,8 +51,15 @@ export function useVoiceChat({ const switchToServerRelayRef = useRef<(() => void) | null>(null); const playRemoteStreamRef = useRef<((peerId: string, stream: MediaStream) => void) | null>(null); - // ICE服务器配置(使用免费的STUN服务器) + // ICE服务器配置(使用多个免费的STUN服务器作为备份) const iceServers = [ + // Cloudflare STUN 服务器(主选,全球 CDN) + { urls: 'stun:stun.cloudflare.com:3478' }, + // Numb/Viagenie(备选,老牌稳定服务) + { urls: 'stun:stun.numb.viagenie.ca:3478' }, + // Annatel(备选) + { urls: 'stun:stun.annatel.net:3478' }, + // Google STUN 服务器(最后备选) { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ]; @@ -76,7 +84,7 @@ export function useVoiceChat({ } }, []); - // 停止本地流 + // 停止本地流(完全停止并释放麦克风) const stopLocalStream = useCallback(() => { if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => track.stop()); @@ -85,6 +93,26 @@ export function useVoiceChat({ } }, []); + // 禁用本地音频轨道(静音,但不释放麦克风) + const muteLocalStream = useCallback(() => { + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => { + track.enabled = false; + }); + console.log('[VoiceChat] Muted local stream'); + } + }, []); + + // 启用本地音频轨道(取消静音) + const unmuteLocalStream = useCallback(() => { + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => { + track.enabled = true; + }); + console.log('[VoiceChat] Unmuted local stream'); + } + }, []); + // ==================== WebRTC P2P 逻辑 ==================== // 创建 RTCPeerConnection @@ -113,16 +141,98 @@ export function useVoiceChat({ } }; - // 连接状态变化 + // ICE 连接状态变化 - 更准确地反映连接质量 + pc.oniceconnectionstatechange = () => { + console.log('[VoiceChat] ICE connection state with', peerId, ':', pc.iceConnectionState); + + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') { + // 连接成功,清除断开定时器 + const timer = disconnectionTimersRef.current.get(peerId); + if (timer) { + clearTimeout(timer); + disconnectionTimersRef.current.delete(peerId); + } + setIsConnected(true); + setIsConnecting(false); + } else if (pc.iceConnectionState === 'disconnected') { + // 连接断开,但给它5秒恢复时间 + console.log('[VoiceChat] ICE disconnected for', peerId, ', waiting for recovery...'); + + // 清除之前的定时器(如果有) + const existingTimer = disconnectionTimersRef.current.get(peerId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + // 设置新的定时器 + const timer = setTimeout(() => { + console.log('[VoiceChat] ICE connection recovery timeout for', peerId); + // 5秒后仍未恢复,检查是否所有连接都断开了 + if (strategy === 'webrtc-fallback' && pc.iceConnectionState === 'disconnected') { + // 检查是否还有其他活跃的连接 + let hasActiveConnection = false; + peerConnectionsRef.current.forEach((otherPc, otherPeerId) => { + if (otherPeerId !== peerId) { + const state = otherPc.iceConnectionState; + if (state === 'connected' || state === 'completed' || state === 'checking') { + hasActiveConnection = true; + } + } + }); + + // 只有当所有连接都断开时才切换到服务器中转 + if (!hasActiveConnection) { + console.log('[VoiceChat] All ICE connections failed, switching to server relay'); + switchToServerRelayRef.current?.(); + } else { + console.log('[VoiceChat] Other connections still active, not switching to server relay'); + // 只关闭这个失败的连接 + pc.close(); + peerConnectionsRef.current.delete(peerId); + } + } + disconnectionTimersRef.current.delete(peerId); + }, 5000); // 给5秒恢复时间 + + disconnectionTimersRef.current.set(peerId, timer); + } else if (pc.iceConnectionState === 'failed') { + // ICE 连接彻底失败 + console.log('[VoiceChat] ICE connection failed for', peerId); + if (strategy === 'webrtc-fallback') { + // 检查是否还有其他活跃的连接 + let hasActiveConnection = false; + peerConnectionsRef.current.forEach((otherPc, otherPeerId) => { + if (otherPeerId !== peerId) { + const state = otherPc.iceConnectionState; + if (state === 'connected' || state === 'completed' || state === 'checking') { + hasActiveConnection = true; + } + } + }); + + if (!hasActiveConnection) { + console.log('[VoiceChat] All ICE connections failed, switching to server relay'); + switchToServerRelayRef.current?.(); + } else { + console.log('[VoiceChat] Other connections still active, not switching to server relay'); + // 只关闭这个失败的连接 + pc.close(); + peerConnectionsRef.current.delete(peerId); + } + } + } + }; + + // 连接状态变化 - 作为辅助监控 pc.onconnectionstatechange = () => { console.log('[VoiceChat] Connection state with', peerId, ':', pc.connectionState); if (pc.connectionState === 'connected') { setIsConnected(true); setIsConnecting(false); - } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { - // WebRTC连接失败,如果策略允许,切换到服务器中转 + } else if (pc.connectionState === 'failed') { + // 只在 failed 状态时切换,不在 disconnected 时切换 if (strategy === 'webrtc-fallback') { - console.log('[VoiceChat] WebRTC failed, falling back to server relay'); + console.log('[VoiceChat] Connection failed, falling back to server relay'); switchToServerRelayRef.current?.(); } } @@ -164,6 +274,12 @@ export function useVoiceChat({ // 清理WebRTC连接 const cleanupWebRTC = useCallback(() => { + // 清除所有断开定时器 + disconnectionTimersRef.current.forEach((timer) => { + clearTimeout(timer); + }); + disconnectionTimersRef.current.clear(); + // 关闭所有peer connections peerConnectionsRef.current.forEach((pc, peerId) => { pc.close(); @@ -382,7 +498,19 @@ export function useVoiceChat({ // 启动服务器中转 if (isMicEnabled && localStreamRef.current) { - startServerRelay(); + // 确保音频轨道是启用的 + const tracks = localStreamRef.current.getTracks(); + const hasEnabledTrack = tracks.some(track => track.enabled && track.readyState === 'live'); + + if (hasEnabledTrack) { + startServerRelay(); + } else { + console.error('[VoiceChat] Cannot start server relay - no enabled audio tracks'); + setError('服务器中转启动失败:麦克风未启用'); + } + } else { + console.error('[VoiceChat] Cannot start server relay - mic disabled or no stream'); + setError('服务器中转启动失败:麦克风未开启'); } }, [isMicEnabled, cleanupWebRTC, startServerRelay]); @@ -475,6 +603,19 @@ export function useVoiceChat({ if (isMicEnabled) { // 开启麦克风 + // 检查是否已经有本地流(可能只是被静音了) + if (localStreamRef.current) { + // 已有本地流,只需取消静音 + console.log('[VoiceChat] Unmuting existing local stream'); + unmuteLocalStream(); + // 重新启动服务器中转(如果需要) + if (strategy === 'server-only' || peerConnectionsRef.current.size === 0) { + startServerRelay(); + } + return; + } + + // 没有本地流,需要重新获取 setIsConnecting(true); setError(null); @@ -511,18 +652,15 @@ export function useVoiceChat({ setIsConnecting(false); }); } else { - // 关闭麦克风 - stopLocalStream(); - cleanupWebRTC(); + // 关闭麦克风 - 只静音,不断开连接 + muteLocalStream(); + // 停止服务器中转(因为不需要发送音频了) stopServerRelay(); } - return () => { - if (!isMicEnabled) { - cleanup(); - } - }; - }, [isMicEnabled, socket, roomId, strategy, members, getLocalStream, stopLocalStream, cleanupWebRTC, stopServerRelay, startServerRelay, cleanup, initiateConnection]); + // 不需要 cleanup,因为我们希望保持连接 + // cleanup 只在房间变化时调用 + }, [isMicEnabled, socket, roomId, strategy, members, getLocalStream, muteLocalStream, unmuteLocalStream, stopServerRelay, startServerRelay, initiateConnection]); // 监听喇叭状态变化 useEffect(() => { diff --git a/src/lib/watch-room-socket.ts b/src/lib/watch-room-socket.ts index 42b5aec..377dfb3 100644 --- a/src/lib/watch-room-socket.ts +++ b/src/lib/watch-room-socket.ts @@ -212,13 +212,14 @@ class WatchRoomSocketManager { // eslint-disable-next-line no-console console.warn('[WatchRoom] Heartbeat timeout detected, last response was', timeSinceLastResponse, 'ms ago'); - // 尝试重连 - if (this.socket) { - this.socket.disconnect(); - this.socket.connect(); - // 重置心跳响应时间,避免重复触发 - this.lastHeartbeatResponse = Date.now(); - } + // 不要强制断开连接,让 Socket.IO 的自动重连机制处理 + // Socket.IO 会自动检测连接问题并尝试重连 + // 只记录警告,不主动断开 + // eslint-disable-next-line no-console + console.warn('[WatchRoom] Waiting for Socket.IO auto-reconnect mechanism'); + + // 重置心跳响应时间,避免重复触发警告 + this.lastHeartbeatResponse = Date.now(); } }, 3000); }