├── .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 | ![Project Logo](src/assets/high-qa.png) 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 | ![High Quality Screenshot](desc/image.png) 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 | 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 | 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 | 68 | 69 | -------------------------------------------------------------------------------- /src/components/Device/AppManager.vue: -------------------------------------------------------------------------------- 1 | 191 | 192 | 457 | 458 | 530 | -------------------------------------------------------------------------------- /src/components/Device/BatteryInfo.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 51 | 52 | 99 | -------------------------------------------------------------------------------- /src/components/Device/DeviceBasicInfo.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 109 | 110 | 177 | -------------------------------------------------------------------------------- /src/components/Device/DeviceGuide.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 109 | 110 | 119 | -------------------------------------------------------------------------------- /src/components/Device/DeviceInfo.vue: -------------------------------------------------------------------------------- 1 | 227 | 228 | 333 | 334 | 450 | -------------------------------------------------------------------------------- /src/components/Device/DeviceInstall.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 213 | 214 | 287 | -------------------------------------------------------------------------------- /src/components/Device/DeviceLogcat.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 350 | 351 | 446 | -------------------------------------------------------------------------------- /src/components/Device/DeviceSelectDrawer.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/Device/DeviceShell.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 118 | 119 | 137 | -------------------------------------------------------------------------------- /src/components/Device/NavigationBar.vue: -------------------------------------------------------------------------------- 1 | 294 | 295 | 388 | 389 | 418 | -------------------------------------------------------------------------------- /src/components/Device/PairedDevices.vue: -------------------------------------------------------------------------------- 1 | 207 | 208 | 322 | 323 | 337 | -------------------------------------------------------------------------------- /src/components/Device/StorageInfo.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 86 | 87 | 134 | -------------------------------------------------------------------------------- /src/components/Device/VideoContainer.vue: -------------------------------------------------------------------------------- 1 | 254 | 255 | 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 | 27 | 28 | 38 | 39 | 74 | -------------------------------------------------------------------------------- /src/views/DeviceView.vue: -------------------------------------------------------------------------------- 1 | 198 | 199 | 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 | --------------------------------------------------------------------------------