├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── assets ├── logo-32x32.ico ├── logo.ico └── logo.svg ├── docs ├── assets │ ├── logo-32x32.ico │ ├── logo.ico │ └── logo.svg ├── index.html ├── script.js └── styles.css ├── index.html ├── main.js ├── package-lock.json ├── package.json ├── preload.js └── renderer.js /.gitignore: -------------------------------------------------------------------------------- 1 | recordings 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com 2 | electron_mirror=https://npmmirror.com/mirrors/electron/ 3 | electron-builder-binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ -------------------------------------------------------------------------------- /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 | Copyright 2024 易录屏 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 易录屏 2 | 3 | 一个使用 Electron 开发的简洁高效的屏幕录制工具,能够快速录制屏幕活动并保存为视频文件。 4 | 5 | ![应用预览](./assets/logo.svg) 6 | 7 | ## 功能特点 8 | 9 | - 🎥 一键开始录制整个屏幕,多屏幕状态下可自由选择要录制屏幕 10 | - ⏱️ 实时显示录制时长 11 | - ⏯️ 支持暂停/恢复录制功能 12 | - 🕒 支持设置最大录制时长,到达时间后自动停止录制 13 | - 👁️ 录制完成后提供视频预览功能 14 | - 💻 录制开始后自动最小化窗口,不影响录制内容 15 | - 💾 支持将录制内容保存为 WebM/MP4 格式 16 | - 🖥️ 跨平台支持(Windows/macOS/Linux) 17 | - 🚀 轻量级,功能精简,启动快速,占用资源少 18 | 19 | ## 截图展示 20 | 21 | ![image](https://github.com/user-attachments/assets/f8042860-134b-43c6-b5f6-7cc3ab4679f4) 22 | 23 | ## 安装方法 24 | 25 | ### 从发布版本安装 26 | 27 | 1. 前往 [Releases](https://github.com/yourusername/yi-recorder/releases) 页面 28 | 2. 下载适合您操作系统的安装包: 29 | - **Windows**: 30 | - `易录屏-Setup-1.1.0.exe` - 安装版本 31 | - `易录屏-Portable-1.1.0.exe` - 便携版本 32 | 3. 安装版直接运行安装,便携版可以直接打开使用 33 | 34 | ### 从源码构建 35 | 36 | 1. 克隆仓库 37 | 38 | ```bash 39 | git clone https://github.com/yourusername/yi-recorder.git 40 | cd yi-recorder 41 | ``` 42 | 43 | 2. 安装依赖 44 | 45 | ```bash 46 | npm install 47 | ``` 48 | 49 | 3. 启动应用(开发模式) 50 | 51 | ```bash 52 | npm start 53 | ``` 54 | 55 | 4. 构建应用(生成安装程序和便携版) 56 | 57 | ```bash 58 | npm run build-win 59 | ``` 60 | 61 | 构建后的文件会生成在 `dist` 目录下 62 | 63 | ## 使用说明 64 | 65 | 1. 启动应用程序 66 | 2. 可选择是否录制系统声音,并设置最大录制时长(0 表示无限制,默认为 60 分钟) 67 | 3. 点击"开始录制"按钮开始录制屏幕 68 | 4. 多屏幕状态下,选择要录制的屏幕 69 | 5. 应用将在倒计时 3 秒后自动最小化,录制继续在后台进行 70 | 6. 录制过程中,可以点击任务栏中的应用图标,使用"暂停录制"按钮暂停录制,再次点击继续录制 71 | 7. 需要停止录制时,点击任务栏中的应用图标,然后点击"停止录制" 72 | 8. 录制结束后会显示视频预览,可以查看录制内容 73 | 9. 点击"保存录制",选择保存位置即可生成 MP4 格式视频文件 74 | 75 | ## 技术栈 76 | 77 | - Electron - 跨平台桌面应用框架 78 | - JavaScript - 主要编程语言 79 | - HTML/CSS - 用户界面 80 | - WebRTC - 屏幕捕获技术 81 | - FFmpeg - 视频处理 82 | 83 | ## 开发计划 84 | 85 | - [x] 支持录制视频另存为 mp4 格式 86 | - [x] 支持多屏幕选择 87 | - [x] 支持音频录制 88 | - [x] 添加暂停/恢复录制功能 89 | - [x] 添加录制预览功能 90 | - [x] 添加录制时长限制功能 91 | - [ ] 添加录制区域选择功能 92 | - [ ] 支持对录制后的视频进行二次处理 93 | 94 | ## 贡献指南 95 | 96 | 欢迎贡献代码、报告问题或提出新功能建议。请遵循以下步骤: 97 | 98 | 1. Fork 本仓库 99 | 2. 创建您的特性分支 (`git checkout -b feature/amazing-feature`) 100 | 3. 提交您的更改 (`git commit -m 'Add some amazing feature'`) 101 | 4. 推送到分支 (`git push origin feature/amazing-feature`) 102 | 5. 开启一个 Pull Request 103 | 104 | ## 开源许可 105 | 106 | 本项目采用 [Apache License 2.0](LICENSE) 许可证。 107 | 108 | ## 版权声明 109 | 110 | Copyright 2025 易录屏 111 | 112 | --- 113 | 114 | 如果您觉得这个项目有用,请给它一个星标 ⭐️! 115 | -------------------------------------------------------------------------------- /assets/logo-32x32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SignitDoc/yi-recorder/875a227b2a5db10f2d7e2867b7a614a9de46eaab/assets/logo-32x32.ico -------------------------------------------------------------------------------- /assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SignitDoc/yi-recorder/875a227b2a5db10f2d7e2867b7a614a9de46eaab/assets/logo.ico -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/logo-32x32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SignitDoc/yi-recorder/875a227b2a5db10f2d7e2867b7a614a9de46eaab/docs/assets/logo-32x32.ico -------------------------------------------------------------------------------- /docs/assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SignitDoc/yi-recorder/875a227b2a5db10f2d7e2867b7a614a9de46eaab/docs/assets/logo.ico -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 易录屏 - 简洁高效的屏幕录制工具 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 20 | 32 |
33 |
34 | 35 |
36 |
37 |
38 |

易录屏

39 |

一个使用 Electron 开发的简洁高效的屏幕录制工具

40 |

41 | 快速录制屏幕活动并保存为视频文件,轻量级,功能精简,启动快速,占用资源少 42 |

43 |
44 | 立即下载 45 | 查看源码 47 |
48 |
49 |
50 |
51 | 52 |
53 |
54 |

功能特点

55 |
56 |
57 |
58 |

一键录制

59 |

一键开始录制整个屏幕,多屏幕状态下可自由选择要录制屏幕

60 |
61 |
62 |
63 |

实时计时

64 |

实时显示录制时长,掌握录制进度

65 |
66 |
67 |
68 |

暂停/恢复

69 |

支持暂停/恢复录制功能,灵活控制录制过程

70 |
71 |
72 |
73 |

时长限制

74 |

支持设置最大录制时长,到达时间后自动停止录制

75 |
76 |
77 |
78 |

视频预览

79 |

录制完成后提供视频预览功能,即时查看录制效果

80 |
81 |
82 |
83 | 84 |
85 |

自动最小化

86 |

录制开始后自动最小化窗口,不影响录制内容

87 |
88 |
89 |
90 |

多格式保存

91 |

支持将录制内容保存为 WebM/MP4 格式

92 |
93 |
94 |
95 |

跨平台支持

96 |

支持 Windows/macOS/Linux 多平台使用

97 |
98 |
99 |
100 |
101 | 102 |
103 |
104 |

截图展示

105 |
106 | 易录屏应用截图 107 |
108 |
109 |
110 | 111 |
112 |
113 |

下载安装

114 |
115 |
116 |
117 |

Windows

118 | 124 |
125 |
126 |
127 |

macOS

128 |

即将推出

