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,42 @@
<script setup>
import { onMounted } from 'vue';
import { store, initProto } from './store/controllerStore';
import ConnectionPanel from './components/ConnectionPanel.vue';
import RemoteScreen from './components/RemoteScreen.vue';
import ControlBar from './components/ControlBar.vue';
import StatsBar from './components/StatsBar.vue';
onMounted(async () => {
if (!store.deviceId) store.deviceId = 'web-' + Math.random().toString(36).slice(2, 8);
await initProto();
});
</script>
<template>
<div class="app">
<header class="topbar">
<div class="brand">WebRTC 网页远程控制</div>
<div class="status" :class="{ ok: store.rtcConnected }">{{ store.statusText }}</div>
<div class="badges">
<span class="badge" :class="{ on: store.signalingConnected }">信令</span>
<span class="badge" :class="{ on: store.registered }">注册</span>
<span class="badge" :class="{ on: store.rtcConnected }">P2P</span>
<span class="badge" :class="{ on: store.dataChannelOpen }">通道</span>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<ConnectionPanel />
</aside>
<section class="stage">
<RemoteScreen />
<ControlBar />
</section>
</main>
<footer class="footer">
<StatsBar />
</footer>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref } from 'vue';
import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore';
const selected = ref('');
function onConnectDevice() {
connectToDevice(selected.value);
}
</script>
<template>
<div class="conn-panel">
<div class="field">
<label>信令服务器地址 (WebSocket)</label>
<input class="input" v-model="store.serverUrl" placeholder="ws://host:port/ws/signal" />
</div>
<div class="field">
<label>本机设备 ID (CONTROLLER)</label>
<input class="input" v-model="store.deviceId" placeholder="例如 web-controller-001" />
</div>
<button class="btn" v-if="!store.signalingConnected" @click="connectSignaling">连接信令服务器</button>
<button class="btn danger" v-else @click="disconnectSignaling">断开信令</button>
<div class="section-title">被控端设备 (CONTROLLED)</div>
<button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="!store.signalingConnected">
刷新设备列表
</button>
<div v-if="store.controlledDevices.length === 0" class="hint">
暂无在线被控端请确认 Android 被控端 (WebRTCControlled) 已启动并连接到同一信令服务器
</div>
<div
v-for="id in store.controlledDevices"
:key="id"
class="device-item"
:class="{ active: store.targetDeviceId === id }"
@click="selected = id"
>
<span>{{ id }}</span>
<span style="color:var(--accent-2)">在线</span>
</div>
<div class="field" style="margin-top:14px">
<select class="select" v-model="selected">
<option value="">选择目标设备</option>
<option v-for="id in store.controlledDevices" :key="id" :value="id">{{ id }}</option>
</select>
</div>
<button class="btn" @click="onConnectDevice" :disabled="!selected || store.rtcConnected">发起远程控制</button>
<button class="btn danger" v-if="store.rtcConnected" @click="disconnectDevice">结束控制</button>
<div v-if="store.error" class="error">{{ store.error }}</div>
<div class="section-title">说明</div>
<div class="hint">
本页面作为 <b>控制端 (CONTROLLER)</b>对应原 Android / Flutter 控制端<br />
连接后可在右侧屏幕区域触摸/滑动来操控被控设备<br />
控制指令以 protobuf DataChannel 发送与原 Android 被控端完全兼容
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { store, sendKey } from '../store/controllerStore';
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
const keys = [
{ label: '主页', code: 3 }, // KEYCODE_HOME
{ label: '返回', code: 4 }, // KEYCODE_BACK
{ label: '多任务', code: 187 }, // KEYCODE_APP_SWITCH
{ label: '菜单', code: 82 }, // KEYCODE_MENU
{ label: '音量 +', code: 24 }, // KEYCODE_VOLUME_UP
{ label: '音量 -', code: 25 }, // KEYCODE_VOLUME_DOWN
{ label: '电源', code: 26 }, // KEYCODE_POWER
];
function press(code) {
if (!store.dataChannelOpen) return;
sendKey(code);
}
</script>
<template>
<div class="control-bar">
<button
v-for="k in keys"
:key="k.code"
class="key-btn"
:disabled="!store.dataChannelOpen"
@click="press(k.code)"
>
{{ k.label }}
</button>
</div>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { store, sendTouch, sendSwipe, sendLongPress, sendMotionEvent } from '../store/controllerStore';
const videoRef = ref(null);
const overlayRef = ref(null);
const hasStream = ref(false);
const active = computed(() => store.dataChannelOpen);
watch(
() => store.remoteStream,
(stream) => {
if (videoRef.value && stream) {
videoRef.value.srcObject = stream;
hasStream.value = true;
} else {
hasStream.value = false;
}
}
);
// 将指针坐标映射到视频内容区相对坐标 (0~1),兼容 object-fit: contain 的黑边。
function mapRelative(clientX, clientY) {
const v = videoRef.value;
const rect = v.getBoundingClientRect();
const vw = v.videoWidth || rect.width;
const vh = v.videoHeight || rect.height;
const scale = Math.min(rect.width / vw, rect.height / vh) || 1;
const dispW = vw * scale;
const dispH = vh * scale;
const offX = (rect.width - dispW) / 2;
const offY = (rect.height - dispH) / 2;
let x = (clientX - rect.left - offX) / dispW;
let y = (clientY - rect.top - offY) / dispH;
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
return { x, y };
}
const LONG_PRESS_MS = 400;
const TOUCH_SLOP = 0.02;
const SAMPLE_MS = 16;
let startX = 0, startY = 0, startTime = 0, isLongPressed = false, longTimer = null, lastMoveTs = 0;
function clearLong() {
if (longTimer) { clearTimeout(longTimer); longTimer = null; }
}
function onDown(e) {
if (!active.value) return;
overlayRef.value.setPointerCapture && overlayRef.value.setPointerCapture(e.pointerId);
isLongPressed = false;
const p = mapRelative(e.clientX, e.clientY);
startX = p.x; startY = p.y; startTime = Date.now(); lastMoveTs = 0;
sendMotionEvent(0, p.x, p.y); // ACTION_DOWN
clearLong();
longTimer = setTimeout(() => {
isLongPressed = true;
sendLongPress(p.x, p.y);
}, LONG_PRESS_MS);
}
function onMove(e) {
if (!active.value) return;
const p = mapRelative(e.clientX, e.clientY);
if (Math.abs(p.x - startX) > TOUCH_SLOP || Math.abs(p.y - startY) > TOUCH_SLOP) clearLong();
const now = Date.now();
if (now - lastMoveTs >= SAMPLE_MS) {
lastMoveTs = now;
sendMotionEvent(2, p.x, p.y); // ACTION_MOVE
}
}
function onUp(e) {
if (!active.value) return;
clearLong();
const p = mapRelative(e.clientX, e.clientY);
sendMotionEvent(1, p.x, p.y); // ACTION_UP
if (!isLongPressed) {
const dx = Math.abs(p.x - startX);
const dy = Math.abs(p.y - startY);
const dur = Date.now() - startTime;
if (dur < 200 && dx < TOUCH_SLOP && dy < TOUCH_SLOP) sendTouch(startX, startY);
else sendSwipe(startX, startY, p.x, p.y, Math.max(dur, 1));
}
}
</script>
<template>
<div class="screen-wrap">
<video ref="videoRef" class="remote-video" autoplay playsinline muted></video>
<div
ref="overlayRef"
class="touch-overlay"
:class="{ active }"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="clearLong"
></div>
<div v-if="!hasStream" class="placeholder">
<div class="big">📱</div>
<div v-if="!store.signalingConnected">请先在左侧连接信令服务器</div>
<div v-else-if="!store.targetDeviceId">请选择并连接一个被控端设备</div>
<div v-else>正在等待被控端画面</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { store } from '../store/controllerStore';
</script>
<template>
<div class="stats-bar">
<span>分辨率: <b>{{ store.stats ? store.stats.width + '×' + store.stats.height : '-' }}</b></span>
<span>帧率: <b>{{ store.stats ? store.stats.fps : '-' }}</b></span>
<span>解码: <b>{{ store.stats ? store.stats.codec : '-' }}</b></span>
<span>下载: <b>{{ store.stats ? store.stats.downSpeed : '-' }}</b></span>
<span>时长: <b>{{ store.stats ? store.stats.duration : '-' }}</b></span>
<span>控制通道: <b>{{ store.dataChannelOpen ? '已连接' : '未连接' }}</b></span>
<span>目标: <b>{{ store.targetDeviceId || '-' }}</b></span>
</div>
</template>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,31 @@
import protobuf from 'protobufjs';
// 与 webrtc_controller_flutter/proto/control_message.proto 完全一致的运行时编码。
// 浏览器端通过 protobufjs 动态加载 .proto构造 ControlMessage 并序列化为二进制,
// 经 DataChannel 发送,由 Android 被控端 InputCommandHandler 解析执行。
let ControlMessage = null;
export const Action = {
ACTION_UNKNOWN: 0,
TOUCH: 1,
SWIPE: 2,
KEY: 3,
LONG_PRESS: 4,
MOTION_EVENT: 5,
};
export async function loadProto() {
if (ControlMessage) return;
const root = await protobuf.load(`${import.meta.env.BASE_URL}control_message.proto`);
ControlMessage = root.lookupType('com.ttstd.control.ControlMessage');
}
export function encodeControlMessage(fields) {
if (!ControlMessage) throw new Error('protobuf 尚未加载,请先调用 loadProto()');
const err = ControlMessage.verify(fields);
if (err) throw new Error(err);
const message = ControlMessage.create(fields);
// 返回 Uint8Array可直接通过 RTCDataChannel.send 发送(二进制)。
return ControlMessage.encode(message).finish();
}

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 = [];
}
}

