project: 增加web和adb控制,未测试
This commit is contained in:
91
WebRTCControllerWeb/src/services/SignalingClient.js
Normal file
91
WebRTCControllerWeb/src/services/SignalingClient.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// 信令客户端:对应 Android 端 WebSocketClient / Flutter signaling_client.dart。
|
||||
// 连接成功后自动发送 REGISTER(deviceType=CONTROLLER),
|
||||
// 负责 OFFER / ICE_CANDIDATE 的发送,以及 ANSWER / ICE_CANDIDATE / 通知类的接收与转发。
|
||||
export class SignalingClient {
|
||||
constructor({ serverUrl, deviceId, onConnected, onDisconnected, onError, onMessage }) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.deviceId = deviceId;
|
||||
this.onConnected = onConnected;
|
||||
this.onDisconnected = onDisconnected;
|
||||
this.onError = onError;
|
||||
this.onMessage = onMessage;
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
this.ws = new WebSocket(this.serverUrl);
|
||||
this.ws.onopen = () => {
|
||||
this.register();
|
||||
this.onConnected && this.onConnected();
|
||||
};
|
||||
this.ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.onMessage && this.onMessage(msg);
|
||||
};
|
||||
this.ws.onclose = () => this.onDisconnected && this.onDisconnected();
|
||||
this.ws.onerror = (e) => this.onError && this.onError(e?.message || 'WebSocket 错误');
|
||||
} catch (e) {
|
||||
this.onError && this.onError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
this.send({
|
||||
type: 'REGISTER',
|
||||
fromDeviceId: this.deviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
});
|
||||
}
|
||||
|
||||
requestDeviceList() {
|
||||
this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId });
|
||||
}
|
||||
|
||||
sendOffer(sdp, toDeviceId) {
|
||||
this.send({
|
||||
type: 'OFFER',
|
||||
fromDeviceId: this.deviceId,
|
||||
toDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: JSON.stringify({ sdp }),
|
||||
});
|
||||
}
|
||||
|
||||
sendIceCandidate(candidate, toDeviceId) {
|
||||
const payload = {
|
||||
sdpMid: candidate.sdpMid,
|
||||
sdpMLineIndex: candidate.sdpMLineIndex,
|
||||
candidate: candidate.candidate,
|
||||
};
|
||||
this.send({
|
||||
type: 'ICE_CANDIDATE',
|
||||
fromDeviceId: this.deviceId,
|
||||
toDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
send(msg) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
205
WebRTCControllerWeb/src/services/WebRtcController.js
Normal file
205
WebRTCControllerWeb/src/services/WebRtcController.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { encodeControlMessage } 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 }) {
|
||||
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.pc = null;
|
||||
this.dataChannel = null;
|
||||
this._statsTimer = null;
|
||||
this._prevBytes = 0;
|
||||
this._prevTs = 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() {
|
||||
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) => {
|
||||
if (e.streams && e.streams[0]) this.onStream && this.onStream(e.streams[0]);
|
||||
};
|
||||
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();
|
||||
await this.pc.setLocalDescription(offer);
|
||||
this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId);
|
||||
|
||||
this._startStats();
|
||||
}
|
||||
|
||||
setupDataChannel(dc) {
|
||||
this.dataChannel = dc;
|
||||
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
|
||||
dc.onclose = () => this.onDataChannelState && this.onDataChannelState(false);
|
||||
dc.onmessage = (e) => console.debug('[DataChannel] 收到消息', e.data);
|
||||
}
|
||||
|
||||
async handleAnswer(sdp) {
|
||||
if (!this.pc) return;
|
||||
try {
|
||||
await this.pc.setRemoteDescription({ type: 'answer', sdp });
|
||||
} 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
|
||||
|
||||
_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 = '-', bytesReceived = 0;
|
||||
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];
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
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 = bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s'
|
||||
: bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s';
|
||||
}
|
||||
}
|
||||
this._prevBytes = bytesReceived;
|
||||
this._prevTs = 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');
|
||||
}
|
||||
return { width, height, fps, codec, downSpeed, duration, 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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user