129 |
130 |
131 |
132 |

Linux

133 |

即将推出

134 |
135 |
136 |
137 |

从源码构建

138 |
139 |
git clone https://github.com/SignitDoc/yi-recorder.git
140 | cd yi-recorder
141 | npm install
142 | npm start
143 |
144 |

构建应用(生成安装程序和便携版):

145 |
146 |
npm run build-win
147 |
148 |

构建后的文件会生成在 dist 目录下

149 |
150 |
151 |
152 | 153 |
154 |
155 |

使用说明

156 |
157 |
158 |
1
159 |
160 |

启动应用程序

161 |

双击安装后的应用图标启动易录屏

162 |
163 |
164 |
165 |
2
166 |
167 |

设置录制选项

168 |

169 | 可选择是否录制系统声音,并设置最大录制时长(0 表示无限制,默认为 170 | 60 分钟) 171 |

172 |
173 |
174 |
175 |
3
176 |
177 |

开始录制

178 |

179 | 点击"开始录制"按钮开始录制屏幕,多屏幕状态下,选择要录制的屏幕 180 |

181 |
182 |
183 |
184 |
4
185 |
186 |

录制过程

187 |

应用将在倒计时 3 秒后自动最小化,录制继续在后台进行

188 |
189 |
190 |
191 |
5
192 |
193 |

暂停/继续

194 |

195 | 录制过程中,可以点击任务栏中的应用图标,使用"暂停录制"按钮暂停录制,再次点击继续录制 196 |

197 |
198 |
199 |
200 |
6
201 |
202 |

停止录制

203 |

需要停止录制时,点击任务栏中的应用图标,然后点击"停止录制"

204 |
205 |
206 |
207 |
7
208 |
209 |

预览和保存

210 |

211 | 录制结束后会显示视频预览,可以查看录制内容,点击"保存录制",选择保存位置即可生成视频文件 212 |

