import { encodeControlMessage, decodeControlMessage, Action } from '../proto/controlMessage'; // 对应 Android 端 WebRtcClient / Flutter webrtc_controller.dart。 // 作为 OFFER 方:仅接收远端视频(recvonly)+ 创建控制用 DataChannel, // 并将屏幕触摸/按键转换为 protobuf 控制指令经 DataChannel 发送给被控端。 const DATA_CHANNEL_LABEL = 'control_channel'; export class WebRtcController { constructor({ iceServers, deviceId, targetDeviceId, signaling, onIceState, onDataChannelState, onStream, onStats, onError, onResolutionReport }) { this.iceServers = iceServers; this.deviceId = deviceId; this.targetDeviceId = targetDeviceId; this.signaling = signaling; this.onIceState = onIceState; this.onDataChannelState = onDataChannelState; this.onStream = onStream; this.onStats = onStats; this.onError = onError; this.onResolutionReport = onResolutionReport; this.pc = null; this.dataChannel = null; this._statsTimer = null; this._prevBytes = 0; this._prevTs = 0; this._prevSentBytes = 0; this._prevSentTs = 0; this._connectedAt = 0; this._gatheredRelay = false; this._gatheredSrflx = false; this._gatheredHost = false; // 关键修复:浏览器要求 addIceCandidate 必须在 setRemoteDescription(answer) 之后调用。 // 被控端会在一瞬间突发大量候选,若此时远端描述尚未设置则会整批失败被丢弃,导致 ICE 无法配对。 // 因此先把远端候选缓存起来,等 answer 设置完成后再统一 flush。 this._remoteDescSet = false; this._pendingCandidates = []; } async createOffer(authType = null, authValue = null) { this.pc = new RTCPeerConnection({ iceServers: this.iceServers }); this.pc.onicecandidate = (e) => { if (e.candidate) { if (e.candidate.candidate.includes('typ relay')) this._gatheredRelay = true; else if (e.candidate.candidate.includes('typ srflx')) this._gatheredSrflx = true; else if (e.candidate.candidate.includes('typ host')) this._gatheredHost = true; this.signaling.sendIceCandidate(e.candidate, this.targetDeviceId); } else { console.info('[ICE] 本端候选收集完成 relay=%s srflx=%s host=%s', this._gatheredRelay, this._gatheredSrflx, this._gatheredHost); } }; // 关键诊断:TURN/STUN 分配失败时浏览器会触发该事件,原代码未捕获导致原因被吞掉。 this.pc.onicecandidateerror = (e) => { const url = e.url || ''; console.warn('[ICE 候选错误] url=%s code=%s text=%s', url, e.errorCode, e.errorText); }; this.pc.oniceconnectionstatechange = () => { const s = this.pc.iceConnectionState; if (s === 'connected') this._connectedAt = Date.now(); this.onIceState && this.onIceState(s); }; this.pc.onconnectionstatechange = () => { const s = this.pc.connectionState; if (s === 'failed') { // ICE 彻底失败:通常是双方没有可达的候选路径(需 TURN 中继或同一网络)。 const relayInfo = this._gatheredRelay ? '本端已拿到 TURN 中继候选' : '本端未拿到任何 TURN 中继候选(中继分配很可能失败,见上方 TURN 错误)'; this.onError && this.onError('WebRTC 连接失败(connectionState=failed)。iceConnectionState=' + this.pc.iceConnectionState + ';' + relayInfo + '。请确认浏览器能访问 TURN 服务器 175.178.213.60:3478(UDP/TCP 至少一种可达),且被控端与本机网络可互通。'); } else if (s === 'connecting') { this.onError && this.onError(''); } this.onIceState && this.onIceState(s); }; this.pc.ontrack = (e) => { // 某些浏览器/协商场景下 event.streams 可能为空,需回退到用 track 自行构造 MediaStream, // 否则 store.remoteStream 始终为空 -> 视频元素拿不到流 -> 黑屏(用户会误以为无法控制)。 let stream = e.streams && e.streams[0]; if (!stream) { stream = new MediaStream([e.track]); } this.onStream && this.onStream(stream); e.track.onunmute = () => { if (e.track.readyState === 'live') { this.onStream && this.onStream(stream); } }; }; this.pc.ondatachannel = (e) => this.setupDataChannel(e.channel); // 仅接收被控端屏幕视频 this.pc.addTransceiver('video', { direction: 'recvonly' }); // 控制用 DataChannel:非可靠、无序,降低延迟(与被控端协商一致) const dc = this.pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: false, maxRetransmits: 0 }); this.setupDataChannel(dc); const offer = await this.pc.createOffer(); // 与 WebRTCControlled 的 optimizeSdp 保持一致:将 m=video 行的编解码器 // 重排为 VP8/VP9 优先、H264 兜底,确保各控制端(浏览器/桌面/Chromium/Linux) // 都能稳定解码,避免 H264 High Profile 无法解码而黑屏。 offer.sdp = this.preferVideoCodecs(offer.sdp); await this.pc.setLocalDescription(offer); this.signaling.sendOffer(offer.sdp, this.targetDeviceId, authType, authValue); this._startStats(); } /** * 重排 Offer SDP 中 m=video 行的视频编解码器顺序:VP8/VP9 优先,H264 兜底。 * 与 Android 被控端 WebRtcClient.optimizeSdp 的编解码优先级保持一致, * 保证协商出的编码格式控制端一定可解码(编码/解码一致性)。 * @param {string} sdp 原始 SDP * @returns {string} 重排后的 SDP */ preferVideoCodecs(sdp) { const lines = sdp.split('\r\n'); const result = []; // 先收集各类型视频负载的 payload type const vp8 = []; const vp9 = []; const h264 = []; for (const line of lines) { const t = line.trim(); if (t.startsWith('a=rtpmap:')) { const payload = t.split(':')[1].split(' ')[0]; if (t.includes('VP8/90000')) vp8.push(payload); else if (t.includes('VP9/90000')) vp9.push(payload); else if (t.includes('H264/90000')) h264.push(payload); } } const ordered = [...vp8, ...vp9, ...h264]; if (ordered.length === 0) return sdp; for (const line of lines) { const t = line.trim(); if (t.startsWith('m=video')) { const parts = t.split(' '); if (parts.length > 3) { const head = [parts[0], parts[1], parts[2]].join(' '); const rest = parts.slice(3).filter((p) => !ordered.includes(p)); result.push([head, ...ordered, ...rest].join(' ')); continue; } } result.push(line); } return result.join('\r\n'); } setupDataChannel(dc) { this.dataChannel = dc; dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true); dc.onclose = () => this.onDataChannelState && this.onDataChannelState(false); dc.onmessage = (e) => { try { // 被控端发来的是二进制 protobuf(目前仅 REPORT_RESOLUTION 上报)。 const bytes = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data; const msg = decodeControlMessage(bytes); if (msg && msg.action === Action.REPORT_RESOLUTION) { this.onResolutionReport && this.onResolutionReport({ width: msg.width | 0, height: msg.height | 0, fps: msg.fps | 0, // 被控端按屏幕刷新率筛选出的帧率档位(供帧率下拉框使用) supportedFps: Array.isArray(msg.supportedFps) ? msg.supportedFps.map((f) => f | 0) : [], }); } } catch (err) { console.debug('[DataChannel] 解码消息失败(非分辨率上报或格式异常)', err); } }; } async handleAnswer(sdp) { if (!this.pc) return; try { await this.pc.setRemoteDescription({ type: 'answer', sdp }); // 诊断:确认视频收发方向,便于区分“编解码器问题”与“连接方向问题” const vt = this.pc.getTransceivers ? this.pc.getTransceivers().find((t) => t.receiver && t.receiver.track && t.receiver.track.kind === 'video') : null; console.info('[协商完成] video transceiver currentDirection=', vt ? vt.currentDirection : 'n/a'); } catch (e) { this.onError && this.onError('设置远端描述(ANSWER)失败: ' + (e?.message || e)); throw e; } // 远端描述已就绪,flush 之前缓存的候选。 this._remoteDescSet = true; const pending = this._pendingCandidates; this._pendingCandidates = []; for (const c of pending) { try { await this.pc.addIceCandidate(c); } catch (e) { console.warn('flush addIceCandidate 失败', e); } } } async handleIceCandidate(payload) { if (!this.pc) return; const cand = { candidate: payload.candidate, sdpMid: payload.sdpMid, sdpMLineIndex: payload.sdpMLineIndex }; if (!this._remoteDescSet) { // 远端描述尚未设置,先缓存,避免整批候选被浏览器丢弃。 this._pendingCandidates.push(cand); return; } try { await this.pc.addIceCandidate(cand); } catch (e) { console.warn('addIceCandidate 失败', e); } } sendControlMessage(fields) { if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false; const bytes = encodeControlMessage(fields); this.dataChannel.send(bytes); return true; } sendTouch(x, y) { return this.sendControlMessage({ action: 1, x, y }); } // TOUCH sendSwipe(x1, y1, x2, y2, duration) { return this.sendControlMessage({ action: 2, x1, y1, x2, y2, duration }); } // SWIPE sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY sendLongPress(x, y) { return this.sendControlMessage({ action: 4, x, y }); } // LONG_PRESS sendMotionEvent(action, x, y) { return this.sendControlMessage({ action: 5, motionAction: action, x, y }); } // MOTION_EVENT sendResolutionChange(width, height, fps) { return this.sendControlMessage({ action: 6, width, height, fps }); } // SET_RESOLUTION _startStats() { this._statsTimer = setInterval(async () => { if (!this.pc) return; try { this.onStats && this.onStats(await this.collectStats()); } catch { /* ignore */ } }, 1000); } async collectStats() { const stats = await this.pc.getStats(); let width = '-', height = '-', fps = '-', codec = '-'; let bytesReceived = 0, bytesSent = 0; let jitter = 0, framesDropped = 0, packetsLost = 0, packetsReceived = 0; let framesDecoded = 0, totalDecodeTime = 0, rtt = null; const codecs = {}; stats.forEach((r) => { if (r.type === 'inbound-rtp' && r.kind === 'video') { width = r.frameWidth ?? '-'; height = r.frameHeight ?? '-'; fps = r.framesPerSecond ?? '-'; if (r.codecId && codecs[r.codecId]) codec = codecs[r.codecId]; jitter = r.jitter ?? 0; // 接收抖动(秒) framesDropped = r.framesDropped ?? 0; // 累计丢帧数 packetsLost = r.packetsLost ?? 0; // 累计丢包数 packetsReceived = r.packetsReceived ?? 0; // 累计接收包数 framesDecoded = r.framesDecoded ?? 0; // 累计解码帧数 totalDecodeTime = r.totalDecodeTime ?? 0; // 累计解码耗时(秒) } else if (r.type === 'codec' && r.mimeType && r.mimeType.startsWith('video/')) { codecs[r.id] = r.mimeType.substring(6); } else if (r.type === 'candidate-pair' && r.nominated) { bytesReceived = r.bytesReceived ?? 0; bytesSent = r.bytesSent ?? 0; if (typeof r.currentRoundTripTime === 'number') rtt = r.currentRoundTripTime; // 往返时延(秒) } }); const now = Date.now(); // 下载速率 let downSpeed = '-'; if (this._prevTs) { const dt = (now - this._prevTs) / 1000; if (dt > 0) { const bps = (bytesReceived - this._prevBytes) / dt; downSpeed = formatSpeed(bps); } } this._prevBytes = bytesReceived; this._prevTs = now; // 上传速率 let upSpeed = '-'; if (this._prevSentTs) { const dt = (now - this._prevSentTs) / 1000; if (dt > 0) { const bps = (bytesSent - this._prevSentBytes) / dt; upSpeed = formatSpeed(bps); } } this._prevSentBytes = bytesSent; this._prevSentTs = now; let duration = '-'; if (this._connectedAt) { const secs = Math.floor((now - this._connectedAt) / 1000); duration = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0'); } // 延迟(往返时延) const latency = rtt != null ? (rtt * 1000).toFixed(0) + ' ms' : '-'; // 抖动 const jitterMs = jitter ? (jitter * 1000).toFixed(1) + ' ms' : '-'; // 解码耗时(平均每帧解码时间) const decodeMs = (framesDecoded > 0 && totalDecodeTime > 0) ? (totalDecodeTime / framesDecoded * 1000).toFixed(1) + ' ms' : '-'; // 丢包率 const totalPackets = packetsLost + packetsReceived; const lossRate = totalPackets > 0 ? (packetsLost / totalPackets * 100).toFixed(1) + '%' : '-'; return { width, height, fps, codec, downSpeed, upSpeed, duration, latency, jitter: jitterMs, framesDropped, decodeMs, packetLoss: lossRate, dcState: this.dataChannel ? this.dataChannel.readyState : 'none', }; } async close() { if (this._statsTimer) clearInterval(this._statsTimer); if (this.dataChannel) { try { this.dataChannel.close(); } catch { /* ignore */ } } if (this.pc) { try { await this.pc.close(); } catch { /* ignore */ } } this.dataChannel = null; this.pc = null; this._remoteDescSet = false; this._pendingCandidates = []; } } // 将字节/秒格式化为可读速率 function formatSpeed(bps) { if (!bps || bps <= 0) return '0 B/s'; return bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s' : bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s'; }