diff --git a/chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx b/chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx index 76c4063..37e1854 100644 --- a/chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx +++ b/chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx @@ -300,6 +300,15 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec connectionCode={inputCode} onDisconnect={handleStopViewing} /> + ) : desktopShare.webRTCConnection?.transportMode === 'relay' ? ( +
+
+ +

⚠️ 当前为中继模式

+

中继模式(WS 转发)不支持桌面视频流传输,桌面共享需要 P2P 直连。

+

请检查网络环境或尝试重新连接。

+
+
) : (
diff --git a/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx b/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx index bd6b354..038f654 100644 --- a/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx +++ b/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx @@ -105,11 +105,12 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W } }, [desktopShare, showToast]); - // P2P连接建立后自动弹出桌面选择 + // P2P连接建立后自动弹出桌面选择(仅 P2P 模式) useEffect(() => { if ( desktopShare.isPeerConnected && desktopShare.canStartSharing && + desktopShare.transportMode !== 'relay' && !desktopShare.isSharing && !isLoading && !hasAutoStartedRef.current @@ -118,7 +119,7 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W console.log('[DesktopShareSender] P2P连接已建立,自动弹出桌面选择'); handleStartSharing(); } - }, [desktopShare.isPeerConnected, desktopShare.canStartSharing, desktopShare.isSharing, isLoading, handleStartSharing]); + }, [desktopShare.isPeerConnected, desktopShare.canStartSharing, desktopShare.transportMode, desktopShare.isSharing, isLoading, handleStartSharing]); // 切换桌面 const handleSwitchDesktop = useCallback(async () => { @@ -235,6 +236,18 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W />
+ {/* 中继模式提示(在控制区域外面,确保可见) */} + {desktopShare.transportMode === 'relay' && ( +
+
+ +

⚠️ 当前为中继模式

+

中继模式(WS 转发)不支持桌面视频流传输,桌面共享需要 P2P 直连。

+

请检查网络环境或尝试重新连接。

+
+
+ )} + {/* 桌面共享控制区域 */} {desktopShare.canStartSharing && (
@@ -255,7 +268,7 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W {isLoading ? '启动中...' : '选择并开始共享桌面'} - {!desktopShare.isPeerConnected && ( + {!desktopShare.isPeerConnected && desktopShare.transportMode !== 'relay' && (

等待接收方加入房间建立P2P连接... diff --git a/chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts b/chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts index a47046b..8b7a138 100644 --- a/chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts +++ b/chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts @@ -58,6 +58,8 @@ export function useWebRTCConnectionCore( const relayRequestSent = useRef(false); // 通过 ref 避免闭包捕获过时的 initiateRelayFallback const initiateRelayFallbackRef = useRef<() => void>(() => {}); + // 全局保底超时:peer-joined 后 N 秒内 isPeerConnected 仍为 false 则降级 + const peerConnectedTimeout = useRef(null); // 清理连接 const cleanup = useCallback((shouldNotifyDisconnect: boolean = false) => { @@ -78,6 +80,14 @@ export function useWebRTCConnectionCore( iceDisconnectedTimeout.current = null; } + if (peerConnectedTimeout.current) { + clearTimeout(peerConnectedTimeout.current); + peerConnectedTimeout.current = null; + } + + // 先清除降级回调,防止后续 PC.close() 触发旧 DataChannel 的 onclose 误触发降级 + dataChannelManager.setFallbackCallback(null); + if (pcRef.current) { pcRef.current.close(); pcRef.current = null; @@ -257,6 +267,7 @@ export function useWebRTCConnectionCore( relayWsRef.current = null; } isRelayFallbackInProgress.current = false; + relayRequestSent.current = false; // 重置以允许后续重试 // 如果不是用户主动断开,且当前是中继模式 if (!isUserDisconnecting.current && stateManager.getState().transportMode === 'relay') { @@ -436,6 +447,12 @@ export function useWebRTCConnectionCore( clearTimeout(iceDisconnectedTimeout.current); iceDisconnectedTimeout.current = null; } + if (peerConnectedTimeout.current) { + clearTimeout(peerConnectedTimeout.current); + peerConnectedTimeout.current = null; + } + // P2P 已建立,清除降级回调(降级仅在建连阶段生效) + dataChannelManager.setFallbackCallback(null); // 确保所有连接状态都正确更新 stateManager.updateState({ isWebSocketConnected: true, @@ -488,6 +505,12 @@ export function useWebRTCConnectionCore( // 创建数据通道 dataChannelManager.createDataChannel(pc, role, isReconnect); + // 注册降级回调,DataChannel 错误/关闭时自动触发中继降级 + dataChannelManager.setFallbackCallback(() => { + console.log('[ConnectionCore] 📡 收到 DataChannel 降级回调,启动中继降级'); + initiateRelayFallbackRef.current(); + }); + console.log('[ConnectionCore] ✅ PeerConnection创建完成,角色:', role, '是否重新连接:', isReconnect); return pc; }, [stateManager, dataChannelManager]); @@ -615,6 +638,18 @@ export function useWebRTCConnectionCore( console.log('[ConnectionCore] ✅ 接收方PeerConnection已准备就绪'); }, 100); } + + // 全局保底超时:peer-joined 后 15 秒内如果 isPeerConnected 仍为 false,自动降级 + if (peerConnectedTimeout.current) { + clearTimeout(peerConnectedTimeout.current); + } + peerConnectedTimeout.current = setTimeout(() => { + const currentState = stateManager.getState(); + if (!currentState.isPeerConnected && !isUserDisconnecting.current) { + console.log('[ConnectionCore] ⏰ peer-joined 后 15 秒仍未建立对等连接,启动中继降级'); + initiateRelayFallbackRef.current(); + } + }, 15000); break; case 'offer': @@ -810,8 +845,8 @@ export function useWebRTCConnectionCore( // 设置主动断开标志 isUserDisconnecting.current = true; - - // 清理连接并发送断开通知 + + // 清理连接并发送断开通知(cleanup 内部已会清除 fallback 回调) cleanup(shouldNotifyDisconnect); // 主动断开时,将状态完全重置为初始状态(没有任何错误或消息) diff --git a/chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts b/chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts index 573cda5..961be88 100644 --- a/chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts +++ b/chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts @@ -43,6 +43,11 @@ export interface WebRTCDataChannelManager { getBufferedAmount: () => number; /** 等待缓冲区排空到指定阈值以下 */ waitForBufferDrain: (threshold?: number) => Promise; + + // ── 降级回调 ── + + /** 注册降级回调:DataChannel 错误/关闭时通知 ConnectionCore 触发中继降级 */ + setFallbackCallback: (cb: (() => void) | null) => void; } /** @@ -60,6 +65,13 @@ export function useWebRTCDataChannelManager( const dcRef = useRef(null); const relayWsRef = useRef(null); + // 降级回调:由 ConnectionCore 注册 + const fallbackCallbackRef = useRef<(() => void) | null>(null); + + const setFallbackCallback = useCallback((cb: (() => void) | null) => { + fallbackCallbackRef.current = cb; + }, []); + // 处理器注册表 const messageHandlers = useRef>(new Map()); const dataHandlers = useRef>(new Map()); @@ -219,6 +231,17 @@ export function useWebRTCDataChannelManager( isPeerConnected: false, canRetry: shouldRetry, }); + // 通知 ConnectionCore 触发中继降级 + if (shouldRetry) { + if (fallbackCallbackRef.current) { + console.log('[DataChannel] 📡 DataChannel 错误,触发中继降级回调'); + fallbackCallbackRef.current(); + } else { + console.warn('[DataChannel] ⚠️ DataChannel 错误且需要降级,但降级回调未注册'); + } + } + } else { + console.log('[DataChannel] ⓘ️ 已在中继模式,跳过错误处理'); } }, [stateManager, isRelayMode]); @@ -234,7 +257,16 @@ export function useWebRTCDataChannelManager( dataChannel.onopen = () => handleChannelOpen(dataChannel, role, isReconnect); dataChannel.onmessage = dispatchIncoming; dataChannel.onerror = () => handleChannelError(dataChannel, pc, role); - }, [handleChannelOpen, dispatchIncoming, handleChannelError]); + dataChannel.onclose = () => { + console.log(`[DataChannel] 数据通道已关闭 (${role})`); + // 仅当之前已建立连接(isPeerConnected 曾为 true)且不在中继模式时才触发降级 + // 这可以避免 cleanup 期间或初始化期间的误触发 + if (!isRelayMode() && fallbackCallbackRef.current) { + console.log('[DataChannel] 📡 DataChannel 意外关闭,触发中继降级回调'); + fallbackCallbackRef.current(); + } + }; + }, [handleChannelOpen, dispatchIncoming, handleChannelError, isRelayMode]); // ── 创建数据通道 ── @@ -346,19 +378,15 @@ export function useWebRTCDataChannelManager( // ──────────────────────────────────────── const registerMessageHandler = useCallback((channel: string, handler: MessageHandler): Unsubscribe => { - console.log('[DataChannel] 注册消息处理器:', channel); messageHandlers.current.set(channel, handler); return () => { - console.log('[DataChannel] 取消注册消息处理器:', channel); messageHandlers.current.delete(channel); }; }, []); const registerDataHandler = useCallback((channel: string, handler: DataHandler): Unsubscribe => { - console.log('[DataChannel] 注册数据处理器:', channel); dataHandlers.current.set(channel, handler); return () => { - console.log('[DataChannel] 取消注册数据处理器:', channel); dataHandlers.current.delete(channel); }; }, []); @@ -426,5 +454,6 @@ export function useWebRTCDataChannelManager( getChannelState, getBufferedAmount, waitForBufferDrain, + setFallbackCallback, }; } diff --git a/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts b/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts index 2f1ae7d..5e72186 100644 --- a/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts +++ b/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts @@ -45,9 +45,9 @@ export function useDesktopShareBusiness(externalConnection?: WebRTCConnection) { }, [updateState]); // 设置远程轨道处理器(始终监听,支持与语音通话并存) + const registerTrackHandler = webRTC.registerTrackHandler; useEffect(() => { - console.log('[DesktopShare] 🎧 注册远程轨道处理器'); - const unsubscribe = webRTC.registerTrackHandler('desktop-share', (event: RTCTrackEvent) => { + const unsubscribe = registerTrackHandler('desktop-share', (event: RTCTrackEvent) => { // 只处理视频轨道,音频由 VoiceChat 处理 if (event.track.kind !== 'video') return; @@ -79,7 +79,7 @@ export function useDesktopShareBusiness(externalConnection?: WebRTCConnection) { } }); return unsubscribe; - }, [webRTC, handleRemoteStream]); + }, [registerTrackHandler, handleRemoteStream]); // 获取桌面共享流 const getDesktopStream = useCallback(async (): Promise => { @@ -268,6 +268,11 @@ export function useDesktopShareBusiness(externalConnection?: WebRTCConnection) { // 开始桌面共享(在接收方加入后) const startSharing = useCallback(async (): Promise => { try { + // 中继模式不支持媒体流传输 + if (webRTC.transportMode === 'relay') { + throw new Error('当前为中继模式,不支持桌面共享。桌面共享需要 P2P 直连,请检查网络环境后重试'); + } + // 检查P2P连接状态(与switchDesktop保持一致) if (!webRTC.isPeerConnected) { throw new Error('P2P连接未建立'); @@ -316,6 +321,10 @@ export function useDesktopShareBusiness(externalConnection?: WebRTCConnection) { // 切换桌面共享(重新选择屏幕) const switchDesktop = useCallback(async (): Promise => { try { + if (webRTC.transportMode === 'relay') { + throw new Error('当前为中继模式,不支持桌面共享'); + } + if (!webRTC.isPeerConnected) { throw new Error('P2P连接未建立'); } @@ -522,8 +531,10 @@ export function useDesktopShareBusiness(externalConnection?: WebRTCConnection) { isConnecting: webRTC.isConnecting, isWebSocketConnected: webRTC.isWebSocketConnected, isPeerConnected: webRTC.isPeerConnected, - // 新增:表示是否可以开始共享(WebSocket已连接且有房间代码) - canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode, + // 新增:表示是否可以开始共享(WebSocket已连接且有房间代码且非中继模式) + canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode && webRTC.transportMode !== 'relay', + // 传输模式 + transportMode: webRTC.transportMode, // 方法 createRoom, // 创建房间 diff --git a/chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts b/chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts index 59d1b0b..cc95ff6 100644 --- a/chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts +++ b/chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts @@ -107,7 +107,7 @@ export function useVoiceChatBusiness(connection: WebRTCConnection) { currentTrackRef.current.onunmute = null; } }; - }, [connection, handleRemoteAudioTrack]); + }, [connection.registerTrackHandler, handleRemoteAudioTrack]); // 获取本地音频流 const getLocalAudioStream = useCallback(async (): Promise => { diff --git a/chuan-next/src/hooks/text-transfer/useChatBusiness.ts b/chuan-next/src/hooks/text-transfer/useChatBusiness.ts index 983ed75..005e175 100644 --- a/chuan-next/src/hooks/text-transfer/useChatBusiness.ts +++ b/chuan-next/src/hooks/text-transfer/useChatBusiness.ts @@ -163,9 +163,10 @@ export function useChatBusiness(connection: WebRTCConnection) { }, []); // 注册消息处理器 + const registerMessageHandler = connection.registerMessageHandler; useEffect(() => { - return connection.registerMessageHandler(CHANNEL_NAME, handleMessage); - }, [connection, handleMessage]); + return registerMessageHandler(CHANNEL_NAME, handleMessage); + }, [registerMessageHandler, handleMessage]); // ── 发送文本消息 ── diff --git a/chuan-next/src/hooks/text-transfer/useTextTransferBusiness.ts b/chuan-next/src/hooks/text-transfer/useTextTransferBusiness.ts index 11546c1..f131c4c 100644 --- a/chuan-next/src/hooks/text-transfer/useTextTransferBusiness.ts +++ b/chuan-next/src/hooks/text-transfer/useTextTransferBusiness.ts @@ -49,9 +49,10 @@ export function useTextTransferBusiness(connection: WebRTCConnection) { }, []); // 注册消息处理器 + const registerMessageHandler = connection.registerMessageHandler; useEffect(() => { - return connection.registerMessageHandler(CHANNEL_NAME, handleMessage); - }, [connection, handleMessage]); + return registerMessageHandler(CHANNEL_NAME, handleMessage); + }, [registerMessageHandler, handleMessage]); // 连接管理(透传) const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => { diff --git a/chuan-next/src/hooks/ui/useURLHandler.ts b/chuan-next/src/hooks/ui/useURLHandler.ts index 33945e3..5573953 100644 --- a/chuan-next/src/hooks/ui/useURLHandler.ts +++ b/chuan-next/src/hooks/ui/useURLHandler.ts @@ -121,7 +121,6 @@ export const useURLHandler = ({ useEffect(() => { // 使用 ref 确保只处理一次,避免严格模式的重复调用 if (urlProcessedRef.current) { - console.log('URL已处理过,跳过重复处理'); return; }