213 |
214 |
215 |
216 |
217 |
218 | 219 | 260 | 261 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /docs/script.js: -------------------------------------------------------------------------------- 1 | // 平滑滚动 2 | document.querySelectorAll('a[href^="#"]').forEach(anchor => { 3 | anchor.addEventListener('click', function (e) { 4 | e.preventDefault(); 5 | 6 | const targetId = this.getAttribute('href'); 7 | const targetElement = document.querySelector(targetId); 8 | 9 | if (targetElement) { 10 | window.scrollTo({ 11 | top: targetElement.offsetTop - 80, // 考虑导航栏高度 12 | behavior: 'smooth' 13 | }); 14 | } 15 | }); 16 | }); 17 | 18 | // 导航栏滚动效果 19 | window.addEventListener('scroll', function() { 20 | const header = document.querySelector('header'); 21 | if (window.scrollY > 50) { 22 | header.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.1)'; 23 | header.style.background = 'rgba(255, 255, 255, 0.95)'; 24 | } else { 25 | header.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)'; 26 | header.style.background = 'var(--white)'; 27 | } 28 | }); 29 | 30 | // 动画效果 31 | document.addEventListener('DOMContentLoaded', function() { 32 | // 检测元素是否在视口中 33 | function isInViewport(element) { 34 | const rect = element.getBoundingClientRect(); 35 | return ( 36 | rect.top <= (window.innerHeight || document.documentElement.clientHeight) && 37 | rect.bottom >= 0 38 | ); 39 | } 40 | 41 | // 添加动画类 42 | function addAnimationClass() { 43 | const elements = document.querySelectorAll('.feature-card, .step, .download-card'); 44 | 45 | elements.forEach(element => { 46 | if (isInViewport(element) && !element.classList.contains('animated')) { 47 | element.classList.add('animated'); 48 | element.style.opacity = '1'; 49 | element.style.transform = element.classList.contains('feature-card') 50 | ? 'translateY(0)' 51 | : 'translateX(0)'; 52 | } 53 | }); 54 | } 55 | 56 | // 初始化元素样式 57 | const elements = document.querySelectorAll('.feature-card, .step, .download-card'); 58 | elements.forEach(element => { 59 | element.style.opacity = '0'; 60 | element.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; 61 | 62 | if (element.classList.contains('feature-card')) { 63 | element.style.transform = 'translateY(20px)'; 64 | } else { 65 | element.style.transform = 'translateX(-20px)'; 66 | } 67 | }); 68 | 69 | // 初始检查 70 | addAnimationClass(); 71 | 72 | // 滚动时检查 73 | window.addEventListener('scroll', addAnimationClass); 74 | }); 75 | 76 | // 版本号更新 77 | document.addEventListener('DOMContentLoaded', function() { 78 | // 获取当前年份 79 | const currentYear = new Date().getFullYear(); 80 | 81 | // 更新版权信息中的年份 82 | const copyrightYear = document.querySelector('.copyright p'); 83 | if (copyrightYear) { 84 | copyrightYear.innerHTML = copyrightYear.innerHTML.replace(/\d{4}/, currentYear); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | /* 全局样式 */ 2 | :root { 3 | --primary-color: #4a86e8; 4 | --secondary-color: #34a853; 5 | --accent-color: #ea4335; 6 | --dark-color: #333333; 7 | --light-color: #f8f9fa; 8 | --gray-color: #6c757d; 9 | --light-gray: #e9ecef; 10 | --white: #ffffff; 11 | --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 12 | --transition: all 0.3s ease; 13 | } 14 | 15 | * { 16 | margin: 0; 17 | padding: 0; 18 | box-sizing: border-box; 19 | } 20 | 21 | body { 22 | font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; 23 | line-height: 1.6; 24 | color: var(--dark-color); 25 | background-color: var(--light-color); 26 | } 27 | 28 | .container { 29 | width: 100%; 30 | max-width: 1200px; 31 | margin: 0 auto; 32 | padding: 0 20px; 33 | } 34 | 35 | a { 36 | text-decoration: none; 37 | color: var(--primary-color); 38 | transition: var(--transition); 39 | } 40 | 41 | a:hover { 42 | color: var(--secondary-color); 43 | } 44 | 45 | h1, h2, h3, h4, h5, h6 { 46 | margin-bottom: 1rem; 47 | line-height: 1.2; 48 | } 49 | 50 | h2 { 51 | font-size: 2.5rem; 52 | text-align: center; 53 | margin-bottom: 2rem; 54 | position: relative; 55 | padding-bottom: 0.5rem; 56 | } 57 | 58 | h2::after { 59 | content: ''; 60 | position: absolute; 61 | bottom: 0; 62 | left: 50%; 63 | transform: translateX(-50%); 64 | width: 80px; 65 | height: 4px; 66 | background-color: var(--primary-color); 67 | border-radius: 2px; 68 | } 69 | 70 | section { 71 | padding: 80px 0; 72 | } 73 | 74 | section:nth-child(even) { 75 | background-color: var(--white); 76 | } 77 | 78 | .btn { 79 | display: inline-block; 80 | padding: 12px 24px; 81 | border-radius: 50px; 82 | font-weight: 600; 83 | text-align: center; 84 | cursor: pointer; 85 | transition: var(--transition); 86 | border: none; 87 | } 88 | 89 | .btn-primary { 90 | background-color: var(--primary-color); 91 | color: var(--white); 92 | } 93 | 94 | .btn-primary:hover { 95 | background-color: #3a76d8; 96 | color: var(--white); 97 | transform: translateY(-2px); 98 | box-shadow: var(--shadow); 99 | } 100 | 101 | .btn-secondary { 102 | background-color: var(--white); 103 | color: var(--primary-color); 104 | border: 2px solid var(--primary-color); 105 | } 106 | 107 | .btn-secondary:hover { 108 | background-color: var(--primary-color); 109 | color: var(--white); 110 | transform: translateY(-2px); 111 | box-shadow: var(--shadow); 112 | } 113 | 114 | .btn-download { 115 | background-color: var(--secondary-color); 116 | color: var(--white); 117 | margin: 5px; 118 | } 119 | 120 | .btn-download:hover { 121 | background-color: #2d9748; 122 | color: var(--white); 123 | transform: translateY(-2px); 124 | box-shadow: var(--shadow); 125 | } 126 | 127 | /* 导航栏 */ 128 | header { 129 | background-color: var(--white); 130 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 131 | position: sticky; 132 | top: 0; 133 | z-index: 1000; 134 | } 135 | 136 | header .container { 137 | display: flex; 138 | justify-content: space-between; 139 | align-items: center; 140 | padding: 15px 20px; 141 | } 142 | 143 | .logo { 144 | display: flex; 145 | align-items: center; 146 | } 147 | 148 | .logo img { 149 | height: 40px; 150 | margin-right: 10px; 151 | } 152 | 153 | .logo h1 { 154 | font-size: 1.5rem; 155 | margin-bottom: 0; 156 | color: var(--primary-color); 157 | } 158 | 159 | nav ul { 160 | display: flex; 161 | list-style: none; 162 | } 163 | 164 | nav ul li { 165 | margin-left: 20px; 166 | } 167 | 168 | nav ul li a { 169 | color: var(--dark-color); 170 | font-weight: 500; 171 | padding: 5px 10px; 172 | border-radius: 4px; 173 | } 174 | 175 | nav ul li a:hover { 176 | color: var(--primary-color); 177 | } 178 | 179 | .github-link { 180 | display: flex; 181 | align-items: center; 182 | } 183 | 184 | .github-link i { 185 | margin-right: 5px; 186 | } 187 | 188 | /* 英雄区 */ 189 | .hero { 190 | padding: 100px 0; 191 | background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); 192 | } 193 | 194 | .hero .container { 195 | display: flex; 196 | align-items: center; 197 | justify-content: space-between; 198 | } 199 | 200 | .hero-content { 201 | flex: 1; 202 | padding-right: 40px; 203 | } 204 | 205 | .hero-content h1 { 206 | font-size: 3.5rem; 207 | margin-bottom: 1rem; 208 | color: var(--primary-color); 209 | } 210 | 211 | .hero-content .subtitle { 212 | font-size: 1.5rem; 213 | margin-bottom: 1rem; 214 | color: var(--dark-color); 215 | } 216 | 217 | .hero-content .description { 218 | font-size: 1.1rem; 219 | margin-bottom: 2rem; 220 | color: var(--gray-color); 221 | } 222 | 223 | .cta-buttons { 224 | display: flex; 225 | gap: 15px; 226 | } 227 | 228 | .hero-image { 229 | flex: 1; 230 | display: flex; 231 | justify-content: center; 232 | align-items: center; 233 | } 234 | 235 | .hero-image img { 236 | max-width: 100%; 237 | height: auto; 238 | max-height: 400px; 239 | border-radius: 10px; 240 | box-shadow: var(--shadow); 241 | } 242 | 243 | /* 功能特点 */ 244 | .features-grid { 245 | display: grid; 246 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 247 | gap: 30px; 248 | margin-top: 40px; 249 | } 250 | 251 | .feature-card { 252 | background-color: var(--white); 253 | border-radius: 10px; 254 | padding: 30px; 255 | box-shadow: var(--shadow); 256 | transition: var(--transition); 257 | text-align: center; 258 | } 259 | 260 | .feature-card:hover { 261 | transform: translateY(-10px); 262 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); 263 | } 264 | 265 | .feature-icon { 266 | font-size: 2.5rem; 267 | color: var(--primary-color); 268 | margin-bottom: 20px; 269 | } 270 | 271 | .feature-card h3 { 272 | font-size: 1.5rem; 273 | margin-bottom: 15px; 274 | } 275 | 276 | /* 截图展示 */ 277 | .screenshot-container { 278 | margin-top: 40px; 279 | text-align: center; 280 | } 281 | 282 | .screenshot-container img { 283 | max-width: 100%; 284 | border-radius: 10px; 285 | box-shadow: var(--shadow); 286 | } 287 | 288 | /* 下载安装 */ 289 | .download-options { 290 | display: flex; 291 | justify-content: center; 292 | flex-wrap: wrap; 293 | gap: 30px; 294 | margin-bottom: 50px; 295 | } 296 | 297 | .download-card { 298 | background-color: var(--white); 299 | border-radius: 10px; 300 | padding: 30px; 301 | box-shadow: var(--shadow); 302 | text-align: center; 303 | width: 300px; 304 | transition: var(--transition); 305 | } 306 | 307 | .download-card:hover { 308 | transform: translateY(-5px); 309 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); 310 | } 311 | 312 | .download-icon { 313 | font-size: 3rem; 314 | color: var(--primary-color); 315 | margin-bottom: 20px; 316 | } 317 | 318 | .download-card h3 { 319 | font-size: 1.5rem; 320 | margin-bottom: 20px; 321 | } 322 | 323 | .download-links { 324 | display: flex; 325 | flex-direction: column; 326 | gap: 10px; 327 | } 328 | 329 | .coming-soon { 330 | opacity: 0.7; 331 | } 332 | 333 | .coming-soon p { 334 | font-style: italic; 335 | color: var(--gray-color); 336 | } 337 | 338 | .source-build { 339 | background-color: var(--white); 340 | border-radius: 10px; 341 | padding: 30px; 342 | box-shadow: var(--shadow); 343 | } 344 | 345 | .code-block { 346 | background-color: var(--dark-color); 347 | border-radius: 5px; 348 | padding: 15px; 349 | margin: 15px 0; 350 | overflow-x: auto; 351 | } 352 | 353 | .code-block pre { 354 | margin: 0; 355 | } 356 | 357 | .code-block code { 358 | color: var(--light-color); 359 | font-family: 'Consolas', 'Courier New', monospace; 360 | font-size: 0.9rem; 361 | } 362 | 363 | /* 使用说明 */ 364 | .usage-steps { 365 | margin-top: 40px; 366 | } 367 | 368 | .step { 369 | display: flex; 370 | margin-bottom: 30px; 371 | background-color: var(--white); 372 | border-radius: 10px; 373 | padding: 20px; 374 | box-shadow: var(--shadow); 375 | } 376 | 377 | .step-number { 378 | display: flex; 379 | justify-content: center; 380 | align-items: center; 381 | width: 50px; 382 | height: 50px; 383 | background-color: var(--primary-color); 384 | color: var(--white); 385 | border-radius: 50%; 386 | font-size: 1.5rem; 387 | font-weight: bold; 388 | margin-right: 20px; 389 | flex-shrink: 0; 390 | } 391 | 392 | .step-content h3 { 393 | margin-bottom: 10px; 394 | } 395 | 396 | /* 页脚 */ 397 | footer { 398 | background-color: var(--dark-color); 399 | color: var(--light-color); 400 | padding: 60px 0 20px; 401 | } 402 | 403 | .footer-content { 404 | display: flex; 405 | flex-wrap: wrap; 406 | justify-content: space-between; 407 | margin-bottom: 40px; 408 | } 409 | 410 | .footer-logo { 411 | display: flex; 412 | align-items: center; 413 | margin-bottom: 20px; 414 | } 415 | 416 | .footer-logo img { 417 | height: 40px; 418 | margin-right: 10px; 419 | } 420 | 421 | .footer-logo h3 { 422 | color: var(--white); 423 | margin-bottom: 0; 424 | } 425 | 426 | .footer-links, .footer-tech { 427 | margin-bottom: 20px; 428 | } 429 | 430 | .footer-links h4, .footer-tech h4 { 431 | color: var(--white); 432 | margin-bottom: 15px; 433 | } 434 | 435 | .footer-links ul, .footer-tech ul { 436 | list-style: none; 437 | } 438 | 439 | .footer-links ul li, .footer-tech ul li { 440 | margin-bottom: 10px; 441 | } 442 | 443 | .footer-links ul li a { 444 | color: var(--light-gray); 445 | } 446 | 447 | .footer-links ul li a:hover { 448 | color: var(--white); 449 | } 450 | 451 | .footer-tech ul li { 452 | color: var(--light-gray); 453 | } 454 | 455 | .copyright { 456 | text-align: center; 457 | padding-top: 20px; 458 | border-top: 1px solid rgba(255, 255, 255, 0.1); 459 | color: var(--light-gray); 460 | } 461 | 462 | .copyright a { 463 | color: var(--light-gray); 464 | } 465 | 466 | .copyright a:hover { 467 | color: var(--white); 468 | } 469 | 470 | /* 响应式设计 */ 471 | @media (max-width: 992px) { 472 | .hero .container { 473 | flex-direction: column; 474 | text-align: center; 475 | } 476 | 477 | .hero-content { 478 | padding-right: 0; 479 | margin-bottom: 40px; 480 | } 481 | 482 | .cta-buttons { 483 | justify-content: center; 484 | } 485 | } 486 | 487 | @media (max-width: 768px) { 488 | header .container { 489 | flex-direction: column; 490 | } 491 | 492 | nav ul { 493 | margin-top: 15px; 494 | flex-wrap: wrap; 495 | justify-content: center; 496 | } 497 | 498 | nav ul li { 499 | margin: 5px 10px; 500 | } 501 | 502 | .footer-content { 503 | flex-direction: column; 504 | } 505 | } 506 | 507 | @media (max-width: 576px) { 508 | .hero-content h1 { 509 | font-size: 2.5rem; 510 | } 511 | 512 | .hero-content .subtitle { 513 | font-size: 1.2rem; 514 | } 515 | 516 | .cta-buttons { 517 | flex-direction: column; 518 | gap: 10px; 519 | } 520 | 521 | .step { 522 | flex-direction: column; 523 | align-items: center; 524 | text-align: center; 525 | } 526 | 527 | .step-number { 528 | margin-right: 0; 529 | margin-bottom: 15px; 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 易录屏 7 | 296 | 297 | 298 | 299 | 300 | 310 | 311 |
312 |

