project: 增加web和adb控制,未测试
This commit is contained in:
6
WebRTCControllerWeb/.gitignore
vendored
Normal file
6
WebRTCControllerWeb/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
105
WebRTCControllerWeb/README.md
Normal file
105
WebRTCControllerWeb/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# WebRTC 网页远程控制 (WebRTCControllerWeb)
|
||||
|
||||
基于 **Vue3 + Vite** 的 WebRTC 网页远程控制端,参考并兼容以下既有项目:
|
||||
|
||||
- `WebRTCSignalServer` —— Java/Spring 信令服务器(WebSocket,端点 `/ws/signal`)
|
||||
- `WebRTCControlled` —— Android 被控端(采集屏幕、接收控制指令)
|
||||
- `WebRTCController` / `webrtc_controller_flutter` —— Android / Flutter 控制端
|
||||
|
||||
本项目的定位是 **网页版控制端 (CONTROLLER)**,与原有信令协议、protobuf 控制协议 **完全兼容**:
|
||||
|
||||
- 通过 WebSocket 连接信令服务器并注册为 `CONTROLLER`
|
||||
- 作为 OFFER 方创建 WebRTC 连接(仅接收远端视频 `recvonly` + 一条控制用 `DataChannel`)
|
||||
- 在浏览器中显示被控端画面,并通过触摸/鼠标/导航按键采集输入
|
||||
- 将输入转换为相对坐标 (0.0~1.0) 的 protobuf `ControlMessage`,经 DataChannel 发送给被控端执行
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
WebRTCControllerWeb/
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── public/
|
||||
│ └── control_message.proto # 与 Android/Flutter 完全一致的 protobuf 定义
|
||||
├── server/ # 可选:轻量 Node 信令服务器(与 Spring 版协议一致)
|
||||
│ ├── index.js
|
||||
│ └── package.json
|
||||
└── src/
|
||||
├── main.js
|
||||
├── App.vue
|
||||
├── style.css
|
||||
├── proto/controlMessage.js # protobufjs 运行时编码
|
||||
├── services/
|
||||
│ ├── SignalingClient.js # WebSocket 信令(对应 Android WebSocketClient)
|
||||
│ └── WebRtcController.js # WebRTC + DataChannel(对应 WebRtcClient)
|
||||
├── store/controllerStore.js # 全局状态与流程编排(对应 RemoteController)
|
||||
└── components/
|
||||
├── ConnectionPanel.vue # 信令连接 / 设备列表 / 发起控制
|
||||
├── RemoteScreen.vue # 远端视频 + 触摸/滑动采集层
|
||||
├── ControlBar.vue # 主页/返回/多任务/音量/电源等导航键
|
||||
└── StatsBar.vue # 连接统计(分辨率/帧率/下载速率等)
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
### 1. 准备信令服务器(二选一)
|
||||
|
||||
**方式 A:复用现有 Spring 信令服务器**
|
||||
直接启动 `WebRTCSignalServer` 即可(默认 `ws://<host>:8088/ws/signal`)。
|
||||
|
||||
**方式 B:使用本项目自带的 Node 信令服务器**
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm start # 监听 ws://localhost:8088/ws/signal
|
||||
```
|
||||
|
||||
### 2. 启动被控端
|
||||
在安卓设备安装并运行 `WebRTCControlled`,保持其在线并连接到同一信令服务器。
|
||||
|
||||
### 3. 启动网页控制端
|
||||
```bash
|
||||
cd WebRTCControllerWeb
|
||||
npm install
|
||||
npm run dev # 默认 http://localhost:5173
|
||||
```
|
||||
|
||||
> 浏览器需支持 WebRTC。本地 `http://localhost` 可直接使用;
|
||||
> 部署到远程地址时建议使用 `https://` 与 `wss://`,否则部分浏览器会拦截。
|
||||
|
||||
### 4. 使用
|
||||
1. 左侧填写信令服务器地址与「本机设备 ID」(已自动生成随机 ID)。
|
||||
2. 点击「连接信令服务器」,再点击「刷新设备列表」获取在线被控端。
|
||||
3. 选择目标设备,点击「发起远程控制」。
|
||||
4. 被控端弹出连接请求并「接受」后,右侧出现屏幕画面。
|
||||
5. 在画面上 **触摸/滑动** 即可操控;底部按钮对应系统导航键(主页/返回/多任务/菜单/音量/电源)。
|
||||
|
||||
## 控制协议要点
|
||||
|
||||
- **信令消息**(`SignalMessage`):`type / fromDeviceId / toDeviceId / deviceType / payload(JSON 字符串)`,
|
||||
类型含 `REGISTER / OFFER / ANSWER / ICE_CANDIDATE / DEVICE_LIST` 等。
|
||||
- **DataChannel** 标签固定为 `control_channel`,创建参数 `ordered:false, maxRetransmits:0`(非可靠、无序,最低延迟)。
|
||||
- **控制指令**(`ControlMessage`,protobuf 二进制):
|
||||
- `TOUCH=1` 单击(x,y)
|
||||
- `SWIPE=2` 滑动(x1,y1,x2,y2,duration)
|
||||
- `KEY=3` 按键(key_code;被控端自动完成 按下+抬起)
|
||||
- `LONG_PRESS=4` 长按(x,y)
|
||||
- `MOTION_EVENT=5` 原始指针动作(motion_action: 0=DOWN/1=UP/2=MOVE;x,y)
|
||||
|
||||
坐标均为相对屏幕百分比 (0.0~1.0),因此不同分辨率设备间可直接换算。
|
||||
|
||||
## 与原有项目的对应关系
|
||||
|
||||
| 能力 | Android/Flutter | 本项目 (Vue3) |
|
||||
| --- | --- | --- |
|
||||
| 信令注册 | `WebSocketClient.registerDevice()` | `SignalingClient.register()` |
|
||||
| 创建连接 | `WebRtcClient.createOffer()` | `WebRtcController.createOffer()` |
|
||||
| 触摸采集 | `RemoteTouchView` | `RemoteScreen.vue`(pointer 事件 + 相对坐标映射) |
|
||||
| 指令构造 | `ControlCommands` | `WebRtcController.sendTouch/...` + `controlMessage.js` |
|
||||
| 流程编排 | `MainActivity` / `RemoteController` | `controllerStore.js` |
|
||||
|
||||
## 备注
|
||||
|
||||
- ICE/TURN 配置见 `src/store/controllerStore.js` 的 `DEFAULT_ICE_SERVERS`,请按需替换为你自己的 TURN 凭据。
|
||||
- 本网页端作为 **控制端**,被控端仍为 Android(`WebRTCControlled`)。
|
||||
15
WebRTCControllerWeb/index.html
Normal file
15
WebRTCControllerWeb/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>WebRTC 网页远程控制</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1346
WebRTCControllerWeb/package-lock.json
generated
Normal file
1346
WebRTCControllerWeb/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
WebRTCControllerWeb/package.json
Normal file
20
WebRTCControllerWeb/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "webrtc-controller-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "基于 Vue3 的 WebRTC 网页远程控制端,兼容现有信令服务器与 Android 被控端(对应 WebRTCController / webrtc_controller_flutter)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"protobufjs": "^7.3.2",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
844
WebRTCControllerWeb/pnpm-lock.yaml
generated
Normal file
844
WebRTCControllerWeb/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,844 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
protobufjs:
|
||||
specifier: ^7.3.2
|
||||
version: 7.6.5
|
||||
vue:
|
||||
specifier: ^3.4.21
|
||||
version: 3.5.40
|
||||
devDependencies:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.0.4
|
||||
version: 5.2.4(vite@5.4.21(@types/node@26.1.1))(vue@3.5.40)
|
||||
vite:
|
||||
specifier: ^5.2.0
|
||||
version: 5.4.21(@types/node@26.1.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1':
|
||||
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.2':
|
||||
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.62.2':
|
||||
resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.62.2':
|
||||
resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.62.2':
|
||||
resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.62.2':
|
||||
resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.62.2':
|
||||
resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.62.2':
|
||||
resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
|
||||
resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.62.2':
|
||||
resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.62.2':
|
||||
resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.62.2':
|
||||
resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.62.2':
|
||||
resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vue/compiler-core@3.5.40':
|
||||
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
|
||||
|
||||
'@vue/compiler-dom@3.5.40':
|
||||
resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.40':
|
||||
resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.40':
|
||||
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
|
||||
|
||||
'@vue/reactivity@3.5.40':
|
||||
resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==}
|
||||
|
||||
'@vue/runtime-core@3.5.40':
|
||||
resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==}
|
||||
|
||||
'@vue/runtime-dom@3.5.40':
|
||||
resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==}
|
||||
|
||||
'@vue/server-renderer@3.5.40':
|
||||
resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==}
|
||||
|
||||
'@vue/shared@3.5.40':
|
||||
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.5.19:
|
||||
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
rollup@4.62.2:
|
||||
resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
vite@5.4.21:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^18.0.0 || >=20.0.0
|
||||
less: '*'
|
||||
lightningcss: ^1.21.0
|
||||
sass: '*'
|
||||
sass-embedded: '*'
|
||||
stylus: '*'
|
||||
sugarss: '*'
|
||||
terser: ^5.4.0
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
vue@3.5.40:
|
||||
resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.2': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@26.1.1))(vue@3.5.40)':
|
||||
dependencies:
|
||||
vite: 5.4.21(@types/node@26.1.1)
|
||||
vue: 3.5.40
|
||||
|
||||
'@vue/compiler-core@3.5.40':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@vue/shared': 3.5.40
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.40':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/compiler-sfc@3.5.40':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@vue/compiler-core': 3.5.40
|
||||
'@vue/compiler-dom': 3.5.40
|
||||
'@vue/compiler-ssr': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.19
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.40':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/reactivity@3.5.40':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/runtime-core@3.5.40':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/runtime-dom@3.5.40':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.40
|
||||
'@vue/runtime-core': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.40':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.40
|
||||
'@vue/runtime-dom': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/shared@3.5.40': {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
'@esbuild/android-arm': 0.21.5
|
||||
'@esbuild/android-arm64': 0.21.5
|
||||
'@esbuild/android-x64': 0.21.5
|
||||
'@esbuild/darwin-arm64': 0.21.5
|
||||
'@esbuild/darwin-x64': 0.21.5
|
||||
'@esbuild/freebsd-arm64': 0.21.5
|
||||
'@esbuild/freebsd-x64': 0.21.5
|
||||
'@esbuild/linux-arm': 0.21.5
|
||||
'@esbuild/linux-arm64': 0.21.5
|
||||
'@esbuild/linux-ia32': 0.21.5
|
||||
'@esbuild/linux-loong64': 0.21.5
|
||||
'@esbuild/linux-mips64el': 0.21.5
|
||||
'@esbuild/linux-ppc64': 0.21.5
|
||||
'@esbuild/linux-riscv64': 0.21.5
|
||||
'@esbuild/linux-s390x': 0.21.5
|
||||
'@esbuild/linux-x64': 0.21.5
|
||||
'@esbuild/netbsd-x64': 0.21.5
|
||||
'@esbuild/openbsd-x64': 0.21.5
|
||||
'@esbuild/sunos-x64': 0.21.5
|
||||
'@esbuild/win32-arm64': 0.21.5
|
||||
'@esbuild/win32-ia32': 0.21.5
|
||||
'@esbuild/win32-x64': 0.21.5
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.5.19:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
protobufjs@7.6.5:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.1
|
||||
'@protobufjs/fetch': 1.1.1
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.2
|
||||
'@types/node': 26.1.1
|
||||
long: 5.3.2
|
||||
|
||||
rollup@4.62.2:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.62.2
|
||||
'@rollup/rollup-android-arm64': 4.62.2
|
||||
'@rollup/rollup-darwin-arm64': 4.62.2
|
||||
'@rollup/rollup-darwin-x64': 4.62.2
|
||||
'@rollup/rollup-freebsd-arm64': 4.62.2
|
||||
'@rollup/rollup-freebsd-x64': 4.62.2
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.62.2
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.62.2
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-arm64-musl': 4.62.2
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-loong64-musl': 4.62.2
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.62.2
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.62.2
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-x64-gnu': 4.62.2
|
||||
'@rollup/rollup-linux-x64-musl': 4.62.2
|
||||
'@rollup/rollup-openbsd-x64': 4.62.2
|
||||
'@rollup/rollup-openharmony-arm64': 4.62.2
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.62.2
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.62.2
|
||||
'@rollup/rollup-win32-x64-gnu': 4.62.2
|
||||
'@rollup/rollup-win32-x64-msvc': 4.62.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
vite@5.4.21(@types/node@26.1.1):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.19
|
||||
rollup: 4.62.2
|
||||
optionalDependencies:
|
||||
'@types/node': 26.1.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
vue@3.5.40:
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.40
|
||||
'@vue/compiler-sfc': 3.5.40
|
||||
'@vue/runtime-dom': 3.5.40
|
||||
'@vue/server-renderer': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
41
WebRTCControllerWeb/public/control_message.proto
Normal file
41
WebRTCControllerWeb/public/control_message.proto
Normal file
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package com.ttstd.control;
|
||||
|
||||
option java_package = "com.ttstd.control";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// 控制指令类型,对应原 JSON 字段 action。
|
||||
enum Action {
|
||||
ACTION_UNKNOWN = 0;
|
||||
TOUCH = 1;
|
||||
SWIPE = 2;
|
||||
KEY = 3;
|
||||
LONG_PRESS = 4;
|
||||
MOTION_EVENT = 5;
|
||||
}
|
||||
|
||||
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||
// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。
|
||||
message ControlMessage {
|
||||
// 指令类型
|
||||
Action action = 1;
|
||||
|
||||
// 单点坐标(TOUCH / LONG_PRESS / MOTION_EVENT)
|
||||
double x = 2;
|
||||
double y = 3;
|
||||
|
||||
// 滑动起止坐标(SWIPE)
|
||||
double x1 = 4;
|
||||
double y1 = 5;
|
||||
double x2 = 6;
|
||||
double y2 = 7;
|
||||
int64 duration = 8;
|
||||
|
||||
// 按键(KEY):key_action 0=按下 1=抬起(对应 Android KeyEvent ACTION_DOWN/UP)
|
||||
int32 key_code = 9;
|
||||
int32 key_action = 10;
|
||||
|
||||
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||
int32 motion_action = 11;
|
||||
}
|
||||
109
WebRTCControllerWeb/server/index.js
Normal file
109
WebRTCControllerWeb/server/index.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// 轻量信令服务器,协议与 WebRTCSignalServer (Spring) 完全兼容。
|
||||
// 端点: ws://host:PORT/ws/signal
|
||||
// 支持消息: REGISTER / DEVICE_LIST / OFFER / ANSWER / ICE_CANDIDATE / CONTROL_COMMAND / CONNECTION_REJECTED
|
||||
// 支持通知: REGISTER_SUCCESS / DEVICE_LIST / TARGET_OFFLINE / REQUEST_ERROR / REQUEST_TIMEOUT
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8088);
|
||||
const PATH = process.env.WS_PATH || '/ws/signal';
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT, path: PATH });
|
||||
|
||||
// deviceId -> { ws, type }
|
||||
const sessions = new Map();
|
||||
// "from->to" -> true(等待被控端确认)
|
||||
const pending = new Map();
|
||||
|
||||
const keyOf = (a, b) => `${a}->${b}`;
|
||||
|
||||
function send(ws, obj) {
|
||||
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (data) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const type = (msg.type || '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'REGISTER': handleRegister(ws, msg); break;
|
||||
case 'DEVICE_LIST': handleDeviceList(ws); break;
|
||||
case 'OFFER': handleOffer(msg); break;
|
||||
case 'ANSWER':
|
||||
case 'CONNECTION_REJECTED':
|
||||
pending.delete(keyOf(msg.toDeviceId, msg.fromDeviceId));
|
||||
forward(msg);
|
||||
break;
|
||||
case 'ICE_CANDIDATE':
|
||||
case 'CONTROL_COMMAND':
|
||||
forward(msg);
|
||||
break;
|
||||
default:
|
||||
forward(msg);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const [id, s] of sessions) {
|
||||
if (s.ws === ws) { sessions.delete(id); break; }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handleRegister(ws, msg) {
|
||||
const id = msg.fromDeviceId;
|
||||
const type = (msg.deviceType || '').toUpperCase();
|
||||
if (!id || !type) return;
|
||||
sessions.set(id, { ws, type });
|
||||
send(ws, { type: 'REGISTER_SUCCESS', deviceId: id });
|
||||
console.log(`设备注册: ${id} (${type})`);
|
||||
}
|
||||
|
||||
function handleDeviceList(ws) {
|
||||
const controllers = [];
|
||||
const controlled = [];
|
||||
for (const [id, s] of sessions) {
|
||||
if (s.type === 'CONTROLLER') controllers.push(id);
|
||||
else if (s.type === 'CONTROLLED') controlled.push(id);
|
||||
}
|
||||
send(ws, { type: 'DEVICE_LIST', controllers, controlled });
|
||||
}
|
||||
|
||||
function handleOffer(msg) {
|
||||
const from = msg.fromDeviceId;
|
||||
const to = msg.toDeviceId;
|
||||
const fromType = sessions.get(from)?.type;
|
||||
const toType = sessions.get(to)?.type;
|
||||
|
||||
if (fromType !== 'CONTROLLER' || toType !== 'CONTROLLED') {
|
||||
send(sessions.get(from)?.ws, {
|
||||
type: 'REQUEST_ERROR',
|
||||
toDeviceId: to,
|
||||
payload: '连接请求只能由 CONTROLLER 发往 CONTROLLED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const target = sessions.get(to)?.ws;
|
||||
if (!target) {
|
||||
send(sessions.get(from)?.ws, {
|
||||
type: 'TARGET_OFFLINE',
|
||||
toDeviceId: to,
|
||||
payload: '目标被控端不在线,请确认设备已开启并连接服务器',
|
||||
});
|
||||
return;
|
||||
}
|
||||
pending.set(keyOf(from, to), true);
|
||||
forward(msg);
|
||||
}
|
||||
|
||||
function forward(msg) {
|
||||
const target = sessions.get(msg.toDeviceId)?.ws;
|
||||
if (!target) return;
|
||||
send(target, msg);
|
||||
}
|
||||
|
||||
console.log(`信令服务器已启动: ws://localhost:${PORT}${PATH}`);
|
||||
13
WebRTCControllerWeb/server/package.json
Normal file
13
WebRTCControllerWeb/server/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "webrtc-controller-web-signaling",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "与现有 Spring 信令服务器协议兼容的轻量 Node 信令服务(可选,便于本地独立运行)",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.17.0"
|
||||
}
|
||||
}
|
||||
42
WebRTCControllerWeb/src/App.vue
Normal file
42
WebRTCControllerWeb/src/App.vue
Normal 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>
|
||||
66
WebRTCControllerWeb/src/components/ConnectionPanel.vue
Normal file
66
WebRTCControllerWeb/src/components/ConnectionPanel.vue
Normal 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>
|
||||
33
WebRTCControllerWeb/src/components/ControlBar.vue
Normal file
33
WebRTCControllerWeb/src/components/ControlBar.vue
Normal 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>
|
||||
112
WebRTCControllerWeb/src/components/RemoteScreen.vue
Normal file
112
WebRTCControllerWeb/src/components/RemoteScreen.vue
Normal 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>
|
||||
15
WebRTCControllerWeb/src/components/StatsBar.vue
Normal file
15
WebRTCControllerWeb/src/components/StatsBar.vue
Normal 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>
|
||||
5
WebRTCControllerWeb/src/main.js
Normal file
5
WebRTCControllerWeb/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
31
WebRTCControllerWeb/src/proto/controlMessage.js
Normal file
31
WebRTCControllerWeb/src/proto/controlMessage.js
Normal 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();
|
||||
}
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
170
WebRTCControllerWeb/src/store/controllerStore.js
Normal file
170
WebRTCControllerWeb/src/store/controllerStore.js
Normal 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 = [
|
||||
// 公共 TURN(relay 兜底):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); }
|
||||
173
WebRTCControllerWeb/src/style.css
Normal file
173
WebRTCControllerWeb/src/style.css
Normal 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; }
|
||||
10
WebRTCControllerWeb/vite.config.js
Normal file
10
WebRTCControllerWeb/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user