├── .github
└── workflows
│ └── deploy.yml
├── .gitignore
├── LICENSE
├── README.md
├── desc
└── image.png
├── index.html
├── package-lock.json
├── package.json
├── plugins
├── binary-loader.js
└── md-loader.js
├── public
├── favicon.ico
├── getVersion.js
├── icon
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── icon-192.png
│ └── icon-512.png
├── scrcpy-server-v2.6.1
└── version.js
├── site.webmanifest
├── src
├── App.vue
├── assets
│ └── high-qa.png
├── components
│ ├── Common
│ │ └── GitHubStats.vue
│ ├── Device
│ │ ├── AppManager.vue
│ │ ├── BatteryInfo.vue
│ │ ├── DeviceBasicInfo.vue
│ │ ├── DeviceGuide.vue
│ │ ├── DeviceInfo.vue
│ │ ├── DeviceInstall.vue
│ │ ├── DeviceLogcat.vue
│ │ ├── DeviceSelectDrawer.vue
│ │ ├── DeviceShell.vue
│ │ ├── NavigationBar.vue
│ │ ├── PairedDevices.vue
│ │ ├── StorageInfo.vue
│ │ └── VideoContainer.vue
│ └── Scrcpy
│ │ ├── adb-client.ts
│ │ ├── file.ts
│ │ ├── index.ts
│ │ ├── input.ts
│ │ ├── matroska.ts
│ │ ├── recorder.ts
│ │ └── scrcpy-state.ts
├── main.js
└── views
│ ├── AbstractList.vue
│ └── DeviceView.vue
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | # Workflow for deploying Vue.js content to GitHub Pages
2 | name: Deploy Vue app to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
20 | concurrency:
21 | group: "pages"
22 | cancel-in-progress: false
23 |
24 | jobs:
25 | # Build job
26 | build:
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 | - name: Setup Node
32 | uses: actions/setup-node@v3
33 | with:
34 | node-version: '16'
35 | - name: Install dependencies
36 | run: npm ci
37 | - name: Build
38 | run: npm run build
39 | - name: Setup Pages
40 | uses: actions/configure-pages@v5
41 | - name: Upload artifact
42 | uses: actions/upload-pages-artifact@v3
43 | with:
44 | # Upload dist repository
45 | path: './dist'
46 |
47 | # Deployment job
48 | deploy:
49 | environment:
50 | name: github-pages
51 | url: ${{ steps.deployment.outputs.page_url }}
52 | runs-on: ubuntu-latest
53 | needs: build
54 | steps:
55 | - name: Deploy to GitHub Pages
56 | id: deployment
57 | uses: actions/deploy-pages@v4
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # 依赖目录
2 | node_modules
3 | .pnp
4 | .pnp.js
5 |
6 | # 日志
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | pnpm-debug.log*
13 | lerna-debug.log*
14 |
15 | # 编辑器目录和文件
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # 测试覆盖率目录
27 | coverage
28 |
29 | # 构建输出目录
30 | dist
31 | dist-ssr
32 |
33 | # 本地环境文件
34 | *.local
35 |
36 | # 类型声明文件
37 | *.d.ts
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # web-scrcpy
2 | 
3 |
4 | ## 项目简介
5 | web-scrcpy 是一个基于Web的远程控制工具,允许用户通过浏览器控制和查看Android设备的屏幕。
6 | - **高性能**:使用高效的视频编码和解码技术,提供流畅的屏幕镜像体验。
7 | - **低延迟**:优化的数据传输协议,确保低延迟的控制响应。
8 | - **多功能**:支持屏幕录制、截图、全屏模式等多种功能。
9 | - **跨平台**:支持Windows、macOS和Linux操作系统。
10 |
11 | ## 使用说明
12 | web-scrcpy 继承了scrcpy的优点,提供了高性能、低延迟的屏幕镜像和控制功能。以下是详细的使用说明:
13 | 1. 连接Android设备到电脑,并确保设备已启用开发者选项和USB调试。
14 | 2. 打开浏览器,访问 [在线体验地址](https://maxwellos.github.io/web-scrcpy/)。
15 | 3. 按照页面提示,连接并控制Android设备。
16 | 4. 使用鼠标和键盘与设备进行交互,支持全屏模式、屏幕录制和截图功能。
17 |
18 | 
19 |
20 |
21 | ## 贡献指南
22 | 欢迎任何形式的贡献!请遵循以下步骤:
23 | 1. Fork 本仓库
24 | 2. 创建一个新的分支 (`git checkout -b feature-branch`)
25 | 3. 提交你的更改 (`git commit -am 'Add some feature'`)
26 | 4. 推送到分支 (`git push origin feature-branch`)
27 | 5. 创建一个新的Pull Request
28 |
29 | ## 许可证信息
30 | 本项目使用 [MIT 许可证](LICENSE)。
31 |
32 |
--------------------------------------------------------------------------------
/desc/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/desc/image.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | web-scrcpy
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web-scrcpy",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "build-only": "vite build",
10 | "type-check": "vue-tsc --noEmit",
11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
12 | "postinstall": "fetch-scrcpy-server 2.6.1"
13 | },
14 | "dependencies": {
15 | "@mdi/font": "^7.4.47",
16 | "@yume-chan/adb": "^0.0.24",
17 | "@yume-chan/adb-backend-webusb": "^0.0.19",
18 | "@yume-chan/adb-scrcpy": "^0.0.24",
19 | "@yume-chan/android-bin": "^0.0.24",
20 | "@yume-chan/aoa": "^0.0.24",
21 | "@yume-chan/event": "^0.0.24",
22 | "@yume-chan/fetch-scrcpy-server": "^0.0.24",
23 | "@yume-chan/pcm-player": "^0.0.24",
24 | "@yume-chan/scrcpy": "^0.0.24",
25 | "@yume-chan/scrcpy-decoder-tinyh264": "^0.0.24",
26 | "@yume-chan/scrcpy-decoder-webcodecs": "^0.0.24",
27 | "@yume-chan/stream-extra": "^0.0.24",
28 | "@yume-chan/struct": "^0.0.24",
29 | "file-saver": "^2.0.5",
30 | "markdown-it": "^14.1.0",
31 | "vue": "^3.3.4",
32 | "vuetify": "^3.3.5",
33 | "webm-muxer": "^5.0.3",
34 | "xterm": "^5.3.0",
35 | "xterm-addon-fit": "^0.8.0"
36 | },
37 | "devDependencies": {
38 | "@mdi/font": "^7.1.96",
39 | "@rushstack/eslint-patch": "^1.10.4",
40 | "@types/lodash": "^4.14.194",
41 | "@types/lodash-es": "^4.17.12",
42 | "@types/node": "^18.11.12",
43 | "@types/three": "^0.149.0",
44 | "@vitejs/plugin-vue": "^5.0.4",
45 | "@vitejs/plugin-vue-jsx": "^3.0.0",
46 | "@vue/eslint-config-prettier": "^9.0.0",
47 | "@vue/eslint-config-typescript": "^12.0.0",
48 | "@vue/tsconfig": "^0.5.1",
49 | "@vuetify/loader-shared": "^2.0.3",
50 | "@yume-chan/adb-credential-web": "^0.0.24",
51 | "@yume-chan/adb-daemon-webusb": "^0.0.24",
52 | "eslint": "^8.57.1",
53 | "eslint-plugin-vue": "^9.31.0",
54 | "npm-run-all": "^4.1.5",
55 | "prettier": "^3.2.5",
56 | "sass": "^1.57.1",
57 | "typescript": "5.2.2",
58 | "vite": "^5.2.11",
59 | "vite-plugin-vuetify": "^2.0.3",
60 | "vue-tsc": "^2.0.19"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/plugins/binary-loader.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 |
3 | export function binaryLoader(options = {}) {
4 | const binRegex = /\?binary$/;
5 | return {
6 | name: 'binary-loader',
7 | enforce: 'pre',
8 |
9 | async load(id) {
10 | if (!id.match(binRegex)) {
11 | return;
12 | }
13 | const [path, query] = id.split('?', 2);
14 |
15 | try {
16 | const data = fs.readFileSync(path);
17 | return {
18 | code: `export default new Uint8Array(${JSON.stringify(Array.from(data))})`,
19 | map: null
20 | };
21 | } catch (ex) {
22 | console.warn(
23 | ex,
24 | '\n',
25 | `${id} couldn't be loaded by binary-loader, fallback to default loader`
26 | );
27 | return;
28 | }
29 | },
30 | };
31 | }
32 |
33 | export default binaryLoader;
34 |
--------------------------------------------------------------------------------
/plugins/md-loader.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import { compileTemplate } from '@vue/compiler-sfc';
3 | import MarkdownIt from 'markdown-it';
4 |
5 | const __doc__ = `
6 | ## How to use:
7 |
8 | vite.config.js:
9 | import Markdown from './plugins/md-loader'
10 | export default defineConfig({
11 | plugins: [
12 | vue(),
13 | Markdown(),
14 | ],
15 | assetsInclude: ["**/*.md"]
16 | })
17 |
18 |
19 | in vue:
20 |
21 | import EnableDebugContent from '../docs/enabledebug.md'
22 |
23 |
24 |
25 |
26 |
27 | `;
28 |
29 | export function mdLoader(options = {}) {
30 | const mdRegex = /\.md$/;
31 | return {
32 | name: 'markdown-loader',
33 | enforce: 'pre',
34 |
35 | async load(id) {
36 | if (!id.match(mdRegex)) {
37 | return;
38 | }
39 | const [path, query] = id.split('?', 2);
40 |
41 | let data;
42 | try {
43 | data = fs.readFileSync(path, 'utf-8');
44 | } catch (ex) {
45 | console.warn(
46 | ex,
47 | '\n',
48 | `${id} couldn't be loaded by vite-md-loader, fallback to default loader`,
49 | );
50 | return;
51 | }
52 |
53 | try {
54 | const md = new MarkdownIt();
55 | const result = md.render(data);
56 | const { code } = compileTemplate({
57 | id: JSON.stringify(id),
58 | source: `${result}`,
59 | filename: path,
60 | transformAssetUrls: false,
61 | });
62 | return `${code}\nexport default { render: render }`;
63 | } catch (ex) {
64 | console.warn(ex, '\n', `${id} compile markdown fail`);
65 | return;
66 | }
67 | },
68 | };
69 | }
70 |
71 | export default mdLoader;
72 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/favicon.ico
--------------------------------------------------------------------------------
/public/getVersion.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-undef
2 | const fs = require('fs');
3 | console.log('build version');
4 | fs.writeFile('./public/version.js', `getVersion('${new Date().getTime()}')`, function (err) {
5 | console.log('build version success');
6 | if (err) {
7 | console.log(err, 'build version err');
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/public/icon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/icon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/icon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/icon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/icon/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/icon/icon-192.png
--------------------------------------------------------------------------------
/public/icon/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/icon/icon-512.png
--------------------------------------------------------------------------------
/public/scrcpy-server-v2.6.1:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/public/scrcpy-server-v2.6.1
--------------------------------------------------------------------------------
/public/version.js:
--------------------------------------------------------------------------------
1 | getVersion('1734596630394')
--------------------------------------------------------------------------------
/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web-scrcpy",
3 | "short_name": "web-scrcpy",
4 | "icons": [
5 | {
6 | "src": "./src/assets/folder/icon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "./src/assets/folder/icon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
19 |
20 |
27 |
--------------------------------------------------------------------------------
/src/assets/high-qa.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Maxwellos/web-scrcpy/c1d494989fb4ea2d3373836daf19cffc29dcab9b/src/assets/high-qa.png
--------------------------------------------------------------------------------
/src/components/Common/GitHubStats.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
31 |
39 | mdi-star
40 | {{ stats.stars }}
41 |
42 |
43 |
51 | mdi-source-fork
52 | {{ stats.forks }}
53 |
54 |
55 |
63 | mdi-eye
64 | {{ stats.watchers }}
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/components/Device/AppManager.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | mdi-format-list-bulleted
12 | 应用列表
13 |
14 |
15 |
22 | 刷新
23 |
24 |
25 |
26 |
27 |
28 |
36 |
37 |
38 |
39 |
49 |
50 |
51 |
52 |
mdi-android
53 |
54 |
{{ item.appName }}
55 |
{{ item.packageName }}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
版本号: {{ item.versionCode || '未知' }}
64 |
65 |
66 |
67 |
68 |
69 |
70 |
来源: {{ item.installer }}
71 |
72 | 路径: {{ item.sourceDir }}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
87 | 启动
88 |
89 |
97 | 导出
98 |
99 |
107 | 卸载
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | mdi-package-variant
121 | 安装应用
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 | 导出APK
136 |
137 | {{ exportingApp?.appName }}
138 |
144 |
145 | {{ Math.ceil(value) }}%
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | {{ errorMessage }}
155 |
156 |
157 |
158 |
159 |
160 |
161 | 确认卸载
162 |
163 |
164 | 确定要卸载 {{ selectedApp?.appName }} 吗?
165 |
166 | 包名: {{ selectedApp?.packageName }}
167 |
168 |
169 |
170 |
171 |
176 | 取消
177 |
178 |
184 | 卸载
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
457 |
458 |
530 |
--------------------------------------------------------------------------------
/src/components/Device/BatteryInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 电池
5 |
9 |
10 |
11 |
12 |
{{ batteryPercentage }}%
13 |
14 |
15 | {{ formattedVoltage }}V {{ temperature }}°C
16 |
17 |
18 |
19 |
20 |
21 |
22 |
51 |
52 |
99 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceBasicInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 设备信息
5 |
6 |
7 |
品牌
8 |
{{ deviceInfo.brand }}
9 |
连接状态
10 |
{{ deviceInfo.type }}
11 |
12 |
13 |
型号
14 |
{{ deviceInfo.deviceModel }}
15 |
Bootloader 锁
16 |
{{ bootloaderStatus }}
17 |
18 |
19 |
代号
20 |
{{ deviceInfo.device }}
21 |
A/B槽位
22 |
{{ abPartitionStatus }}
23 |
24 |
25 |
安卓SDK
26 |
Android {{ deviceInfo.androidVersion }}({{ deviceInfo.sdkVersionCode }})
27 |
VNDK 版本
28 |
{{ deviceInfo.sdkVersionCode }}
29 |
30 |
31 |
CPU 架构
32 |
{{ deviceInfo.cpuAbi }}
33 |
CPU 代号
34 |
{{ deviceInfo.cpuInfo }}
35 |
36 |
37 |
分辨率
38 |
{{ deviceInfo.resolution }}
39 |
开机时间
40 |
{{ uptime }}
41 |
42 |
43 |
显示密度
44 |
{{ deviceInfo.screenDensity }}
45 |
闪存类型
46 |
{{ storageType }}
47 |
48 |
49 |
主板 ID
50 |
{{ deviceInfo.board || '-' }}
51 |
52 |
53 |
平台
54 |
{{ deviceInfo.hardware }}
55 |
56 |
57 |
编译版本
58 |
{{ deviceInfo.fingerPrint }}
59 |
60 |
61 |
内核版本
62 |
{{ deviceInfo.kernelVersion }}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
109 |
110 |
177 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceGuide.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | mdi-help-circle
4 | 帮助文档
5 |
6 |
7 |
8 |
9 | 添加设备指南
10 |
11 |
12 | 步骤说明
13 |
14 |
15 | {{ index + 1 }}. {{ step.title }}
16 | {{ step.content }}
17 |
18 |
19 |
20 |
21 |
22 | 常见问题 (FAQ)
23 |
24 |
25 |
26 | {{ item.question }}
28 |
29 | {{ item.answer }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | 完成
39 |
40 |
41 |
42 |
43 |
44 |
45 |
109 |
110 |
119 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceInfo.vue:
--------------------------------------------------------------------------------
1 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
238 |
239 |
240 |
244 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
262 |
263 |
264 |
265 |
270 | 设置
271 |
272 |
277 | 开发者
278 |
279 |
284 | 浏览器
285 |
286 |
291 | WiFi
292 |
293 |
294 |
295 |
296 |
301 | 显示
302 |
303 |
308 | 应用
309 |
310 |
315 | 关于
316 |
317 |
318 |
319 |
320 |
321 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
450 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceInstall.vue:
--------------------------------------------------------------------------------
1 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
131 |
138 |
mdi-android
139 |
点击或拖拽 APK 文件到此处
140 |
已选择: {{ file.name }}
141 |
142 |
143 |
144 |
145 | 文件大小: {{ formatFileSize(file.size) }}
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
168 | 安装应用
169 |
170 |
171 |
172 |
179 |
180 | {{ Math.ceil(value) }}%
181 |
182 |
183 |
184 |
185 |
186 |
187 | {{ progress.filename }}
188 | {{ progress.stage }}
189 |
190 | {{ formatFileSize(progress.uploadedSize) }} /
191 | {{ formatFileSize(progress.totalSize) }}
192 |
193 |
194 |
195 |
196 |
197 |
198 | {{ errorMessage }}
199 |
200 |
201 |
202 |
203 |
204 |
205 | {{ log }}
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
287 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceLogcat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
64 |
65 |
66 |
72 |
73 |
79 |
80 |
93 |
{{ formatTime(item) }}
94 |
95 |
102 | {{ AndroidLogPriorityToCharacter[item.priority] }}
103 |
104 |
105 |
{{ item.tag }}
106 |
{{ item.message }}
107 |
108 |
109 |
110 |
111 |
112 |
113 | {{ isRunning ? '正在等待日志...' : '没有日志可显示' }}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
350 |
351 |
446 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceSelectDrawer.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
26 |
27 |
28 | 选择调试设备
29 |
30 | mdi-close
31 |
32 |
33 |
34 |
39 | 支持将您本地设备快速接入到High-QA平台,提供设备调试、应用管理、日志查看等功能
40 |
41 |
42 | 可用设备
43 |
44 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/components/Device/DeviceShell.vue:
--------------------------------------------------------------------------------
1 |
97 |
98 |
99 |
100 |
101 | 设备终端器
102 |
103 |
104 | mdi-restart
105 | 重新启动
106 |
107 |
108 | mdi-delete
109 | 清除
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
137 |
--------------------------------------------------------------------------------
/src/components/Device/NavigationBar.vue:
--------------------------------------------------------------------------------
1 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
319 |
327 |
328 |
338 |
339 |
340 |
341 |
342 |
349 |
350 |
351 | mdi-arrow-left
352 |
353 |
354 |
355 |
356 |
363 |
364 |
365 | mdi-circle-outline
366 |
367 |
368 |
369 |
376 |
377 |
378 | mdi-square-outline
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
418 |
--------------------------------------------------------------------------------
/src/components/Device/PairedDevices.vue:
--------------------------------------------------------------------------------
1 |
207 |
208 |
209 |
210 |
221 |
222 |
223 | 设备切换
224 | mdi-cellphone-link
225 |
226 | {{ selected.name || selected.serial }}
227 |
228 | 选择设备
229 |
230 | {{
231 | connectionStatus === 'connected'
232 | ? 'mdi-check-circle'
233 | : 'mdi-alert-circle'
234 | }}
235 |
236 |
237 |
238 |
239 |
240 | 配对的设备
241 |
242 |
243 | mdi-plus
244 | 配对设备
245 |
246 |
247 |
248 |
249 |
250 | {{ errorMessage }}
251 | {{ errorDetails }}
252 | 重试连接
254 |
255 | 查看帮助
257 |
258 |
259 |
260 |
261 |
262 | {{ disconnectionMessage }}
263 |
264 |
265 |
266 |
267 | mdi-cellphone-link
268 | 添加 USB 设备
269 |
270 |
271 |
272 |
273 |
279 |
280 |
281 | mdi-cellphone
282 |
283 |
284 |
285 | {{ device.name || device.serial }}
286 |
287 |
288 | {{ device.serial }}
289 |
290 |
291 |
296 | mdi-check-circle
297 |
298 |
306 | mdi-delete
307 | 移除设备
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 | 关闭
317 |
318 |
319 |
320 |
321 |
322 |
323 |
337 |
--------------------------------------------------------------------------------
/src/components/Device/StorageInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 存储和内存
5 |
6 |
7 |
8 |
9 |
10 |
{{ storageUsage.percentage }}%
11 |
12 |
19 |
20 |
21 |
{{ storageUsage.used }}GB/{{ storageUsage.total }}GB
22 |
23 |
24 |
25 |
26 |
27 |
28 |
{{ memoryUsage.percentage }}%
29 |
30 |
37 |
38 |
39 |
{{ memoryUsage.used }}GB/{{ memoryUsage.total }}GB
40 |
41 |
42 |
43 |
44 |
45 |
46 |
86 |
87 |
134 |
--------------------------------------------------------------------------------
/src/components/Device/VideoContainer.vue:
--------------------------------------------------------------------------------
1 |
254 |
255 |
256 |
272 |
273 |
274 |
323 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/adb-client.ts:
--------------------------------------------------------------------------------
1 | import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
2 | import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
3 | import { Adb, AdbDaemonTransport, type AdbPacketData } from '@yume-chan/adb';
4 | import { Consumable, ReadableStream, WritableStream } from '@yume-chan/stream-extra';
5 |
6 | export interface DeviceMeta {
7 | serial: string;
8 | connect: () => Promise<{
9 | readable: ReadableStream;
10 | writable: WritableStream>;
11 | }>;
12 | }
13 |
14 | export class AdbClient {
15 | device: Adb | undefined;
16 | serial: string | undefined;
17 | name: string | undefined;
18 | credentialStore: AdbWebCredentialStore;
19 |
20 | constructor() {
21 | this.credentialStore = new AdbWebCredentialStore('high-qa');
22 | }
23 |
24 | get isSupportedWebUsb() {
25 | return !!AdbDaemonWebUsbDeviceManager.BROWSER;
26 | }
27 |
28 | get isConnected() {
29 | return !!this.device;
30 | }
31 |
32 | get deviceName() {
33 | return this.name;
34 | }
35 |
36 | get deviceSerial() {
37 | return this.serial;
38 | }
39 |
40 | async connect(deviceMeta: DeviceMeta) {
41 | if (this.device) {
42 | await this.disconnect();
43 | }
44 | let readable: ReadableStream;
45 | let writable: WritableStream>;
46 | try {
47 | const streams = await deviceMeta.connect();
48 | readable = streams.readable;
49 | writable = streams.writable;
50 | } catch (e: any) {
51 | if (typeof e === 'object' && e !== null && 'name' in e && e.name === 'NetworkError') {
52 | throw new Error(
53 | 'Failed to connect to device. Please check if the device is connected and try again.'
54 | );
55 | }
56 | return;
57 | }
58 |
59 | this.device = new Adb(
60 | await AdbDaemonTransport.authenticate({
61 | serial: deviceMeta.serial,
62 | connection: { readable, writable },
63 | credentialStore: this.credentialStore,
64 | })
65 | );
66 |
67 | return this.device;
68 | }
69 |
70 | async disconnect() {
71 | if (!this.device) {
72 | return;
73 | }
74 | await this.device.close();
75 | this.device = undefined;
76 | }
77 |
78 | async addUsbDevice() {
79 | return await AdbDaemonWebUsbDeviceManager.BROWSER!.requestDevice();
80 | }
81 |
82 | async getUsbDeviceList() {
83 | return await AdbDaemonWebUsbDeviceManager.BROWSER!.getDevices();
84 | }
85 | }
86 |
87 | const client = new AdbClient();
88 | export default client;
89 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/file.ts:
--------------------------------------------------------------------------------
1 | import { WrapReadableStream, WritableStream, ReadableStream } from '@yume-chan/stream-extra';
2 |
3 | interface PickFileOptions {
4 | accept?: string;
5 | }
6 |
7 | export function pickFile(options: { multiple: true } & PickFileOptions): Promise {
8 | return new Promise((resolve) => {
9 | const input = document.createElement('input');
10 | input.type = 'file';
11 | input.multiple = options.multiple;
12 | if (options.accept) {
13 | input.accept = options.accept;
14 | }
15 | input.onchange = () => {
16 | if (input.files) {
17 | resolve(input.files);
18 | }
19 | };
20 | input.click();
21 | });
22 | }
23 |
24 | /**
25 | * 使用浏览器原生功能下载文件
26 | * @param buffer 文件内容
27 | * @param fileName 文件名
28 | * @param mimeType MIME类型
29 | */
30 | export function downloadFile(buffer: ArrayBuffer, fileName: string, mimeType: string): void {
31 | try {
32 | const blob = new Blob([buffer], { type: mimeType });
33 | const url = URL.createObjectURL(blob);
34 | const a = document.createElement('a');
35 | a.href = url;
36 | a.download = fileName;
37 | document.body.appendChild(a);
38 | a.click();
39 | document.body.removeChild(a);
40 | URL.revokeObjectURL(url);
41 | } catch (error) {
42 | console.error('Error downloading file:', error);
43 | throw error;
44 | }
45 | }
46 |
47 | export function createFileStream(file: File): ReadableStream {
48 | return new ReadableStream({
49 | start(controller) {
50 | const reader = new FileReader();
51 | let offset = 0;
52 |
53 | reader.onload = () => {
54 | if (reader.result instanceof ArrayBuffer) {
55 | controller.enqueue(new Uint8Array(reader.result));
56 | offset += reader.result.byteLength;
57 | }
58 |
59 | if (offset >= file.size) {
60 | controller.close();
61 | } else {
62 | readNextChunk();
63 | }
64 | };
65 |
66 | reader.onerror = () => {
67 | controller.error(reader.error);
68 | };
69 |
70 | function readNextChunk() {
71 | const chunk = file.slice(offset, offset + 64 * 1024);
72 | reader.readAsArrayBuffer(chunk);
73 | }
74 |
75 | readNextChunk();
76 | },
77 | });
78 | }
79 |
80 | export class WrapConsumableStream {
81 | readable: ReadableStream;
82 | writable: WritableStream;
83 |
84 | constructor() {
85 | let controller: ReadableStreamDefaultController;
86 | this.readable = new ReadableStream({
87 | start(c) {
88 | controller = c;
89 | },
90 | });
91 |
92 | this.writable = new WritableStream({
93 | write(chunk) {
94 | controller.enqueue(chunk);
95 | },
96 | close() {
97 | controller.close();
98 | },
99 | });
100 | }
101 | }
102 |
103 | export class ProgressStream {
104 | readable: ReadableStream;
105 | writable: WritableStream;
106 |
107 | constructor(onProgress: (uploaded: number) => void) {
108 | let uploaded = 0;
109 | let controller: ReadableStreamDefaultController;
110 |
111 | this.readable = new ReadableStream({
112 | start(c) {
113 | controller = c;
114 | },
115 | });
116 |
117 | this.writable = new WritableStream({
118 | write(chunk) {
119 | uploaded += chunk.byteLength;
120 | onProgress(uploaded);
121 | controller.enqueue(chunk);
122 | },
123 | close() {
124 | controller.close();
125 | },
126 | });
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/index.ts:
--------------------------------------------------------------------------------
1 | export * from './scrcpy-state';
--------------------------------------------------------------------------------
/src/components/Scrcpy/input.ts:
--------------------------------------------------------------------------------
1 | import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
2 | // import { AoaHidDevice, HidKeyCode, HidKeyboard } from '@yume-chan/aoa';
3 | import { type Disposable } from '@yume-chan/event';
4 | import { AndroidKeyCode, AndroidKeyEventAction, AndroidKeyEventMeta } from '@yume-chan/scrcpy';
5 |
6 | export interface KeyboardInjector extends Disposable {
7 | down(key: string): Promise;
8 |
9 | up(key: string): Promise;
10 |
11 | reset(): Promise;
12 | }
13 |
14 | export class ScrcpyKeyboardInjector implements KeyboardInjector {
15 | private readonly client: AdbScrcpyClient;
16 |
17 | private _controlLeft = false;
18 | private _controlRight = false;
19 | private _shiftLeft = false;
20 | private _shiftRight = false;
21 | private _altLeft = false;
22 | private _altRight = false;
23 | private _metaLeft = false;
24 | private _metaRight = false;
25 |
26 | private _capsLock = false;
27 | private _numLock = true;
28 |
29 | private _keys: Set = new Set();
30 |
31 | public constructor(client: AdbScrcpyClient) {
32 | this.client = client;
33 | }
34 |
35 | private setModifier(keyCode: AndroidKeyCode, value: boolean) {
36 | switch (keyCode) {
37 | case AndroidKeyCode.ControlLeft:
38 | this._controlLeft = value;
39 | break;
40 | case AndroidKeyCode.ControlRight:
41 | this._controlRight = value;
42 | break;
43 | case AndroidKeyCode.ShiftLeft:
44 | this._shiftLeft = value;
45 | break;
46 | case AndroidKeyCode.ShiftRight:
47 | this._shiftRight = value;
48 | break;
49 | case AndroidKeyCode.AltLeft:
50 | this._altLeft = value;
51 | break;
52 | case AndroidKeyCode.AltRight:
53 | this._altRight = value;
54 | break;
55 | case AndroidKeyCode.MetaLeft:
56 | this._metaLeft = value;
57 | break;
58 | case AndroidKeyCode.MetaRight:
59 | this._metaRight = value;
60 | break;
61 | case AndroidKeyCode.CapsLock:
62 | if (value) {
63 | this._capsLock = !this._capsLock;
64 | }
65 | break;
66 | case AndroidKeyCode.NumLock:
67 | if (value) {
68 | this._numLock = !this._numLock;
69 | }
70 | break;
71 | }
72 | }
73 |
74 | private getMetaState(): AndroidKeyEventMeta {
75 | let metaState = 0;
76 | if (this._altLeft) {
77 | metaState |= AndroidKeyEventMeta.AltOn | AndroidKeyEventMeta.AltLeftOn;
78 | }
79 | if (this._altRight) {
80 | metaState |= AndroidKeyEventMeta.AltOn | AndroidKeyEventMeta.AltRightOn;
81 | }
82 | if (this._shiftLeft) {
83 | metaState |= AndroidKeyEventMeta.ShiftOn | AndroidKeyEventMeta.ShiftLeftOn;
84 | }
85 | if (this._shiftRight) {
86 | metaState |= AndroidKeyEventMeta.ShiftOn | AndroidKeyEventMeta.ShiftRightOn;
87 | }
88 | if (this._controlLeft) {
89 | metaState |= AndroidKeyEventMeta.CtrlOn | AndroidKeyEventMeta.CtrlLeftOn;
90 | }
91 | if (this._controlRight) {
92 | metaState |= AndroidKeyEventMeta.CtrlOn | AndroidKeyEventMeta.CtrlRightOn;
93 | }
94 | if (this._metaLeft) {
95 | metaState |= AndroidKeyEventMeta.MetaOn | AndroidKeyEventMeta.MetaLeftOn;
96 | }
97 | if (this._metaRight) {
98 | metaState |= AndroidKeyEventMeta.MetaOn | AndroidKeyEventMeta.MetaRightOn;
99 | }
100 | if (this._capsLock) {
101 | metaState |= AndroidKeyEventMeta.CapsLockOn;
102 | }
103 | if (this._numLock) {
104 | metaState |= AndroidKeyEventMeta.NumLockOn;
105 | }
106 | return metaState;
107 | }
108 |
109 | public async down(key: string): Promise {
110 | const keyCode = AndroidKeyCode[key as keyof typeof AndroidKeyCode];
111 | if (!keyCode) {
112 | return;
113 | }
114 |
115 | this.setModifier(keyCode, true);
116 | this._keys.add(keyCode);
117 | await this.client.controller?.injectKeyCode({
118 | action: AndroidKeyEventAction.Down,
119 | keyCode,
120 | metaState: this.getMetaState(),
121 | repeat: 0,
122 | });
123 | }
124 |
125 | public async up(key: string): Promise {
126 | const keyCode = AndroidKeyCode[key as keyof typeof AndroidKeyCode];
127 | if (!keyCode) {
128 | return;
129 | }
130 |
131 | this.setModifier(keyCode, false);
132 | this._keys.delete(keyCode);
133 | await this.client.controller?.injectKeyCode({
134 | action: AndroidKeyEventAction.Up,
135 | keyCode,
136 | metaState: this.getMetaState(),
137 | repeat: 0,
138 | });
139 | }
140 |
141 | public async reset(): Promise {
142 | this._controlLeft = false;
143 | this._controlRight = false;
144 | this._shiftLeft = false;
145 | this._shiftRight = false;
146 | this._altLeft = false;
147 | this._altRight = false;
148 | this._metaLeft = false;
149 | this._metaRight = false;
150 | for (const key of this._keys) {
151 | this.up(AndroidKeyCode[key]);
152 | }
153 | this._keys.clear();
154 | }
155 |
156 | public dispose(): void {
157 | // do nothing
158 | }
159 | }
160 | // export class AoaKeyboardInjector implements KeyboardInjector {
161 | // // eslint-disable-next-line no-undef
162 | // public static async register(device: USBDevice): Promise {
163 | // const keyboard = await AoaHidDevice.register(device, 0, HidKeyboard.DESCRIPTOR);
164 | // return new AoaKeyboardInjector(keyboard);
165 | // }
166 | //
167 | // private readonly aoaKeyboard: AoaHidDevice;
168 | // private readonly hidKeyboard: HidKeyboard = new HidKeyboard();
169 | //
170 | // public constructor(aoaKeyboard: AoaHidDevice) {
171 | // this.aoaKeyboard = aoaKeyboard;
172 | // }
173 | //
174 | // public async down(key: string): Promise {
175 | // const keyCode = HidKeyCode[key as keyof typeof HidKeyCode];
176 | // if (!keyCode) {
177 | // return;
178 | // }
179 | //
180 | // this.hidKeyboard.down(keyCode);
181 | // }
182 | //
183 | // public async up(key: string): Promise {
184 | // const keyCode = HidKeyCode[key as keyof typeof HidKeyCode];
185 | // if (!keyCode) {
186 | // return;
187 | // }
188 | //
189 | // this.hidKeyboard.up(keyCode);
190 | // }
191 | //
192 | // public async reset(): Promise {
193 | // this.hidKeyboard.reset();
194 | // }
195 | //
196 | // public async dispose(): Promise {
197 | // await this.aoaKeyboard.unregister();
198 | // }
199 | // }
200 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/matroska.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type H265NaluRaw,
3 | type ScrcpyMediaStreamPacket,
4 | type ScrcpyVideoStreamMetadata,
5 | ScrcpyAudioCodec,
6 | ScrcpyVideoCodecId,
7 | annexBSplitNalu,
8 | h264SearchConfiguration,
9 | h265ParseSequenceParameterSet,
10 | h265ParseVideoParameterSet,
11 | h265SearchConfiguration,
12 | } from '@yume-chan/scrcpy';
13 | import { ArrayBufferTarget, Muxer as WebMMuxer } from 'webm-muxer';
14 | import { downloadFile } from './file';
15 |
16 | // 定义数据包类型
17 | type ScrcpyDataPacket = Extract;
18 |
19 | // Helper functions
20 | function h264ConfigurationToAvcDecoderConfigurationRecord(
21 | sequenceParameterSet: Uint8Array,
22 | pictureParameterSet: Uint8Array
23 | ): Uint8Array {
24 | const buffer = new Uint8Array(
25 | 11 + sequenceParameterSet.byteLength + pictureParameterSet.byteLength
26 | );
27 | buffer[0] = 1;
28 | buffer[1] = sequenceParameterSet[1];
29 | buffer[2] = sequenceParameterSet[2];
30 | buffer[3] = sequenceParameterSet[3];
31 | buffer[4] = 0xff;
32 | buffer[5] = 0xe1;
33 | buffer[6] = sequenceParameterSet.byteLength >> 8;
34 | buffer[7] = sequenceParameterSet.byteLength & 0xff;
35 | buffer.set(sequenceParameterSet, 8);
36 | buffer[8 + sequenceParameterSet.byteLength] = 1;
37 | buffer[9 + sequenceParameterSet.byteLength] = pictureParameterSet.byteLength >> 8;
38 | buffer[10 + sequenceParameterSet.byteLength] = pictureParameterSet.byteLength & 0xff;
39 | buffer.set(pictureParameterSet, 11 + sequenceParameterSet.byteLength);
40 | return buffer;
41 | }
42 |
43 | function h265ConfigurationToHevcDecoderConfigurationRecord(
44 | videoParameterSet: H265NaluRaw,
45 | sequenceParameterSet: H265NaluRaw,
46 | pictureParameterSet: H265NaluRaw
47 | ): Uint8Array {
48 | const {
49 | profileTierLevel: {
50 | generalProfileTier: {
51 | profile_space: general_profile_space,
52 | tier_flag: general_tier_flag,
53 | profile_idc: general_profile_idc,
54 | profileCompatibilitySet: generalProfileCompatibilitySet,
55 | constraintSet: generalConstraintSet,
56 | },
57 | general_level_idc,
58 | },
59 | vps_max_layers_minus1,
60 | vps_temporal_id_nesting_flag,
61 | } = h265ParseVideoParameterSet(videoParameterSet.rbsp);
62 |
63 | const {
64 | chroma_format_idc,
65 | bit_depth_luma_minus8,
66 | bit_depth_chroma_minus8,
67 | vuiParameters: { min_spatial_segmentation_idc = 0 } = {},
68 | } = h265ParseSequenceParameterSet(sequenceParameterSet.rbsp);
69 |
70 | const buffer = new Uint8Array(
71 | 23 +
72 | 5 * 3 +
73 | videoParameterSet.data.length +
74 | sequenceParameterSet.data.length +
75 | pictureParameterSet.data.length
76 | );
77 |
78 | buffer[0] = 1;
79 | buffer[1] =
80 | (general_profile_space << 6) | (Number(general_tier_flag) << 5) | general_profile_idc;
81 | buffer.set(generalProfileCompatibilitySet, 2);
82 | buffer.set(generalConstraintSet, 6);
83 | buffer[12] = general_level_idc;
84 | buffer[13] = 0xf0 | (min_spatial_segmentation_idc >> 8);
85 | buffer[14] = min_spatial_segmentation_idc;
86 | buffer[15] = 0xfc;
87 | buffer[16] = 0xfc | chroma_format_idc;
88 | buffer[17] = 0xf8 | bit_depth_luma_minus8;
89 | buffer[18] = 0xf8 | bit_depth_chroma_minus8;
90 | buffer[19] = 0;
91 | buffer[20] = 0;
92 | buffer[21] =
93 | ((vps_max_layers_minus1 + 1) << 3) | (Number(vps_temporal_id_nesting_flag) << 2) | 3;
94 | buffer[22] = 3;
95 |
96 | let i = 23;
97 | for (const nalu of [videoParameterSet, sequenceParameterSet, pictureParameterSet]) {
98 | buffer[i] = nalu.nal_unit_type;
99 | i += 1;
100 | buffer[i] = 0;
101 | i += 1;
102 | buffer[i] = 1;
103 | i += 1;
104 | buffer[i] = nalu.data.length >> 8;
105 | i += 1;
106 | buffer[i] = nalu.data.length;
107 | i += 1;
108 | buffer.set(nalu.data, i);
109 | i += nalu.data.length;
110 | }
111 |
112 | return buffer;
113 | }
114 |
115 | function h264StreamToAvcSample(buffer: Uint8Array): Uint8Array {
116 | const nalUnits: Uint8Array[] = [];
117 | let totalLength = 0;
118 |
119 | for (const unit of annexBSplitNalu(buffer)) {
120 | nalUnits.push(unit);
121 | totalLength += unit.byteLength + 4;
122 | }
123 |
124 | const sample = new Uint8Array(totalLength);
125 | let offset = 0;
126 | for (const nalu of nalUnits) {
127 | sample[offset] = nalu.byteLength >> 24;
128 | sample[offset + 1] = nalu.byteLength >> 16;
129 | sample[offset + 2] = nalu.byteLength >> 8;
130 | sample[offset + 3] = nalu.byteLength & 0xff;
131 | sample.set(nalu, offset + 4);
132 | offset += 4 + nalu.byteLength;
133 | }
134 | return sample;
135 | }
136 |
137 | const MatroskaVideoCodecNameMap: Record = {
138 | [ScrcpyVideoCodecId.H264]: 'V_MPEG4/ISO/AVC',
139 | [ScrcpyVideoCodecId.H265]: 'V_MPEGH/ISO/HEVC',
140 | [ScrcpyVideoCodecId.AV1]: 'V_AV1',
141 | };
142 |
143 | const MatroskaAudioCodecNameMap: Record = {
144 | [ScrcpyAudioCodec.RAW.mimeType]: 'A_PCM/INT/LIT',
145 | [ScrcpyAudioCodec.AAC.mimeType]: 'A_AAC',
146 | [ScrcpyAudioCodec.OPUS.mimeType]: 'A_OPUS',
147 | };
148 |
149 | export class MatroskaMuxingRecorder {
150 | public running = false;
151 | public videoMetadata: ScrcpyVideoStreamMetadata | undefined;
152 | public audioCodec: ScrcpyAudioCodec | undefined;
153 |
154 | private muxer: WebMMuxer | undefined;
155 | private videoCodecDescription: Uint8Array | undefined;
156 | private configurationWritten = false;
157 | private _firstTimestamp = -1;
158 | private _packetsFromLastKeyframe: {
159 | type: 'video' | 'audio';
160 | packet: ScrcpyDataPacket;
161 | }[] = [];
162 |
163 | private addVideoChunk(packet: ScrcpyDataPacket) {
164 | if (this._firstTimestamp === -1) {
165 | this._firstTimestamp = Number(packet.pts!);
166 | }
167 |
168 | const sample = h264StreamToAvcSample(packet.data);
169 | this.muxer!.addVideoChunkRaw(
170 | sample,
171 | packet.keyframe ? 'key' : 'delta',
172 | Number(packet.pts) - this._firstTimestamp,
173 | this.configurationWritten
174 | ? undefined
175 | : { decoderConfig: { codec: '', description: this.videoCodecDescription } }
176 | );
177 | this.configurationWritten = true;
178 | }
179 |
180 | public addVideoPacket(packet: ScrcpyMediaStreamPacket) {
181 | if (!this.videoMetadata) {
182 | throw new Error('videoMetadata must be set');
183 | }
184 |
185 | try {
186 | if (packet.type === 'configuration') {
187 | switch (this.videoMetadata.codec) {
188 | case ScrcpyVideoCodecId.H264: {
189 | const { sequenceParameterSet, pictureParameterSet } =
190 | h264SearchConfiguration(packet.data);
191 | this.videoCodecDescription =
192 | h264ConfigurationToAvcDecoderConfigurationRecord(
193 | sequenceParameterSet,
194 | pictureParameterSet
195 | );
196 | this.configurationWritten = false;
197 | break;
198 | }
199 | case ScrcpyVideoCodecId.H265: {
200 | const { videoParameterSet, sequenceParameterSet, pictureParameterSet } =
201 | h265SearchConfiguration(packet.data);
202 | this.videoCodecDescription =
203 | h265ConfigurationToHevcDecoderConfigurationRecord(
204 | videoParameterSet,
205 | sequenceParameterSet,
206 | pictureParameterSet
207 | );
208 | this.configurationWritten = false;
209 | break;
210 | }
211 | }
212 | return;
213 | }
214 |
215 | if (packet.type === 'data') {
216 | if (packet.keyframe === true) {
217 | this._packetsFromLastKeyframe.length = 0;
218 | }
219 | this._packetsFromLastKeyframe.push({ type: 'video', packet });
220 |
221 | if (this.muxer) {
222 | this.addVideoChunk(packet);
223 | }
224 | }
225 | } catch (e) {
226 | console.error('Error processing video packet:', e);
227 | }
228 | }
229 |
230 | private addAudioChunk(chunk: ScrcpyDataPacket) {
231 | if (this._firstTimestamp === -1) {
232 | return;
233 | }
234 |
235 | const timestamp = Number(chunk.pts) - this._firstTimestamp;
236 | if (timestamp < 0) {
237 | return;
238 | }
239 |
240 | if (!this.muxer) {
241 | return;
242 | }
243 |
244 | this.muxer.addAudioChunkRaw(chunk.data, 'key', timestamp);
245 | }
246 |
247 | public addAudioPacket(packet: ScrcpyDataPacket) {
248 | this._packetsFromLastKeyframe.push({ type: 'audio', packet });
249 | this.addAudioChunk(packet);
250 | }
251 |
252 | public start() {
253 | if (!this.videoMetadata) {
254 | throw new Error('videoMetadata must be set');
255 | }
256 |
257 | try {
258 | this.running = true;
259 |
260 | const options: ConstructorParameters[0] = {
261 | target: new ArrayBufferTarget(),
262 | type: 'matroska',
263 | firstTimestampBehavior: 'permissive',
264 | video: {
265 | codec: MatroskaVideoCodecNameMap[this.videoMetadata.codec!],
266 | width: this.videoMetadata.width ?? 0,
267 | height: this.videoMetadata.height ?? 0,
268 | },
269 | };
270 |
271 | if (this.audioCodec) {
272 | options.audio = {
273 | codec: MatroskaAudioCodecNameMap[this.audioCodec.mimeType!],
274 | sampleRate: 48000,
275 | numberOfChannels: 2,
276 | bitDepth: this.audioCodec === ScrcpyAudioCodec.RAW ? 16 : undefined,
277 | };
278 | }
279 |
280 | this.muxer = new WebMMuxer(options as any);
281 |
282 | if (this._packetsFromLastKeyframe.length > 0) {
283 | for (const { type, packet } of this._packetsFromLastKeyframe) {
284 | if (type === 'video') {
285 | this.addVideoChunk(packet);
286 | } else {
287 | this.addAudioChunk(packet);
288 | }
289 | }
290 | }
291 | } catch (error) {
292 | console.error('Error starting recording:', error);
293 | this.running = false;
294 | throw error;
295 | }
296 | }
297 |
298 | public stop() {
299 | if (!this.muxer) {
300 | return;
301 | }
302 |
303 | try {
304 | this.muxer.finalize()!;
305 | const buffer = this.muxer.target.buffer;
306 | const now = new Date();
307 | const fileName = `Recording ${now.getFullYear()}-${(now.getMonth() + 1)
308 | .toString()
309 | .padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now
310 | .getHours()
311 | .toString()
312 | .padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now
313 | .getSeconds()
314 | .toString()
315 | .padStart(2, '0')}.webm`;
316 |
317 | // 使用新的下载函数
318 | downloadFile(buffer, fileName, 'video/x-matroska');
319 |
320 | this.muxer = undefined;
321 | this.configurationWritten = false;
322 | this.running = false;
323 | this._firstTimestamp = -1;
324 | this._packetsFromLastKeyframe = [];
325 | } catch (error) {
326 | console.error('Error stopping recording:', error);
327 | throw error;
328 | }
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/recorder.ts:
--------------------------------------------------------------------------------
1 | import { MatroskaMuxingRecorder } from './matroska';
2 | import type { ScrcpyMediaStreamPacket, ScrcpyVideoStreamMetadata } from '@yume-chan/scrcpy';
3 |
4 | export interface RecordingState {
5 | isRecording: boolean;
6 | currentTime: string;
7 | canRecord: boolean;
8 | }
9 |
10 | export class VideoRecorder {
11 | private recorder: MatroskaMuxingRecorder;
12 | private isRecording: boolean;
13 | private recordingTime: number;
14 | private intervalId: number | null;
15 | private stateChangeCallbacks: ((state: RecordingState) => void)[];
16 | private packetCount: number;
17 |
18 | constructor() {
19 | this.recorder = new MatroskaMuxingRecorder();
20 | this.isRecording = false;
21 | this.recordingTime = 0;
22 | this.intervalId = null;
23 | this.stateChangeCallbacks = [];
24 | this.packetCount = 0;
25 | }
26 |
27 | public get canRecord(): boolean {
28 | return this.recorder.videoMetadata !== undefined;
29 | }
30 |
31 | public get recording(): boolean {
32 | return this.isRecording;
33 | }
34 |
35 | public get currentRecordingTime(): number {
36 | return this.recordingTime;
37 | }
38 |
39 | public formatTime(seconds: number): string {
40 | const hours = Math.floor(seconds / 3600);
41 | const minutes = Math.floor((seconds % 3600) / 60);
42 | const remainingSeconds = seconds % 60;
43 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
44 | }
45 |
46 | public onStateChange(callback: (state: RecordingState) => void): () => void {
47 | this.stateChangeCallbacks.push(callback);
48 | callback(this.getCurrentState());
49 | return () => {
50 | const index = this.stateChangeCallbacks.indexOf(callback);
51 | if (index !== -1) {
52 | this.stateChangeCallbacks.splice(index, 1);
53 | }
54 | };
55 | }
56 |
57 | private notifyStateChange(): void {
58 | const state = this.getCurrentState();
59 | this.stateChangeCallbacks.forEach((callback) => {
60 | try {
61 | callback(state);
62 | } catch (error) {
63 | console.error('Error in recording state change callback:', error);
64 | }
65 | });
66 | }
67 |
68 | private getCurrentState(): RecordingState {
69 | return {
70 | isRecording: this.isRecording,
71 | currentTime: this.formatTime(this.recordingTime),
72 | canRecord: this.canRecord,
73 | };
74 | }
75 |
76 | public startRecording(): void {
77 | if (!this.canRecord) {
78 | const error = new Error('Cannot start recording: video metadata is not set');
79 | console.error(error);
80 | throw error;
81 | }
82 | if (this.isRecording) {
83 | console.warn('Recording is already in progress');
84 | return;
85 | }
86 | try {
87 | this.recorder.start();
88 | this.isRecording = true;
89 | this.packetCount = 0;
90 | this.intervalId = window.setInterval(() => {
91 | this.recordingTime++;
92 | this.notifyStateChange();
93 | }, 1000);
94 | this.notifyStateChange();
95 | console.log('Recording started successfully');
96 | } catch (error) {
97 | this.isRecording = false;
98 | this.recordingTime = 0;
99 | if (this.intervalId) {
100 | clearInterval(this.intervalId);
101 | this.intervalId = null;
102 | }
103 | console.error('Failed to start recording:', error);
104 | throw error;
105 | }
106 | }
107 |
108 | public stopRecording(): void {
109 | if (!this.isRecording) {
110 | console.warn('No recording in progress');
111 | return;
112 | }
113 | try {
114 | if (this.intervalId) {
115 | clearInterval(this.intervalId);
116 | this.intervalId = null;
117 | }
118 | this.recorder.stop();
119 | console.log(`Recording stopped. Total packets processed: ${this.packetCount}`);
120 | this.isRecording = false;
121 | this.recordingTime = 0;
122 | this.packetCount = 0;
123 | this.notifyStateChange();
124 | } catch (error) {
125 | console.error('Error stopping recording:', error);
126 | this.isRecording = false;
127 | this.recordingTime = 0;
128 | this.packetCount = 0;
129 | this.notifyStateChange();
130 | throw error;
131 | }
132 | }
133 |
134 | public toggleRecording(): void {
135 | try {
136 | if (this.isRecording) {
137 | this.stopRecording();
138 | } else {
139 | this.startRecording();
140 | }
141 | } catch (error) {
142 | console.error('Error toggling recording:', error);
143 | throw error;
144 | }
145 | }
146 |
147 | public addVideoPacket(packet: ScrcpyMediaStreamPacket): void {
148 | if (!packet) {
149 | console.warn('Received empty video packet');
150 | return;
151 | }
152 | if (!this.isRecording) {
153 | return;
154 | }
155 | try {
156 | this.recorder.addVideoPacket(packet);
157 | this.packetCount++;
158 | if (this.packetCount % 100 === 0) {
159 | console.log(`Processed ${this.packetCount} video packets`);
160 | }
161 | } catch (error) {
162 | console.error('Error adding video packet:', error);
163 | }
164 | }
165 |
166 | public setVideoMetadata(metadata: ScrcpyVideoStreamMetadata): void {
167 | if (!metadata) {
168 | console.warn('Received empty video metadata');
169 | return;
170 | }
171 | try {
172 | this.recorder.videoMetadata = metadata;
173 | this.notifyStateChange();
174 | console.log('Video metadata set successfully:', metadata);
175 | } catch (error) {
176 | console.error('Error setting video metadata:', error);
177 | throw error;
178 | }
179 | }
180 |
181 | public dispose(): void {
182 | try {
183 | if (this.isRecording) {
184 | this.stopRecording();
185 | }
186 | this.stateChangeCallbacks = [];
187 | console.log('Recorder disposed successfully');
188 | } catch (error) {
189 | console.error('Error disposing recorder:', error);
190 | this.stateChangeCallbacks = [];
191 | }
192 | }
193 | }
194 |
195 | // 创建单例实例
196 | const recorder = new VideoRecorder();
197 | export default recorder;
198 |
--------------------------------------------------------------------------------
/src/components/Scrcpy/scrcpy-state.ts:
--------------------------------------------------------------------------------
1 | // 导入外部依赖
2 | import { AdbDaemonWebUsbDevice } from '@yume-chan/adb-daemon-webusb';
3 | import { AdbScrcpyClient, AdbScrcpyOptionsLatest } from '@yume-chan/adb-scrcpy';
4 | import { VERSION } from '@yume-chan/fetch-scrcpy-server';
5 | import { PcmPlayer } from '@yume-chan/pcm-player';
6 | import {
7 | clamp,
8 | CodecOptions,
9 | h264ParseConfiguration,
10 | ScrcpyHoverHelper,
11 | ScrcpyInstanceId,
12 | ScrcpyLogLevel,
13 | ScrcpyOptionsLatest,
14 | ScrcpyVideoCodecId,
15 | ScrcpyVideoOrientation,
16 | DEFAULT_SERVER_PATH,
17 | } from '@yume-chan/scrcpy';
18 | import type {
19 | ScrcpyMediaStreamPacket,
20 | ScrcpyMediaStreamConfigurationPacket,
21 | ScrcpyMediaStreamDataPacket,
22 | } from '@yume-chan/scrcpy';
23 | import { Consumable, InspectStream, ReadableStream, WritableStream } from '@yume-chan/stream-extra';
24 | import { WebCodecsVideoDecoder } from '@yume-chan/scrcpy-decoder-webcodecs';
25 |
26 | // 导入本地依赖
27 | import { ScrcpyKeyboardInjector } from './input';
28 | import recorder from './recorder';
29 |
30 | // @ts-ignore
31 | import SCRCPY_SERVER_BIN from '../../../public/scrcpy-server-v2.6.1?binary';
32 |
33 | // 类型定义
34 | type RotationListener = (rotation: number, prevRotation: number) => void;
35 |
36 | // 常量定义
37 | const DEFAULT_VIDEO_CODEC = 'h264';
38 | const DEFAULT_MAX_SIZE = 1920;
39 | const DEFAULT_DISPLAY_ID = 0;
40 | const DEFAULT_POWER_ON = true;
41 | const DEFAULT_BORDER_WIDTH = 6;
42 | const DEFAULT_FPS = 30;
43 | const DEFAULT_BITRATE = 8000000;
44 |
45 | export class ScrcpyState {
46 | // 基本状态
47 | running = false;
48 | fullScreenContainer: HTMLDivElement | null = null;
49 | rendererContainer: HTMLDivElement | null = null;
50 | canvas?: HTMLCanvasElement;
51 | isFullScreen = false;
52 | width = 0;
53 | height = 0;
54 | private _rotation = 0;
55 | private rotationListeners: RotationListener[] = [];
56 | // 解码器和视频相关
57 | decoder: WebCodecsVideoDecoder | undefined = undefined;
58 | videoCodec: 'h264' | 'h265' = DEFAULT_VIDEO_CODEC;
59 | videoBitRate = DEFAULT_BITRATE;
60 | maxSize = DEFAULT_MAX_SIZE;
61 | maxFps = DEFAULT_FPS;
62 | lockVideoOrientation = ScrcpyVideoOrientation.Unlocked;
63 | displayId = DEFAULT_DISPLAY_ID;
64 | powerOn = DEFAULT_POWER_ON;
65 |
66 | // 设备和连接相关
67 | device: AdbDaemonWebUsbDevice | undefined = undefined;
68 | scrcpy: AdbScrcpyClient | undefined = undefined;
69 | hoverHelper: ScrcpyHoverHelper | undefined = undefined;
70 | keyboard: ScrcpyKeyboardInjector | undefined = undefined;
71 | audioPlayer: PcmPlayer | undefined = undefined;
72 |
73 | // 性能指标
74 | fps = '0';
75 | bitRatesCount = 0;
76 | connecting = false;
77 |
78 | constructor() {
79 | // 添加默认的旋转监听器
80 | this.addRotationListener((rotation: number, prevRotation: number) => {
81 | console.log(`屏幕旋转从 ${prevRotation} 变为 ${rotation}`);
82 | });
83 | }
84 |
85 | // 旋转相关方法
86 | get rotation(): number {
87 | return this._rotation;
88 | }
89 |
90 | set rotation(value: number) {
91 | if (this._rotation !== value) {
92 | const prevRotation = this._rotation;
93 | this._rotation = value;
94 | // 通知所有监听器
95 | this.rotationListeners.forEach((listener) => {
96 | try {
97 | listener(value, prevRotation);
98 | } catch (error) {
99 | console.error('旋转监听器出错:', error);
100 | }
101 | });
102 | // 触发视频容器重新调整大小
103 | this.updateVideoContainer();
104 | }
105 | }
106 |
107 | get rotatedWidth(): number {
108 | return this.rotation & 1 ? this.height : this.width;
109 | }
110 |
111 | get rotatedHeight(): number {
112 | return this.rotation & 1 ? this.width : this.height;
113 | }
114 |
115 | // 添加旋转监听器
116 | addRotationListener(listener: RotationListener): void {
117 | this.rotationListeners.push(listener);
118 | }
119 |
120 | // 移除旋转监听器
121 | removeRotationListener(listener: RotationListener): void {
122 | const index = this.rotationListeners.indexOf(listener);
123 | if (index !== -1) {
124 | this.rotationListeners.splice(index, 1);
125 | }
126 | }
127 |
128 | // 更新视频容器
129 | updateVideoContainer(): void {
130 | if (!this.canvas || !this.rendererContainer) {
131 | return;
132 | }
133 |
134 | const containerRect = this.rendererContainer.getBoundingClientRect();
135 | const containerWidth = containerRect.width;
136 | const containerHeight = containerRect.height;
137 |
138 | if (
139 | containerWidth === 0 ||
140 | containerHeight === 0 ||
141 | this.width === 0 ||
142 | this.height === 0
143 | ) {
144 | return;
145 | }
146 |
147 | const containerAspectRatio = containerWidth / containerHeight;
148 | const videoAspectRatio = this.width / this.height;
149 |
150 | let width: number;
151 | let height: number;
152 |
153 | // 计算实际视频尺寸,考虑边框宽度
154 | if (containerAspectRatio > videoAspectRatio) {
155 | // 以高度为基准
156 | height = containerHeight - DEFAULT_BORDER_WIDTH;
157 | width = height * videoAspectRatio;
158 | } else {
159 | // 以宽度为基准
160 | width = containerWidth - DEFAULT_BORDER_WIDTH;
161 | height = width / videoAspectRatio;
162 | }
163 |
164 | // 设置视频尺寸
165 | this.canvas.style.width = `${width}px`;
166 | this.canvas.style.height = `${height}px`;
167 |
168 | // 设置变换原点和位置
169 | this.canvas.style.transformOrigin = 'center';
170 | this.canvas.style.position = 'absolute';
171 | this.canvas.style.left = '50%';
172 | this.canvas.style.top = '50%';
173 | this.canvas.style.backgroundColor = 'transparent';
174 |
175 | // 根据旋转角度设置变换
176 | let transform = 'translate(-50%, -50%)';
177 | switch (this.rotation) {
178 | case 1: // 90度
179 | transform += ' rotate(90deg)';
180 | // 交换宽高
181 | [this.canvas.style.width, this.canvas.style.height] = [`${height}px`, `${width}px`];
182 | break;
183 | case 2: // 180度
184 | transform += ' rotate(180deg)';
185 | break;
186 | case 3: // 270度
187 | transform += ' rotate(270deg)';
188 | // 交换宽高
189 | [this.canvas.style.width, this.canvas.style.height] = [`${height}px`, `${width}px`];
190 | break;
191 | }
192 | this.canvas.style.transform = transform;
193 |
194 | // 设置其他样式
195 | this.canvas.style.maxWidth = '100%';
196 | this.canvas.style.maxHeight = '100%';
197 | this.canvas.style.objectFit = 'contain';
198 | this.canvas.style.pointerEvents = 'auto';
199 | }
200 |
201 | // 服务器相关方法
202 | async pushServer(): Promise {
203 | if (!this.device) {
204 | console.error('设备不可用');
205 | return;
206 | }
207 |
208 | try {
209 | console.log('开始推送服务器...', new Uint8Array(SCRCPY_SERVER_BIN).length);
210 | const stream = new ReadableStream>({
211 | start(controller) {
212 | controller.enqueue(new Consumable(new Uint8Array(SCRCPY_SERVER_BIN)));
213 | controller.close();
214 | },
215 | });
216 |
217 | await AdbScrcpyClient.pushServer(this.device as any, stream);
218 | } catch (error) {
219 | console.error('推送服务器失败:', error);
220 | }
221 | }
222 |
223 | // 数据包类型检查
224 | private isConfigurationPacket(
225 | packet: ScrcpyMediaStreamPacket
226 | ): packet is ScrcpyMediaStreamConfigurationPacket {
227 | return packet.type === 'configuration';
228 | }
229 |
230 | private isDataPacket(packet: ScrcpyMediaStreamPacket): packet is ScrcpyMediaStreamDataPacket {
231 | return packet.type === 'data';
232 | }
233 |
234 | // 启动方法
235 | async start(device: AdbDaemonWebUsbDevice) {
236 | if (!device || this.rendererContainer === undefined) {
237 | throw new Error('无效的参数');
238 | }
239 | this.device = device;
240 | try {
241 | if (!this.decoder) {
242 | throw new Error('没有可用的解码器');
243 | }
244 | this.connecting = true;
245 | await this.pushServer();
246 | const videoCodecOptions = new CodecOptions();
247 | const options = new AdbScrcpyOptionsLatest(
248 | new ScrcpyOptionsLatest({
249 | maxSize: this.maxSize,
250 | videoBitRate: this.videoBitRate,
251 | videoCodec: this.videoCodec,
252 | maxFps: this.maxFps,
253 | lockVideoOrientation: this.lockVideoOrientation,
254 | displayId: this.displayId,
255 | powerOn: this.powerOn,
256 | audio: false, // 禁用音频
257 | logLevel: ScrcpyLogLevel.Debug,
258 | scid: ScrcpyInstanceId.random(),
259 | sendDeviceMeta: false,
260 | sendDummyByte: false,
261 | videoCodecOptions,
262 | })
263 | );
264 |
265 | this.scrcpy = await AdbScrcpyClient.start(
266 | this.device as any,
267 | DEFAULT_SERVER_PATH,
268 | VERSION,
269 | options
270 | );
271 |
272 | if (!this.scrcpy) {
273 | throw new Error('启动 scrcpy 客户端失败');
274 | }
275 |
276 | this.scrcpy.stdout.pipeTo(
277 | new WritableStream({
278 | write(chunk) {
279 | console.log(`[服务器] ${chunk}`);
280 | },
281 | })
282 | );
283 |
284 | if (this.scrcpy.videoStream) {
285 | const videoStream = await this.scrcpy.videoStream;
286 | if (!videoStream) {
287 | throw new Error('获取视频流失败');
288 | }
289 | const { metadata: videoMetadata, stream: videoPacketStream } = videoStream;
290 | // 初始化视频大小
291 | this.width = videoMetadata.width ?? 0;
292 | this.height = videoMetadata.height ?? 0;
293 | this.rotation = 0; // 初始化为0,后续通过元数据更新
294 |
295 | // 设置录制器的视频元数据
296 | recorder.setVideoMetadata(videoMetadata);
297 |
298 | if (this.decoder && videoPacketStream) {
299 | videoPacketStream
300 | .pipeThrough(
301 | new InspectStream((packet: ScrcpyMediaStreamPacket) => {
302 | // 将数据包传递给录制器
303 | recorder.addVideoPacket(packet);
304 | try {
305 | if (this.isConfigurationPacket(packet)) {
306 | try {
307 | const { croppedWidth, croppedHeight } =
308 | h264ParseConfiguration(packet.data);
309 | if (croppedWidth > 0 && croppedHeight > 0) {
310 | this.width = croppedWidth;
311 | this.height = croppedHeight;
312 | // 更新视频容器大小
313 | this.updateVideoContainer();
314 | }
315 | } catch (error) {
316 | console.error('解析配置出错:', error);
317 | }
318 | } else if (this.isDataPacket(packet)) {
319 | // 更新屏幕旋转状态
320 | const metadata = packet.data;
321 | if (
322 | metadata &&
323 | typeof metadata === 'object' &&
324 | 'rotation' in metadata
325 | ) {
326 | const rotation = (metadata as { rotation: number })
327 | .rotation;
328 | if (
329 | typeof rotation === 'number' &&
330 | rotation >= 0 &&
331 | rotation <= 3
332 | ) {
333 | this.rotation = rotation;
334 | }
335 | }
336 | if (packet.data instanceof Uint8Array) {
337 | this.bitRatesCount += packet.data.byteLength;
338 | }
339 | }
340 | } catch (error) {
341 | console.error('处理数据包出错:', error);
342 | }
343 | })
344 | )
345 | .pipeTo(this.decoder.writable)
346 | .catch((error) => {
347 | console.error('处理数据包出错:', error);
348 | });
349 | }
350 | }
351 |
352 | this.keyboard = new ScrcpyKeyboardInjector(this.scrcpy);
353 | this.hoverHelper = new ScrcpyHoverHelper();
354 | this.scrcpy.exit.then(() => this.dispose());
355 |
356 | this.running = true;
357 | return this.scrcpy;
358 | } catch (e) {
359 | console.error(e);
360 | this.connecting = false;
361 | this.dispose();
362 | return;
363 | }
364 | }
365 |
366 | // 停止方法
367 | async stop() {
368 | // 首先请求关闭客户端
369 | await this.scrcpy?.close();
370 | this.dispose();
371 | }
372 |
373 | // 清理方法
374 | dispose(): void {
375 | // 否则一些数据包可能仍会到达解码器
376 | this.decoder?.dispose();
377 | this.decoder = undefined;
378 | this.keyboard?.dispose();
379 | this.keyboard = undefined;
380 |
381 | this.audioPlayer?.stop();
382 | this.audioPlayer = undefined;
383 |
384 | this.fps = '0';
385 |
386 | if (this.isFullScreen) {
387 | document.exitFullscreen();
388 | this.isFullScreen = false;
389 | }
390 |
391 | this.scrcpy = undefined;
392 | this.device = undefined;
393 | this.canvas = undefined;
394 | this.running = false;
395 | // 清空旋转监听器
396 | this.rotationListeners = [];
397 | }
398 |
399 | setRendererContainer(container: HTMLDivElement): void {
400 | if (this.decoder?.renderer) {
401 | console.log('渲染器容器已更改', this.decoder);
402 | this.rendererContainer = null;
403 | container.removeChild(this.decoder.renderer);
404 | }
405 |
406 | this.fullScreenContainer = container;
407 | this.rendererContainer = container;
408 |
409 | // 确保容器可以正确定位子元素
410 | container.style.position = 'relative';
411 | container.style.overflow = 'hidden';
412 | container.style.backgroundColor = 'transparent';
413 |
414 | this.decoder = new WebCodecsVideoDecoder(ScrcpyVideoCodecId.H264, false);
415 | container.appendChild(this.decoder.renderer);
416 | this.canvas = this.decoder.renderer;
417 | // 初始化视频容器
418 | this.updateVideoContainer();
419 | }
420 |
421 | getCanvas(): HTMLCanvasElement | undefined {
422 | if (!this.scrcpy) {
423 | return;
424 | }
425 | return this.canvas;
426 | }
427 |
428 | clientPositionToDevicePosition(clientX: number, clientY: number): { x: number; y: number } {
429 | const viewRect = this.canvas!.getBoundingClientRect();
430 | let pointerViewX = clamp((clientX - viewRect.x) / viewRect.width, 0, 1);
431 | let pointerViewY = clamp((clientY - viewRect.y) / viewRect.height, 0, 1);
432 |
433 | if (this.rotation & 1) {
434 | [pointerViewX, pointerViewY] = [pointerViewY, pointerViewX];
435 | }
436 | switch (this.rotation) {
437 | case 1:
438 | pointerViewY = 1 - pointerViewY;
439 | break;
440 | case 2:
441 | pointerViewX = 1 - pointerViewX;
442 | pointerViewY = 1 - pointerViewY;
443 | break;
444 | case 3:
445 | pointerViewX = 1 - pointerViewX;
446 | break;
447 | }
448 |
449 | return {
450 | x: pointerViewX * this.width,
451 | y: pointerViewY * this.height,
452 | };
453 | }
454 | }
455 |
456 | const state = new ScrcpyState();
457 | export default state;
458 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import { createVuetify } from 'vuetify'
4 | import { aliases, mdi } from 'vuetify/iconsets/mdi'
5 | import 'vuetify/styles'
6 | import '@mdi/font/css/materialdesignicons.css'
7 | import * as components from 'vuetify/components'
8 | import * as directives from 'vuetify/directives'
9 |
10 | const vuetify = createVuetify({
11 | components,
12 | directives,
13 | icons: {
14 | defaultSet: 'mdi',
15 | aliases,
16 | sets: {
17 | mdi,
18 | },
19 | },
20 | })
21 |
22 | // 创建Vue应用实例
23 | const app = createApp(App)
24 |
25 | // 使用Vuetify
26 | app.use(vuetify)
27 |
28 | // 挂载应用
29 | app.mount('#app')
30 |
--------------------------------------------------------------------------------
/src/views/AbstractList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ✨ 免费, 开源, 无广告 ✨
6 |
7 |
8 |
13 |
{{ command.text }}
14 |
15 |
20 | {{ button }}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
38 |
39 |
74 |
--------------------------------------------------------------------------------
/src/views/DeviceView.vue:
--------------------------------------------------------------------------------
1 |
198 |
199 |
200 |
201 |
202 |
203 |
209 |
213 |
214 |
215 |
216 |
217 |
224 | mdi-github
225 |
226 |
227 |
228 |
229 |
236 |
242 |
246 |
247 |
248 |
249 |
256 | 社区
257 |
258 |
259 |
260 |
261 |
262 |
263 |
268 |
269 |
270 |
271 |
276 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
298 |
299 |
306 |
315 | mdi-power
316 |
317 |
318 |
319 | {{ state.connecting ? '正在连接设备...' : '连接设备' }}
320 |
321 |
322 | {{ state.connecting ? '请稍候...' : '请确保设备已开启USB调试模式' }}
323 |
324 |
325 |
326 |
327 |
328 |
329 |
335 |
340 |
341 |
342 |
343 |
348 | {{ item.icon }}
349 | {{ item.title }}
350 |
351 |
352 |
353 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
532 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "useDefineForClassFields": true,
5 | "module": "esnext",
6 | "moduleResolution": "node",
7 | "strict": false,
8 | "jsx": "preserve",
9 | "sourceMap": true,
10 | "resolveJsonModule": true,
11 | "esModuleInterop": true,
12 | "lib": ["esnext", "dom"],
13 | "types": ["vite/client", "vue"],
14 | "skipLibCheck": true,
15 | "noImplicitAny": false
16 | },
17 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
18 | "references": [{ "path": "./tsconfig.node.json" }]
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, Plugin } from 'vite';
2 | import vue from '@vitejs/plugin-vue';
3 | import vuetify from 'vite-plugin-vuetify';
4 | import Markdown from './plugins/md-loader.js';
5 | import Binary from './plugins/binary-loader.js';
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | vue(),
11 | vuetify({ autoImport: true }),
12 | Markdown() as Plugin,
13 | Binary() as Plugin,
14 | ],
15 | base: '/web-scrcpy/', // 设置为您的 GitHub 仓库名
16 | });
17 |
--------------------------------------------------------------------------------