易录屏

313 | 314 |
315 | 319 |
320 | 321 | 322 | 0表示无限制 323 |
324 |
325 | 326 |
327 | 328 | 329 | 330 | 331 |
332 | 333 |
00:00:00
334 | 335 |
336 | 准备就绪。点击"开始录制"按钮开始全屏录制。 337 |
338 | 339 | 340 | 344 |
345 | 346 | 347 |
348 |
349 |
350 | 351 | 352 | 353 | 354 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { 2 | app, 3 | BrowserWindow, 4 | ipcMain, 5 | Menu, 6 | dialog, 7 | desktopCapturer, 8 | } = require("electron"); 9 | const path = require("path"); 10 | const fs = require("fs"); 11 | const { exec } = require("child_process"); 12 | const os = require("os"); 13 | const ffmpegPath = require("ffmpeg-static"); 14 | 15 | // 设置控制台输出编码 16 | process.env.LANG = "zh_CN.UTF-8"; 17 | 18 | // 确保子进程使用UTF-8编码 19 | process.env.PYTHONIOENCODING = "UTF-8"; 20 | 21 | // 创建一个帮助函数来确保正确的编码输出 22 | const logWithEncoding = (message) => { 23 | if (typeof message === "string") { 24 | console.log(Buffer.from(message, "utf8").toString()); 25 | } else { 26 | console.log(message); 27 | } 28 | }; 29 | 30 | // 重写错误日志函数 31 | const errorWithEncoding = (message, error) => { 32 | if (typeof message === "string") { 33 | console.error(Buffer.from(message, "utf8").toString(), error); 34 | } else { 35 | console.error(message, error); 36 | } 37 | }; 38 | 39 | // 获取FFmpeg可执行文件路径 40 | function getFfmpegPath() { 41 | // 修复打包后的ffmpeg路径问题 42 | // 判断是开发环境还是生产环境 43 | if (app.isPackaged) { 44 | // 打包后的环境,通过process.resourcesPath找到资源目录 45 | const ffmpegName = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; 46 | // 检查resources目录是否存在ffmpeg 47 | const resourcesPath = path.join( 48 | process.resourcesPath, 49 | "ffmpeg", 50 | ffmpegName 51 | ); 52 | if (fs.existsSync(resourcesPath)) { 53 | return resourcesPath; 54 | } 55 | // 如果resources目录不存在,尝试在应用目录下查找 56 | const appPath = path.join( 57 | path.dirname(app.getAppPath()), 58 | "ffmpeg", 59 | ffmpegName 60 | ); 61 | if (fs.existsSync(appPath)) { 62 | return appPath; 63 | } 64 | logWithEncoding("使用默认ffmpeg路径: " + ffmpegPath); 65 | } 66 | return ffmpegPath; 67 | } 68 | 69 | // 隐藏菜单栏 70 | Menu.setApplicationMenu(null); 71 | 72 | let mainWindow; 73 | 74 | function createWindow() { 75 | // 检查preload脚本是否存在 76 | const preloadPath = path.join(__dirname, "preload.js"); 77 | logWithEncoding("Preload脚本路径: " + preloadPath); 78 | logWithEncoding("Preload脚本存在: " + fs.existsSync(preloadPath)); 79 | 80 | mainWindow = new BrowserWindow({ 81 | width: 600, 82 | height: 380, 83 | resizable: false, 84 | maximizable: false, 85 | fullscreenable: false, 86 | webPreferences: { 87 | preload: preloadPath, 88 | contextIsolation: true, 89 | nodeIntegration: false, 90 | sandbox: false, // 禁用沙盒以允许更多功能 91 | // 允许访问媒体设备和捕获系统音频 92 | audioCapturerEnabled: true, 93 | }, 94 | icon: path.join(__dirname, "assets/logo.ico"), 95 | }); 96 | 97 | mainWindow.loadFile("index.html"); 98 | 99 | // 开发时打开开发者工具 100 | // mainWindow.webContents.openDevTools(); 101 | 102 | mainWindow.on("closed", () => { 103 | mainWindow = null; 104 | }); 105 | } 106 | 107 | // 添加应用启动参数,允许录制系统音频 108 | app.commandLine.appendSwitch("enable-features", "WebRTCAudioCapturing"); 109 | app.commandLine.appendSwitch("enable-usermedia-screen-capturing"); 110 | 111 | app.whenReady().then(createWindow); 112 | 113 | app.on("window-all-closed", () => { 114 | if (process.platform !== "darwin") { 115 | app.quit(); 116 | } 117 | }); 118 | 119 | app.on("activate", () => { 120 | if (mainWindow === null) { 121 | createWindow(); 122 | } 123 | }); 124 | 125 | // 处理获取屏幕源 126 | ipcMain.handle("get-sources", async () => { 127 | try { 128 | const { screen } = require("electron"); 129 | const displays = screen.getAllDisplays(); 130 | const sources = await desktopCapturer.getSources({ 131 | types: ["screen"], 132 | thumbnailSize: { width: 100, height: 100 }, 133 | }); 134 | 135 | sources.forEach((source, index) => { 136 | source.displaySize = `${displays[index].size.width}x${displays[index].size.height}`; 137 | }); 138 | 139 | return sources; 140 | } catch (error) { 141 | errorWithEncoding("获取屏幕源出错:", error); 142 | throw error; 143 | } 144 | }); 145 | 146 | // 处理保存录制文件 147 | ipcMain.on("save-recording", async (event, buffer, format = "mp4") => { 148 | // 根据选择的格式设置对话框选项 149 | const isWebm = format === "webm"; 150 | const fileExtension = isWebm ? "webm" : "mp4"; 151 | const fileTypeLabel = isWebm ? "WebM 文件" : "MP4 文件"; 152 | 153 | const { filePath } = await dialog.showSaveDialog({ 154 | buttonLabel: "保存视频", 155 | defaultPath: `recording-${Date.now()}.${fileExtension}`, 156 | filters: [{ name: fileTypeLabel, extensions: [fileExtension] }], 157 | }); 158 | 159 | if (filePath) { 160 | // 创建临时WebM文件 161 | const tempDir = os.tmpdir(); 162 | const tempWebmPath = path.join(tempDir, `temp-${Date.now()}.webm`); 163 | 164 | logWithEncoding("临时WebM路径: " + tempWebmPath); 165 | logWithEncoding("最终目标路径: " + filePath); 166 | logWithEncoding("选择的格式: " + format); 167 | 168 | // 先保存WebM文件 169 | fs.writeFile(tempWebmPath, buffer, async (err) => { 170 | if (err) { 171 | errorWithEncoding("保存临时WebM文件失败:", err); 172 | event.reply("save-recording-response", { 173 | success: false, 174 | message: "保存失败:" + err.message, 175 | }); 176 | return; 177 | } 178 | 179 | try { 180 | // 如果选择WebM格式,直接重命名文件 181 | if (isWebm) { 182 | fs.renameSync(tempWebmPath, filePath); 183 | 184 | event.reply("save-recording-response", { 185 | success: true, 186 | message: "保存成功", 187 | filePath, 188 | }); 189 | return; 190 | } 191 | 192 | // 如果选择MP4格式,需要转换 193 | // 创建临时MP4文件路径 194 | const tempMp4Path = path.join(tempDir, `temp-${Date.now()}.mp4`); 195 | logWithEncoding("临时MP4路径: " + tempMp4Path); 196 | 197 | // 获取并验证FFmpeg路径 198 | const ffmpegPath = getFfmpegPath(); 199 | logWithEncoding("使用的FFmpeg路径: " + ffmpegPath); 200 | 201 | if (!fs.existsSync(ffmpegPath)) { 202 | throw new Error("找不到FFmpeg可执行文件: " + ffmpegPath); 203 | } 204 | 205 | // 使用FFmpeg转换WebM为MP4 206 | await new Promise((resolve, reject) => { 207 | const command = `"${ffmpegPath}" -i "${tempWebmPath}" -c:v libx264 -preset medium -crf 23 "${tempMp4Path}"`; 208 | logWithEncoding("执行的FFmpeg命令: " + command); 209 | 210 | // 设置子进程的编码为UTF-8 211 | exec( 212 | command, 213 | { 214 | encoding: "utf8", 215 | env: { 216 | ...process.env, 217 | PYTHONIOENCODING: "UTF-8", 218 | LANG: "zh_CN.UTF-8", 219 | }, 220 | }, 221 | (error, stdout, stderr) => { 222 | if (error) { 223 | errorWithEncoding("FFmpeg错误:", error); 224 | errorWithEncoding("FFmpeg输出:", stderr); 225 | reject(new Error(`FFmpeg转换失败: ${error.message}`)); 226 | return; 227 | } 228 | resolve(); 229 | } 230 | ); 231 | }); 232 | 233 | // 将转换后的MP4文件移动到目标位置 234 | fs.renameSync(tempMp4Path, filePath); 235 | 236 | // 清理临时文件 237 | fs.unlinkSync(tempWebmPath); 238 | 239 | event.reply("save-recording-response", { 240 | success: true, 241 | message: "保存成功", 242 | filePath, 243 | }); 244 | } catch (error) { 245 | errorWithEncoding("处理视频文件失败:", error); 246 | // 清理临时文件 247 | try { 248 | if (fs.existsSync(tempWebmPath)) { 249 | fs.unlinkSync(tempWebmPath); 250 | } 251 | if ( 252 | format === "mp4" && 253 | fs.existsSync(path.join(tempDir, `temp-${Date.now()}.mp4`)) 254 | ) { 255 | fs.unlinkSync(path.join(tempDir, `temp-${Date.now()}.mp4`)); 256 | } 257 | } catch (cleanupError) { 258 | errorWithEncoding("清理临时文件失败:", cleanupError); 259 | } 260 | 261 | // 提供更详细的错误信息 262 | let errorMessage = "处理视频文件失败"; 263 | if (error.message) { 264 | errorMessage += ": " + error.message; 265 | } 266 | if (error.code) { 267 | errorMessage += " (错误代码: " + error.code + ")"; 268 | } 269 | 270 | event.reply("save-recording-response", { 271 | success: false, 272 | message: errorMessage, 273 | }); 274 | } 275 | }); 276 | } else { 277 | event.reply("save-recording-response", { 278 | success: false, 279 | message: "保存取消", 280 | }); 281 | } 282 | }); 283 | 284 | // 处理最小化窗口请求 285 | ipcMain.on("minimize-window", () => { 286 | if (mainWindow) { 287 | mainWindow.minimize(); 288 | } 289 | }); 290 | 291 | // 处理显示窗口请求 292 | ipcMain.on("show-window", () => { 293 | if (mainWindow) { 294 | mainWindow.show(); 295 | mainWindow.focus(); 296 | } 297 | }); 298 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yi-recorder", 3 | "version": "1.1.0", 4 | "main": "main.js", 5 | "scripts": { 6 | "start": "chcp 65001 && electron .", 7 | "build-win": "electron-builder --win --x64 --publish=never" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "Apache-2.0", 12 | "description": "一个基于Electron的轻量级屏幕录制应用", 13 | "devDependencies": { 14 | "@electron/packager": "^18.3.6", 15 | "electron": "^28.1.0", 16 | "electron-builder": "^24.13.3" 17 | }, 18 | "dependencies": { 19 | "ffmpeg-static": "^5.2.0" 20 | }, 21 | "build": { 22 | "appId": "cn.signit.yi-recorder", 23 | "productName": "易录屏", 24 | "directories": { 25 | "output": "dist" 26 | }, 27 | "win": { 28 | "icon": "assets/logo.ico", 29 | "target": [ 30 | { 31 | "target": "nsis", 32 | "arch": [ 33 | "x64" 34 | ] 35 | }, 36 | { 37 | "target": "portable", 38 | "arch": [ 39 | "x64" 40 | ] 41 | } 42 | ], 43 | "artifactName": "${productName}-${version}.${ext}", 44 | "sign": null, 45 | "signingHashAlgorithms": null, 46 | "signDlls": false, 47 | "signAndEditExecutable": false 48 | }, 49 | "nsis": { 50 | "oneClick": false, 51 | "allowToChangeInstallationDirectory": true, 52 | "createDesktopShortcut": true, 53 | "createStartMenuShortcut": true, 54 | "shortcutName": "易录屏", 55 | "installerIcon": "assets/logo.ico", 56 | "uninstallerIcon": "assets/logo.ico", 57 | "installerHeaderIcon": "assets/logo.ico", 58 | "artifactName": "${productName}-Setup-${version}.${ext}" 59 | }, 60 | "portable": { 61 | "artifactName": "${productName}-Portable-${version}.exe" 62 | }, 63 | "asar": true, 64 | "extraResources": [ 65 | { 66 | "from": "assets", 67 | "to": "assets" 68 | }, 69 | { 70 | "from": "node_modules/ffmpeg-static/ffmpeg.exe", 71 | "to": "ffmpeg/ffmpeg.exe" 72 | } 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require("electron"); 2 | const electron = require("electron"); 3 | 4 | // 设置控制台输出编码 5 | process.env.LANG = "zh_CN.UTF-8"; 6 | 7 | // 使用Buffer来确保正确的编码输出 8 | const logWithEncoding = (message) => { 9 | if (typeof message === "string") { 10 | console.log(Buffer.from(message, "utf8").toString()); 11 | } else { 12 | console.log(message); 13 | } 14 | }; 15 | 16 | logWithEncoding("简化版preload脚本已加载"); 17 | logWithEncoding("electron对象: " + typeof electron); 18 | logWithEncoding( 19 | "electron中的desktopCapturer: " + typeof electron.desktopCapturer 20 | ); 21 | 22 | contextBridge.exposeInMainWorld("electronAPI", { 23 | captureScreen: async () => { 24 | logWithEncoding("captureScreen函数被调用"); 25 | try { 26 | // 通过IPC调用主进程来获取屏幕源 27 | const sources = await ipcRenderer.invoke("get-sources"); 28 | logWithEncoding("屏幕源: " + (sources ? sources.length : 0)); 29 | return sources; 30 | } catch (error) { 31 | const errorMsg = "捕获屏幕时出错: " + (error.message || error); 32 | logWithEncoding(errorMsg); 33 | throw error; 34 | } 35 | }, 36 | 37 | saveFile: (buffer, format) => 38 | ipcRenderer.send("save-recording", buffer, format), 39 | 40 | onSaveComplete: (callback) => { 41 | ipcRenderer.on("save-recording-response", (_event, response) => 42 | callback(response) 43 | ); 44 | return true; 45 | }, 46 | 47 | minimizeWindow: () => ipcRenderer.send("minimize-window"), 48 | 49 | // 显示主窗口 50 | showWindow: () => ipcRenderer.send("show-window"), 51 | }); 52 | -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | // DOM元素 2 | const recordBtn = document.getElementById("recordBtn"); 3 | const pauseBtn = document.getElementById("pauseBtn"); 4 | const stopBtn = document.getElementById("stopBtn"); 5 | const saveBtn = document.getElementById("saveBtn"); 6 | const timerElement = document.getElementById("timer"); 7 | const statusElement = document.getElementById("status"); 8 | const recordAudioCheckbox = document.getElementById("recordAudio"); 9 | const maxDurationInput = document.getElementById("maxDuration"); 10 | const loadingOverlay = document.getElementById("loadingOverlay"); 11 | const videoPreviewContainer = document.getElementById("videoPreviewContainer"); 12 | const videoPreview = document.getElementById("videoPreview"); 13 | let selectedSource = null; 14 | 15 | // 录制状态变量 16 | let mediaRecorder; 17 | let recordedChunks = []; 18 | let stream; 19 | let startTime; 20 | let pausedTime = 0; 21 | let totalPausedTime = 0; 22 | let lastPauseTime = 0; 23 | let isPaused = false; 24 | let timerInterval; 25 | let maxDurationMs = 0; // 最大录制时长(毫秒) 26 | let maxDurationTimer = null; // 最大录制时长定时器 27 | 28 | // 初始化 29 | document.addEventListener("DOMContentLoaded", () => { 30 | // 检查API是否可用 31 | console.log("API可用性检查:", !!window.electronAPI); 32 | if (window.electronAPI) { 33 | console.log("可用的API:", Object.keys(window.electronAPI)); 34 | } 35 | 36 | // 初始化按钮状态 37 | recordBtn.style.display = "inline-block"; // 显示开始录制按钮 38 | pauseBtn.style.display = "none"; // 隐藏暂停录制按钮 39 | stopBtn.style.display = "none"; // 隐藏停止录制按钮 40 | saveBtn.style.display = "none"; // 隐藏保存录制按钮 41 | 42 | recordBtn.addEventListener("click", startRecording); 43 | pauseBtn.addEventListener("click", togglePauseRecording); 44 | stopBtn.addEventListener("click", stopRecording); 45 | saveBtn.addEventListener("click", saveRecording); 46 | 47 | // 设置保存响应处理 48 | window.electronAPI.onSaveComplete(handleSaveResponse); 49 | }); 50 | 51 | // 启动录制 52 | async function startRecording() { 53 | try { 54 | // 隐藏视频预览 55 | videoPreviewContainer.style.display = "none"; 56 | 57 | // 获取最大录制时长设置(分钟) 58 | const maxDurationMinutes = parseInt(maxDurationInput.value, 10) || 0; 59 | maxDurationMs = maxDurationMinutes * 60 * 1000; // 转换为毫秒 60 | 61 | // 清除之前的最大时长定时器(如果有) 62 | if (maxDurationTimer) { 63 | clearTimeout(maxDurationTimer); 64 | maxDurationTimer = null; 65 | } 66 | 67 | statusElement.textContent = "正在获取屏幕源..."; 68 | 69 | // 获取可用的屏幕源 70 | const sources = await window.electronAPI.captureScreen(); 71 | if (!sources || sources.length === 0) { 72 | throw new Error("找不到可用的屏幕源"); 73 | } 74 | 75 | // 只有一个屏幕时自动选择,多个屏幕时显示前端对话框 76 | recordBtn.style.display = "none"; // 隐藏开始录制按钮 77 | if (sources.length === 1) { 78 | statusElement.textContent = "检测到单个屏幕,自动选择..."; 79 | selectedSource = sources[0]; 80 | } else { 81 | statusElement.textContent = "请选择要录制的屏幕"; 82 | selectedSource = await showScreenSelectionDialog(sources); 83 | if (!selectedSource) { 84 | statusElement.textContent = "已取消屏幕选择"; 85 | recordBtn.style.display = "inline-block"; // 重新显示开始录制按钮 86 | return; 87 | } 88 | } 89 | 90 | statusElement.textContent = "准备开始录制..."; 91 | await showCountdown(3); 92 | 93 | // 设置媒体约束 94 | const constraints = { 95 | audio: recordAudioCheckbox.checked 96 | ? { 97 | mandatory: { 98 | chromeMediaSource: "desktop", 99 | }, 100 | } 101 | : false, 102 | video: { 103 | mandatory: { 104 | chromeMediaSource: "desktop", 105 | chromeMediaSourceId: selectedSource.id, 106 | }, 107 | }, 108 | }; 109 | 110 | console.log("使用的媒体约束:", JSON.stringify(constraints)); 111 | 112 | // 获取媒体流 113 | stream = await navigator.mediaDevices.getUserMedia(constraints); 114 | 115 | // 创建MediaRecorder实例 116 | mediaRecorder = new MediaRecorder(stream, { 117 | mimeType: "video/webm; codecs=vp9", 118 | }); 119 | 120 | // 收集录制的数据 121 | mediaRecorder.ondataavailable = (e) => { 122 | if (e.data.size > 0) { 123 | recordedChunks.push(e.data); 124 | } 125 | }; 126 | 127 | // 录制结束处理 128 | mediaRecorder.onstop = () => { 129 | stopTimer(); 130 | statusElement.textContent = `录制已完成。${ 131 | recordAudioCheckbox.checked ? "包含系统声音。" : "" 132 | }可以保存录制内容。`; 133 | }; 134 | 135 | // 开始录制 136 | mediaRecorder.start(100); 137 | startTimer(); 138 | 139 | // 设置最大录制时长定时器(如果设置了最大时长) 140 | if (maxDurationMs > 0) { 141 | maxDurationTimer = setTimeout(() => { 142 | if (mediaRecorder && mediaRecorder.state !== "inactive") { 143 | // 显示应用窗口 144 | window.electronAPI.showWindow(); 145 | 146 | // 停止录制 147 | stopRecording(); 148 | 149 | // 提示用户已达到最大录制时长 150 | statusElement.textContent = `已达到最大录制时长 ${maxDurationMinutes} 分钟,录制已自动停止。`; 151 | } 152 | }, maxDurationMs); 153 | } 154 | 155 | // 更新UI状态 156 | recordBtn.style.display = "none"; // 隐藏开始录制按钮 157 | pauseBtn.style.display = "inline-block"; // 显示暂停录制按钮 158 | stopBtn.style.display = "inline-block"; // 显示停止录制按钮 159 | saveBtn.style.display = "none"; // 隐藏保存录制按钮 160 | recordAudioCheckbox.disabled = true; 161 | maxDurationInput.disabled = true; // 录制中禁用最大时长设置 162 | recordBtn.classList.add("recording"); 163 | recordBtn.textContent = "正在录制"; 164 | pauseBtn.textContent = "暂停录制"; 165 | pauseBtn.classList.remove("paused"); 166 | isPaused = false; 167 | totalPausedTime = 0; 168 | 169 | // 更新状态信息,包括最大录制时长 170 | let statusText = `正在录制屏幕${ 171 | recordAudioCheckbox.checked ? "和系统声音" : "" 172 | }`; 173 | if (maxDurationMs > 0) { 174 | statusText += `,最大录制时长: ${maxDurationMinutes} 分钟`; 175 | } 176 | statusElement.textContent = statusText + "..."; 177 | 178 | // 最小化窗口并开始录制 179 | window.electronAPI.minimizeWindow(); 180 | } catch (error) { 181 | console.error("启动录制时出错:", error); 182 | statusElement.textContent = `录制失败: ${error.message}`; 183 | recordBtn.style.display = "inline-block"; // 重新显示开始录制按钮 184 | pauseBtn.style.display = "none"; // 隐藏暂停录制按钮 185 | stopBtn.style.display = "none"; // 隐藏停止录制按钮 186 | saveBtn.style.display = "none"; // 隐藏保存录制按钮 187 | recordAudioCheckbox.disabled = false; 188 | maxDurationInput.disabled = false; 189 | } 190 | } 191 | 192 | // 暂停/继续录制 193 | function togglePauseRecording() { 194 | if (!mediaRecorder || mediaRecorder.state === "inactive") { 195 | return; 196 | } 197 | 198 | if (isPaused) { 199 | // 继续录制 200 | resumeRecording(); 201 | } else { 202 | // 暂停录制 203 | pauseRecording(); 204 | } 205 | } 206 | 207 | // 暂停录制 208 | function pauseRecording() { 209 | if (!mediaRecorder || mediaRecorder.state !== "recording") { 210 | return; 211 | } 212 | 213 | // 暂停 MediaRecorder 214 | mediaRecorder.pause(); 215 | 216 | // 记录暂停时间 217 | lastPauseTime = Date.now(); 218 | 219 | // 暂停计时器 220 | stopTimer(); 221 | 222 | // 如果设置了最大录制时长,暂停定时器 223 | if (maxDurationTimer) { 224 | clearTimeout(maxDurationTimer); 225 | // 计算剩余时间 226 | const elapsedTime = Date.now() - startTime - totalPausedTime; 227 | const remainingTime = Math.max(0, maxDurationMs - elapsedTime); 228 | // 保存剩余时间 229 | maxDurationMs = remainingTime; 230 | } 231 | 232 | // 更新UI状态 233 | pauseBtn.textContent = "继续录制"; 234 | pauseBtn.classList.add("paused"); 235 | statusElement.textContent = "录制已暂停"; 236 | isPaused = true; 237 | } 238 | 239 | // 继续录制 240 | function resumeRecording() { 241 | if (!mediaRecorder || mediaRecorder.state !== "paused") { 242 | return; 243 | } 244 | 245 | // 继续 MediaRecorder 246 | mediaRecorder.resume(); 247 | 248 | // 计算暂停时间 249 | const currentTime = Date.now(); 250 | const pauseDuration = currentTime - lastPauseTime; 251 | totalPausedTime += pauseDuration; 252 | 253 | // 继续计时器 254 | startTimer(); 255 | 256 | // 如果设置了最大录制时长,重新设置定时器 257 | if (maxDurationMs > 0) { 258 | maxDurationTimer = setTimeout(() => { 259 | if (mediaRecorder && mediaRecorder.state !== "inactive") { 260 | // 显示应用窗口 261 | window.electronAPI.showWindow(); 262 | 263 | // 停止录制 264 | stopRecording(); 265 | 266 | // 提示用户已达到最大录制时长 267 | const maxDurationMinutes = Math.ceil(maxDurationMs / (60 * 1000)); 268 | statusElement.textContent = `已达到最大录制时长 ${maxDurationMinutes} 分钟,录制已自动停止。`; 269 | } 270 | }, maxDurationMs); 271 | } 272 | 273 | // 更新UI状态 274 | pauseBtn.textContent = "暂停录制"; 275 | pauseBtn.classList.remove("paused"); 276 | 277 | // 更新状态信息,包括最大录制时长 278 | let statusText = `继续录制屏幕${ 279 | recordAudioCheckbox.checked ? "和系统声音" : "" 280 | }`; 281 | if (maxDurationMs > 0) { 282 | const maxDurationMinutes = Math.ceil(maxDurationMs / (60 * 1000)); 283 | statusText += `,剩余时间: 约 ${maxDurationMinutes} 分钟`; 284 | } 285 | statusElement.textContent = statusText + "..."; 286 | 287 | isPaused = false; 288 | } 289 | 290 | // 停止录制 291 | function stopRecording() { 292 | if (!mediaRecorder || mediaRecorder.state === "inactive") { 293 | return; 294 | } 295 | 296 | // 清除最大录制时长定时器 297 | if (maxDurationTimer) { 298 | clearTimeout(maxDurationTimer); 299 | maxDurationTimer = null; 300 | } 301 | 302 | mediaRecorder.stop(); 303 | stream.getTracks().forEach((track) => track.stop()); 304 | 305 | // 更新UI状态 306 | recordBtn.style.display = "inline-block"; // 显示开始录制按钮 307 | pauseBtn.style.display = "none"; // 隐藏暂停录制按钮 308 | stopBtn.style.display = "none"; // 隐藏停止录制按钮 309 | saveBtn.style.display = "inline-block"; // 显示保存录制按钮 310 | recordAudioCheckbox.disabled = false; 311 | maxDurationInput.disabled = false; // 重新启用最大时长设置 312 | recordBtn.classList.remove("recording"); 313 | pauseBtn.classList.remove("paused"); 314 | recordBtn.textContent = "开始录制"; 315 | pauseBtn.textContent = "暂停录制"; 316 | isPaused = false; 317 | 318 | // 创建并显示视频预览 319 | createVideoPreview(); 320 | } 321 | 322 | // 创建视频预览 323 | function createVideoPreview() { 324 | if (!recordedChunks.length) { 325 | return; 326 | } 327 | 328 | // 创建视频Blob 329 | const blob = new Blob(recordedChunks, { type: "video/webm" }); 330 | 331 | // 创建视频URL 332 | const videoURL = URL.createObjectURL(blob); 333 | 334 | // 设置视频源 335 | videoPreview.src = videoURL; 336 | 337 | // 显示视频预览容器 338 | videoPreviewContainer.style.display = "block"; 339 | 340 | // 视频加载完成后自动播放 341 | videoPreview.onloadedmetadata = () => { 342 | videoPreview.play(); 343 | }; 344 | } 345 | 346 | // 保存录制 347 | function saveRecording() { 348 | if (!recordedChunks.length) { 349 | statusElement.textContent = "没有录制内容可保存"; 350 | return; 351 | } 352 | 353 | // 显示格式选择弹窗 354 | showFormatSelectionDialog(); 355 | } 356 | 357 | // 显示格式选择对话框 358 | function showFormatSelectionDialog() { 359 | // 创建弹窗元素 360 | const modal = document.createElement("div"); 361 | modal.className = "modal"; 362 | modal.style.display = "flex"; 363 | modal.style.zIndex = "3000"; 364 | 365 | const modalContent = document.createElement("div"); 366 | modalContent.className = "modal-content"; 367 | modalContent.style.maxWidth = "400px"; 368 | 369 | const title = document.createElement("div"); 370 | title.className = "modal-title"; 371 | title.textContent = "请选择保存格式"; 372 | title.style.marginBottom = "20px"; 373 | 374 | const buttonsContainer = document.createElement("div"); 375 | buttonsContainer.style.display = "flex"; 376 | buttonsContainer.style.justifyContent = "space-around"; 377 | buttonsContainer.style.marginTop = "20px"; 378 | 379 | // WebM按钮 380 | const webmButton = document.createElement("button"); 381 | webmButton.textContent = "保存为WebM格式"; 382 | webmButton.style.backgroundColor = "#3498db"; 383 | webmButton.style.marginRight = "10px"; 384 | 385 | // MP4按钮 386 | const mp4Button = document.createElement("button"); 387 | mp4Button.textContent = "保存为MP4格式"; 388 | mp4Button.style.backgroundColor = "#2ecc71"; 389 | 390 | // 添加按钮点击事件 391 | webmButton.addEventListener("click", () => { 392 | modal.style.display = "none"; 393 | document.body.removeChild(modal); 394 | processAndSaveRecording("webm"); 395 | }); 396 | 397 | mp4Button.addEventListener("click", () => { 398 | modal.style.display = "none"; 399 | document.body.removeChild(modal); 400 | processAndSaveRecording("mp4"); 401 | }); 402 | 403 | // 组装弹窗 404 | buttonsContainer.appendChild(webmButton); 405 | buttonsContainer.appendChild(mp4Button); 406 | modalContent.appendChild(title); 407 | modalContent.appendChild(buttonsContainer); 408 | modal.appendChild(modalContent); 409 | 410 | // 添加到页面 411 | document.body.appendChild(modal); 412 | } 413 | 414 | // 处理并保存录制内容 415 | function processAndSaveRecording(format) { 416 | statusElement.textContent = "正在处理录制内容,请稍候..."; 417 | loadingOverlay.style.display = "flex"; 418 | 419 | // 隐藏视频预览 420 | videoPreviewContainer.style.display = "none"; 421 | 422 | // 合并所有录制的片段 423 | const blob = new Blob(recordedChunks, { type: "video/webm" }); 424 | 425 | // 将Blob转换为Buffer 426 | const reader = new FileReader(); 427 | reader.onload = () => { 428 | const buffer = new Uint8Array(reader.result); 429 | 430 | // 通过IPC发送到主进程保存,并指定格式 431 | window.electronAPI.saveFile(buffer, format); 432 | statusElement.textContent = `正在保存为${format.toUpperCase()}格式...`; 433 | }; 434 | 435 | reader.readAsArrayBuffer(blob); 436 | } 437 | 438 | // 处理保存录制文件的响应 439 | function handleSaveResponse(response) { 440 | loadingOverlay.style.display = "none"; 441 | 442 | if (response.success) { 443 | statusElement.textContent = `${response.message}:${response.filePath}`; 444 | // 清除录制的数据 445 | recordedChunks = []; 446 | saveBtn.style.display = "none"; // 隐藏保存录制按钮 447 | 448 | // 清除视频预览 449 | videoPreview.src = ""; 450 | videoPreviewContainer.style.display = "none"; 451 | 452 | // 释放视频URL资源 453 | if (videoPreview.src) { 454 | URL.revokeObjectURL(videoPreview.src); 455 | } 456 | } else { 457 | statusElement.textContent = response.message; 458 | } 459 | } 460 | 461 | // 计时器功能 462 | function startTimer() { 463 | if (!isPaused) { 464 | // 如果是第一次开始录制 465 | startTime = Date.now(); 466 | } 467 | updateTimer(); 468 | timerInterval = setInterval(updateTimer, 1000); 469 | } 470 | 471 | function stopTimer() { 472 | clearInterval(timerInterval); 473 | } 474 | 475 | function updateTimer() { 476 | // 计算实际录制时间,减去暂停的时间 477 | const currentTime = Date.now(); 478 | const elapsedTime = currentTime - startTime - totalPausedTime; 479 | 480 | const seconds = Math.floor((elapsedTime / 1000) % 60); 481 | const minutes = Math.floor((elapsedTime / (1000 * 60)) % 60); 482 | const hours = Math.floor(elapsedTime / (1000 * 60 * 60)); 483 | 484 | timerElement.textContent = `${padZero(hours)}:${padZero(minutes)}:${padZero( 485 | seconds 486 | )}`; 487 | } 488 | 489 | function padZero(num) { 490 | return num.toString().padStart(2, "0"); 491 | } 492 | 493 | // 显示屏幕选择对话框 494 | function showScreenSelectionDialog(sources) { 495 | return new Promise((resolve) => { 496 | const modal = document.getElementById("screenModal"); 497 | const screenList = document.getElementById("screenList"); 498 | const confirmBtn = document.getElementById("confirmScreenSelect"); 499 | const cancelBtn = document.getElementById("cancelScreenSelect"); 500 | 501 | // 清空并重新填充屏幕列表 502 | screenList.innerHTML = ""; 503 | let selectedSource = null; 504 | 505 | sources.forEach((source) => { 506 | const item = document.createElement("div"); 507 | item.className = "screen-item"; 508 | item.innerHTML = ` 509 |
510 |
${source.displaySize}
511 |
512 |
${source.name}
513 | `; 514 | 515 | item.addEventListener("click", () => { 516 | // 更新选中状态 517 | document.querySelectorAll(".screen-item").forEach((el) => { 518 | el.classList.remove("selected"); 519 | }); 520 | item.classList.add("selected"); 521 | selectedSource = source; 522 | confirmBtn.style.display = "inline-block"; // 显示确认按钮 523 | }); 524 | 525 | screenList.appendChild(item); 526 | }); 527 | 528 | // 确认按钮点击处理 529 | confirmBtn.addEventListener( 530 | "click", 531 | () => { 532 | modal.style.display = "none"; 533 | resolve(selectedSource); 534 | }, 535 | { once: true } 536 | ); 537 | 538 | // 取消按钮点击处理 539 | cancelBtn.addEventListener( 540 | "click", 541 | () => { 542 | modal.style.display = "none"; 543 | resolve(null); 544 | }, 545 | { once: true } 546 | ); 547 | 548 | // 显示对话框 549 | modal.style.display = "flex"; 550 | confirmBtn.style.display = "none"; // 初始隐藏确认按钮,直到选择了屏幕 551 | }); 552 | } 553 | 554 | // 显示倒计时动画 555 | async function showCountdown(seconds) { 556 | return new Promise((resolve) => { 557 | const countdownOverlay = document.createElement("div"); 558 | countdownOverlay.style.position = "fixed"; 559 | countdownOverlay.style.top = "0"; 560 | countdownOverlay.style.left = "0"; 561 | countdownOverlay.style.width = "100%"; 562 | countdownOverlay.style.height = "100%"; 563 | countdownOverlay.style.backgroundColor = "rgba(0,0,0,0.7)"; 564 | countdownOverlay.style.display = "flex"; 565 | countdownOverlay.style.justifyContent = "center"; 566 | countdownOverlay.style.alignItems = "center"; 567 | countdownOverlay.style.zIndex = "2000"; 568 | countdownOverlay.style.fontSize = "120px"; 569 | countdownOverlay.style.color = "white"; 570 | countdownOverlay.style.fontWeight = "bold"; 571 | countdownOverlay.style.textShadow = "0 0 20px #3498db"; 572 | 573 | document.body.appendChild(countdownOverlay); 574 | 575 | let count = seconds; 576 | countdownOverlay.textContent = count; 577 | 578 | const timer = setInterval(() => { 579 | count--; 580 | if (count <= 0) { 581 | clearInterval(timer); 582 | document.body.removeChild(countdownOverlay); 583 | resolve(); 584 | } else { 585 | countdownOverlay.textContent = count; 586 | } 587 | }, 1000); 588 | }); 589 | } 590 | --------------------------------------------------------------------------------