View File

@@ -0,0 +1,170 @@
import { reactive, markRaw } from 'vue';
import { SignalingClient } from '../services/SignalingClient';
import { WebRtcController } from '../services/WebRtcController';
import { loadProto } from '../proto/controlMessage';
// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。
export const DEFAULT_ICE_SERVERS = [
// 公共 TURNrelay 兜底UDP + TCP 两种传输TCP 用于 UDP 被防火墙拦截的网络。
{ urls: 'turn:175.178.213.60:3478', username: 'fanhuitong', credential: 'Fan19961207..' },
{ urls: 'turn:175.178.213.60:3478?transport=tcp', username: 'fanhuitong', credential: 'Fan19961207..' },
// 内网 TURN与被控端同局域网时可用
// { urls: 'turn:192.168.100.224:3478', username: 'tt', credential: 'fht' },
// { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' },
{ urls: 'stun:175.178.213.60:3478' },
// { urls: 'stun:192.168.5.224:3478' },
// { urls: 'stun:stun.l.google.com:19302' },
// { urls: 'stun:stun1.l.google.com:19302' },
// { urls: 'stun:stun2.l.google.com:19302' },
];
export const store = reactive({
serverUrl: 'ws://175.178.213.60:8088/ws/signal',
deviceId: '',
targetDeviceId: '',
iceServers: DEFAULT_ICE_SERVERS,
protoReady: false,
signalingConnected: false,
registered: false,
rtcConnected: false,
dataChannelOpen: false,
statusText: '未连接',
error: '',
controlledDevices: [],
stats: null,
remoteStream: null,
});
let signaling = null;
let webrtc = null;
export async function initProto() {
await loadProto();
store.protoReady = true;
}
function parsePayload(payload) {
if (!payload) return {};
if (typeof payload === 'string') {
try { return JSON.parse(payload); } catch { return {}; }
}
return payload;
}
export function connectSignaling() {
store.error = '';
const deviceId = store.deviceId.trim();
if (!deviceId) { store.error = '请填写本机设备 ID'; return; }
signaling = new SignalingClient({
serverUrl: store.serverUrl.trim(),
deviceId,
onConnected: () => {
store.signalingConnected = true;
store.statusText = '已连接信令服务器';
refreshDevices();
},
onDisconnected: () => {
store.signalingConnected = false;
store.registered = false;
store.statusText = '已断开信令连接';
},
onError: (e) => { store.error = '信令错误: ' + e; },
onMessage: handleSignalMessage,
});
signaling.connect();
}
function handleSignalMessage(msg) {
switch ((msg.type || '').toUpperCase()) {
case 'REGISTER_SUCCESS':
store.registered = true;
store.statusText = '注册成功 (CONTROLLER)';
break;
case 'DEVICE_LIST': {
const list = msg.controlled || [];
store.controlledDevices = Array.isArray(list) ? list : [];
if (!store.controlledDevices.includes(store.targetDeviceId)) store.targetDeviceId = '';
break;
}
case 'ANSWER': {
store.statusText = '被控端已接受,正在建立连接...';
if (webrtc) {
webrtc.handleAnswer(parsePayload(msg.payload).sdp).catch((e) => {
store.error = '设置远端描述失败: ' + (e?.message || e);
});
}
break;
}
case 'ICE_CANDIDATE':
webrtc && webrtc.handleIceCandidate(parsePayload(msg.payload));
break;
case 'TARGET_OFFLINE':
store.error = msg.payload || '目标被控端不在线,请确认设备已开启并连接服务器';
store.statusText = '连接失败';
break;
case 'CONNECTION_REJECTED':
case 'REQUEST_ERROR':
case 'REQUEST_TIMEOUT':
store.error = msg.payload || '连接请求失败';
store.statusText = '连接被拒绝';
break;
}
}
export function refreshDevices() {
signaling && signaling.requestDeviceList();
}
export async function connectToDevice(targetId) {
if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; }
if (!targetId) { store.error = '请选择要控制的被控端设备'; return; }
store.targetDeviceId = targetId;
store.error = '';
store.statusText = '正在发起连接...';
if (webrtc) { await webrtc.close(); webrtc = null; }
webrtc = new WebRtcController({
iceServers: store.iceServers,
deviceId: store.deviceId,
targetDeviceId: targetId,
signaling,
onIceState: (s) => {
if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; }
else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; }
},
onDataChannelState: (open) => { store.dataChannelOpen = open; },
onStream: (stream) => { store.remoteStream = markRaw(stream); },
onStats: (stats) => { store.stats = stats; },
onError: (msg) => { if (msg) store.error = msg; },
});
await webrtc.createOffer();
}
export async function disconnectDevice() {
if (webrtc) { await webrtc.close(); webrtc = null; }
store.rtcConnected = false;
store.dataChannelOpen = false;
store.remoteStream = null;
store.stats = null;
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
}
export function disconnectSignaling() {
disconnectDevice();
if (signaling) { signaling.disconnect(); signaling = null; }
store.signalingConnected = false;
store.registered = false;
store.controlledDevices = [];
}
// 控制指令转发(供 UI 组件调用)
export function sendTouch(x, y) { return webrtc && webrtc.sendTouch(x, y); }
export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.sendSwipe(x1, y1, x2, y2, duration); }
export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }

