Files
VibeCoding/WebRTCControllerWeb/src/services/WebRtcController.js
TongTongStudio 362b9f238a feat: 支持手动切换采集帧率
在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。

另附带更新信号服务器的数据库配置。
2026-07-31 03:26:40 +08:00

333 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:3478UDP/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';
}