project: 增加web和adb控制,未测试

This commit is contained in:
2026-07-17 19:17:10 +08:00
parent 5fb7b58fc3
commit b8edd5eff5
49 changed files with 5061 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
// 信令客户端:对应 Android 端 WebSocketClient / Flutter signaling_client.dart。
// 连接成功后自动发送 REGISTERdeviceType=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;
}
}

View 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:3478UDP/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 = [];
}
}