View File

@@ -0,0 +1,173 @@
:root {
--bg: #0f1419;
--panel: #1a212b;
--panel-2: #222c38;
--border: #2c3744;
--text: #e6edf3;
--muted: #8b98a5;
--accent: #3b82f6;
--accent-2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
html, body, #app {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; }
.status { color: var(--muted); font-size: 13px; }
.status.ok { color: var(--accent-2); }
.badges { margin-left: auto; display: flex; gap: 8px; }
.badge {
font-size: 12px;
padding: 3px 9px;
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
border: 1px solid var(--border);
}
.badge.on { background: rgba(34,197,94,.15); color: var(--accent-2); border-color: rgba(34,197,94,.4); }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar {
width: 320px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--panel);
overflow-y: auto;
padding: 16px;
}
.stage { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.input, .select {
width: 100%;
padding: 9px 11px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
outline: none;
}
.input:focus, .select:focus { border-color: var(--accent); }
.btn {
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
color: #fff;
background: var(--accent);
}
.btn:hover { filter: brightness(1.08); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
.btn.danger { background: var(--danger); }
.section-title { font-size: 13px; font-weight: 600; margin: 18px 0 10px; color: var(--text); }
.hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
.error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.device-item {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 11px; margin-bottom: 8px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
font-size: 13px;
}
.device-item.active { border-color: var(--accent); }
.screen-wrap {
flex: 1;
position: relative;
background: #000;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.remote-video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.touch-overlay {
position: absolute;
inset: 0;
touch-action: none;
}
.touch-overlay.active { cursor: crosshair; }
.placeholder {
position: absolute;
text-align: center;
color: var(--muted);
font-size: 14px;
padding: 24px;
}
.placeholder .big { font-size: 40px; margin-bottom: 10px; }
.control-bar {
display: flex;
gap: 8px;
padding: 12px;
background: var(--panel);
border-top: 1px solid var(--border);
flex-wrap: wrap;
justify-content: center;
}
.key-btn {
min-width: 84px;
padding: 10px 14px;
border-radius: 8px;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.key-btn:hover { border-color: var(--accent); }
.key-btn:active { background: var(--accent); }
.footer { background: var(--panel); border-top: 1px solid var(--border); }
.stats-bar {
display: flex;
gap: 18px;
padding: 10px 18px;
font-size: 12px;
color: var(--muted);
flex-wrap: wrap;
}
.stats-bar b { color: var(--text); font-weight: 600; }