Compare commits

...

154 Commits

Author SHA1 Message Date
士子☀️ b3b58680e0 release: v2.0.0 2023-06-24 15:29:27 +08:00
士子☀️ d1b75df87d Merge pull request #22 from caorushizi/development
fix: mac active.
2023-06-23 22:10:35 +08:00
caorushizi 4b4e1b9f54 fix: mac active. 2023-06-23 22:05:47 +08:00
士子☀️ 8ebb550270 Merge pull request #21 from caorushizi/development
release: v2.0.0-beta.7
2023-06-23 21:52:50 +08:00
士子☀️ 6b93954246 release: v2.0.0-beta.7 2023-06-23 21:48:46 +08:00
士子☀️ 550d7ebd4b release: v2.0.0-beta.7 2023-06-23 21:18:29 +08:00
caorushizi 3f331e5d3d feat: package.json 2023-06-23 21:12:54 +08:00
士子☀️ 020f921af8 release v2.0.0-beta.6 2023-06-23 10:44:58 +08:00
士子☀️ efa72ce12b release: v2.0.0-beta.6 2023-06-23 09:39:41 +08:00
士子☀️ 38ead633a5 release: v2.0.0-beta.6 2023-06-23 09:29:50 +08:00
士子☀️ 3eb1d0ae99 fix: get video source & dark mode 2023-06-22 21:39:43 +08:00
士子☀️ 7bed4ed335 update dependencies version. 2023-06-22 16:23:48 +08:00
士子☀️ b357d41f23 add: dark mode. 2023-06-22 15:58:48 +08:00
士子☀️ 3834603b0a update dependencies version. 2023-06-22 12:40:33 +08:00
士子☀️ f38ba108a3 add adblocker 2023-06-22 11:33:37 +08:00
士子☀️ 7ceda3d73f readme 2023-06-21 23:53:03 +08:00
士子☀️ aee57b6f98 feat: set dev tool load. 2023-06-20 23:28:47 +08:00
士子☀️ f9d49bd55f feat: restore browser state. 2023-06-20 22:49:07 +08:00
士子☀️ 3bb082473a Merge branch 'master' of https://github.com/caorushizi/m3u8-downloader 2023-06-20 22:01:23 +08:00
士子☀️ 31b6726fe5 fix: direct download. 2023-06-20 22:01:05 +08:00
士子☀️ 348b41b0dd Update README.md 2023-06-19 23:11:36 +08:00
士子☀️ d4bbfa0516 release: beta5 2023-06-18 18:38:38 +08:00
士子☀️ b2af9b3d25 release: 2.0.0-beta.5 2023-06-18 18:25:26 +08:00
caorushizi ded93a4f0b test 2023-06-18 18:04:59 +08:00
caorushizi d43f57565b feat: add video player. 2023-06-18 17:59:39 +08:00
caorushizi 34cf661fb2 feat: add video player 2023-06-18 17:05:05 +08:00
士子☀️ 91cfb883b6 feat: add video player. 2023-06-18 15:06:15 +08:00
士子☀️ 1cb7deb376 player 2023-06-17 21:24:54 +08:00
caorushizi 70933c63bb pack 2023-06-17 19:29:25 +08:00
caorushizi 5723b9edd0 player 2023-06-17 14:15:50 +08:00
士子☀️ 65f37a9c66 main 2023-06-17 13:08:45 +08:00
士子☀️ 57516793ab main 2023-06-17 02:42:41 +08:00
士子☀️ afecc5ac12 rename url 2023-06-17 01:16:53 +08:00
士子☀️ 3331564155 Merge pull request #20 from caorushizi/dev-server
feat: add video server
2023-06-17 00:13:54 +08:00
士子☀️ 19ce7b9906 add 2023-06-17 00:12:54 +08:00
士子☀️ 40f39340f0 add mobile. 2023-06-16 23:20:20 +08:00
士子☀️ f085bcacf7 add mobile 2023-06-11 02:29:53 +08:00
士子☀️ dbd4e73b6e feat: add mobile player. 2023-06-10 17:23:31 +08:00
士子☀️ b35e6b3315 feat: add video player. 2023-06-10 16:56:02 +08:00
士子☀️ 3c603a67dd feat: add video player 2023-06-10 11:33:35 +08:00
士子☀️ 8ff5abf5d8 feat: add video server 2023-06-08 01:22:59 +08:00
士子☀️ 0fdbd39393 fix: windows address url. 2023-06-07 01:02:22 +08:00
caorushizi df93770128 test 2023-06-04 00:40:39 +08:00
caorushizi b61a935068 test 2023-06-03 23:07:42 +08:00
士子☀️ 95e96b1057 优化素材提取页交互,优化打点逻辑,优化页面切换时的问题 2023-05-28 19:18:36 +08:00
士子☀️ 3dd325a280 Update README.md 2023-05-06 09:54:08 +08:00
士子☀️ bbe922df74 Update README.md 2023-05-06 09:53:03 +08:00
caorushizi e31eddfd2e 2.0.0-beta.4 2023-05-05 11:25:10 +08:00
caorushizi bbb9f5798b fix: peocess.env 2023-05-05 10:20:10 +08:00
caorushizi b634cf056d fix: env file. 2023-05-05 09:45:38 +08:00
士子☀️ 2ede22b50a github action pnpm version 2023-05-05 01:15:29 +08:00
士子☀️ a4efd83f7a build and release 2023-05-05 01:10:57 +08:00
caorushizi c3ebced763 macos pack 2023-05-04 23:32:03 +08:00
caorushizi 6cb6621df1 platform: add macos 2023-05-04 23:16:13 +08:00
士子☀️ 4e5febba8b something 2023-04-22 19:47:10 +08:00
士子☀️ 1fb787d4ea 手动添加链接;某些视频网站跳转 about:blank ;修改下载名称 2023-04-22 17:29:26 +08:00
士子☀️ f24ddaa55e add favorite 2023-04-22 14:33:57 +08:00
士子☀️ 66542a0003 talking data version 2023-04-17 23:04:17 +08:00
士子☀️ f8a8e1d3e6 不展示menu 2023-04-15 22:22:34 +08:00
士子☀️ c1873b8c6d 在新页面中打开浏览器 2023-04-15 21:30:15 +08:00
士子☀️ 07dceb2704 在新页面中打开浏览器 2023-04-15 21:05:07 +08:00
士子☀️ ceec042ffd 在新页面中打开浏览器 2023-04-15 16:45:39 +08:00
士子☀️ 2a47f255ee 升级antd,下载完成后删除原始文件 2023-04-14 00:04:44 +08:00
士子☀️ 1f899e6c6d env 2023-04-02 15:07:56 +08:00
士子☀️ f0686bb18c release 2023-04-01 20:44:51 +08:00
士子☀️ b321dc06e9 app version 2023-04-01 19:11:55 +08:00
士子☀️ 5f7c6e846b updater 2023-04-01 11:03:19 +08:00
士子☀️ 2d71475a48 Merge branch 'master' of https://github.com/caorushizi/m3u8-downloader 2023-04-01 10:55:55 +08:00
士子☀️ cfdeb4a1a8 updater 2023-04-01 10:55:36 +08:00
士子☀️ 5d3abdefdc Create .github/dependabot.yml 2023-04-01 10:16:21 +08:00
士子☀️ abcff22f3e 转换为音频 2023-03-31 23:27:45 +08:00
士子☀️ 7d4ca4d758 ipcrenderer 事件监听 2023-03-31 21:01:07 +08:00
caorushizi 9b44b2f31f 2.0 优化功能 2023-03-31 20:27:28 +08:00
士子☀️ 55bd8da894 feature: 完善首页、视频获取逻辑 2023-03-31 00:37:18 +08:00
士子☀️ e8c7abaa2f 修复事件监听&完善下载页面交互 2023-03-30 01:05:02 +08:00
士子☀️ 1702b9beac update 2023-03-28 23:49:41 +08:00
士子☀️ de25052980 暂停下载逻辑 2023-03-27 23:47:10 +08:00
士子☀️ 03700ed4bb readme 2023-03-26 20:48:18 +08:00
士子☀️ e0147a4a6e readme 2023-03-26 20:41:55 +08:00
士子☀️ 281fd07e23 Merge pull request #15 from caorushizi/dev
Dev
2023-03-26 20:34:14 +08:00
士子☀️ 1d7a0e62e5 download. 2023-03-26 20:29:20 +08:00
士子☀️ e4dcdb9422 download. 2023-03-26 19:22:29 +08:00
士子☀️ f035bb09b4 download. 2023-03-26 17:59:54 +08:00
士子☀️ 869c6497e9 download. 2023-03-26 15:54:43 +08:00
士子☀️ 151ed1ce0d download list progress. 2023-03-26 01:12:49 +08:00
士子☀️ bab349da9b download list. 2023-03-25 20:17:02 +08:00
士子☀️ ac8f9d2955 home page. 2023-03-25 16:00:06 +08:00
士子☀️ 0f2670069f readme. 2023-03-25 12:15:38 +08:00
士子☀️ 0af3d32337 download list. 2023-03-25 12:13:30 +08:00
士子☀️ f5a22f8393 config page. 2023-03-25 11:39:40 +08:00
士子☀️ 769a82e7ed setting form. 2023-03-25 00:08:23 +08:00
caorushizi 3985af0042 something 2023-03-24 20:09:18 +08:00
士子☀️ dca0e0a84b add electron extensions. 2023-03-24 00:29:27 +08:00
士子☀️ 233296ba3e receive 2023-03-23 00:22:37 +08:00
士子☀️ 2bc4fe0592 readme 2023-03-22 00:13:24 +08:00
士子☀️ c22245e296 source extract. 2023-03-22 00:11:15 +08:00
士子☀️ 29de2fc81c source extract. 2023-03-20 01:09:08 +08:00
士子☀️ bd27aefe8a source extract. 2023-03-20 00:50:08 +08:00
士子☀️ 722cabba57 source extract. 2023-03-19 19:23:35 +08:00
士子☀️ 3468c8d5e6 new branch. 2023-03-19 12:16:37 +08:00
caorushizi b3cc83f8cd new ui 2023-01-14 12:34:26 +08:00
caorushizi f6a1ca03ac new ui 2023-01-13 11:48:44 +08:00
士子☀️ 516ac72c3e feature: 修改页面提示逻辑 2022-12-28 00:07:53 +08:00
士子☀️ c068bde0a8 feature: 新增插件 2022-12-26 23:38:04 +08:00
士子☀️ ca3ed53a59 feature: 样式交互优化 2022-12-25 23:59:01 +08:00
士子☀️ 04c05ee15e feture: 优化样式交互 2022-12-25 20:48:31 +08:00
士子☀️ 60d27be739 style: eslint 修正 2022-12-25 15:11:06 +08:00
士子☀️ 259fe5f447 style: eslint 修正 2022-12-25 14:30:24 +08:00
士子☀️ 8d9b7fc9dc style: eslint 修正 2022-12-25 13:22:18 +08:00
士子☀️ d5d282147d style: eslint 修正 2022-12-24 17:51:28 +08:00
士子☀️ 08f7f66154 revert: 删除mediago-node 2022-12-23 22:15:26 +08:00
士子☀️ f702f847e6 style: 修正代码提交规范 2022-12-23 22:11:12 +08:00
士子☀️ b9b87cf354 master 2022-12-22 23:14:59 +08:00
士子☀️ a81b9a37ab master 2022-12-22 16:09:58 +08:00
士子☀️ 64174ef374 master 2022-12-18 23:20:34 +08:00
士子☀️ a8b3a9ee1a master 2022-12-17 00:30:53 +08:00
diaozhenyuan 7be1a7907d master 2022-12-16 21:14:14 +08:00
diaozhenyuan 05d207f34d master 2022-12-16 18:49:58 +08:00
diaozhenyuan 26be46b038 master 2022-12-16 18:02:24 +08:00
士子☀️ 47826f1f72 master 2022-12-15 23:27:59 +08:00
士子☀️ f7428f811a master 2022-12-12 22:55:24 +08:00
士子☀️ 3aa8577a38 master 2022-12-11 19:10:44 +08:00
士子☀️ 8b7965dbcb master 2022-12-11 02:54:46 +08:00
士子☀️ 006cb4b13c master 2022-12-10 19:14:22 +08:00
士子☀️ ecb8d6b387 master 2022-12-10 18:52:10 +08:00
士子☀️ b57b05fe1d master 2022-12-10 18:23:47 +08:00
士子☀️ 1e9969857d Merge pull request #13 from caorushizi/dev
Dev
2022-12-10 18:01:03 +08:00
士子☀️ af89dd2694 dev 2022-12-06 23:04:38 +08:00
士子☀️ e418e05ab1 dev 2022-11-29 23:34:31 +08:00
士子☀️ a8d8a7aca7 dev 2022-11-29 23:31:30 +08:00
士子☀️ 4c7226cda1 dev 2022-11-26 00:15:27 +08:00
士子☀️ 91b5ee4423 dev 2022-11-26 00:15:05 +08:00
士子☀️ f8d7148b9e Dev 2022-11-08 00:07:41 +08:00
士子☀️ 0f0687b5c9 dev 2022-11-06 23:36:28 +08:00
士子☀️ 1384cd4c6e dev 2022-11-06 12:41:35 +08:00
士子☀️ 4c5c78d0e4 dev 2022-11-06 12:15:07 +08:00
士子☀️ 551abb4496 dev 2022-10-30 21:49:32 +08:00
士子☀️ 8af8824c3e dev 2022-10-30 20:20:32 +08:00
士子☀️ c01c085ac1 dev 2022-10-30 01:46:39 +08:00
士子☀️ 8668acdd6d dev 2022-10-27 00:13:10 +08:00
士子☀️ 03567ed36d 重构 2022-10-24 00:10:10 +08:00
caorushizi dcfea2cfb3 整理项目文件 2022-05-01 01:01:05 +08:00
caorushizi 3c4e6c8240 整理项目文件 2022-04-30 23:23:38 +08:00
caorushizi 9e93426c38 2 2022-04-20 23:53:45 +08:00
caorushizi 5c698e2e9b 1 2022-04-19 23:34:58 +08:00
caorushizi b174e14fbd process 2022-04-19 23:27:09 +08:00
caorushizi 19e84cd415 downloader 2022-04-19 01:13:12 +08:00
caorushizi 73a9b3fd72 downloader 2022-04-19 01:12:33 +08:00
caorushizi 64a938879d downloader 2022-04-17 23:15:38 +08:00
caorushizi 66b9a9677b 重构文件目录 2022-04-17 01:55:37 +08:00
士子☀️ 39616244d9 mono repo. 2022-04-11 23:14:48 +08:00
士子☀️ 990846515f Update README.md 2022-02-06 21:32:25 +08:00
caorushizi 94777aead3 update readme.md 2022-02-05 16:59:13 +08:00
caorushizi 5275d3bfd3 删除视频数据打点 2022-02-05 16:43:06 +08:00
202 changed files with 15349 additions and 4475 deletions
+15
View File
@@ -0,0 +1,15 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
+6
View File
@@ -0,0 +1,6 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.0
APP_SERVER_PORT=8433
+4 -3
View File
@@ -1,4 +1,5 @@
VITE_APP_TDID=
VITE_APP_SENTRY_DSN=
APP_TD_APPID=
APP_DOWNLOAD_DEBUG=true
NODE_ENV=development
GH_TOKEN=
LOAD_DEVTOOLS=
+3 -3
View File
@@ -1,4 +1,4 @@
VITE_APP_TDID=
VITE_APP_SENTRY_DSN=
APP_TD_APPID=
APP_DOWNLOAD_DEBUG=
NODE_ENV=production
GH_TOKEN=
-1
View File
@@ -1 +0,0 @@
NODE_ENV=staging
-6
View File
@@ -1,6 +0,0 @@
.eslintrc.js
esbuild.config.js
babel.config.js
webpack.config.js
script/
vite.config.ts
-19
View File
@@ -1,19 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react"],
extends: [
"plugin:@typescript-eslint/recommended",
"react-app",
"plugin:prettier/recommended",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"prettier/prettier": "error",
},
};
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+49
View File
@@ -0,0 +1,49 @@
# build.yml
# Workflow's name
name: Build
# Workflow's trigger
on:
push:
branches:
- master
# Workflow's jobs
jobs:
# job's id
release:
# job's name
name: build and release electron app
# the type of machine to run the job on
runs-on: ${{ matrix.os }}
# create a build matrix for jobs
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
# create steps
steps:
# step1: check out repository
- name: Check out git repository
uses: actions/checkout@v3
# step2: install node env
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: "16.x"
- uses: pnpm/action-setup@v2
with:
version: latest
run_install: true
- name: Build & release app
run: pnpm run release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
APP_TD_APPID: ${{ secrets.APP_TD_APPID }}
+1 -10
View File
@@ -1,13 +1,4 @@
node_modules
.DS_Store
dist
dist-ssr
.vscode
*.local
.idea
.bin
.vscode
yarn.lock
yarn-error.log
build
devtools/*
+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"development"
],
"hints": {
"axe/text-alternatives": [
"default",
{
"image-alt": "off"
}
]
}
}
+2
View File
@@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com/
electron_mirror=https://npm.taobao.org/mirrors/electron/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 士子☀️
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+40 -23
View File
@@ -1,39 +1,56 @@
# media-downloader
m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows mac。
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……
# 上手指南
模板: <https://github.com/caorushizi/electron-template>
## 上手指南
以下指南将帮助你在本地机器上安装和运行该项目,进行开发和测试。关于如何将该项目部署到在线环境,请参考部署小节。
# 安装要求
运行代码需要 node 和 yarn,node需要在官网下载安装,yarn可以通过`npm i -g yarn`安装。
【使用帮助】[blog.ziying.site](https://ziying.site/post/media-downloader-how-to-use/?from=github)
# 运行代码
## 安装要求
开发环境 `yarn run watch`
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
打包运行 `yarn run publish`
## 运行代码
# 下载链接
- [v1.1.3 windows](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.3/media-downloader-setup-1.1.3.exe)
- [v1.0.3 windows](http://static.ziying.site/media-downloader-1.0.3%20Setup.exe)
- [v1.0.2 windows](http://static.ziying.site/media-downloader-1.0.2%20Setup.exe)
- [v1.0.1 windows](http://static.ziying.site/media-downloader-1.0.1%20Setup.exe)
- [v1.0.0 windows](http://static.ziying.site/media-downloader-1.0.0%20Setup.exe)
安装依赖 `pnpm i`
# 软件截图
开发环境 `pnpm run dev`
![首页](http://static.ziying.site/Snipaste_2022-01-08_18-43-25.png)
打包运行 `pnpm run release`
![设置页面](http://static.ziying.site/Snipaste_2022-01-08_18-43-39.png)
## 下载链接
# 技术栈
- vite https://cn.vitejs.dev
- antd https://ant.design
- electron https://www.electronjs.org
v2.0.0-beta.5 添加视频播放功能,支持移动设备播放
# 鸣谢
N_m3u8DL-CLI 来自于 https://github.com/nilaoda/N_m3u8DL-CLI
- 【2023.06.18 v2.0.0-beta.6 windows】[media-downloader-setup-2.0.0-beta.6.exe](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.6/media-downloader-setup-2.0.0-beta.6.exe)
- 【2023.06.18 v2.0.0-beta.6 macos】[media-downloader-windows-v2.0.0-beta.6.dmg](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.6/media-downloader-setup-2.0.0-beta.6.dmg)
- 【2023.06.18 v2.0.0-beta.5 windows】[media-downloader-setup-2.0.0-beta.5.exe](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.5/media-downloader-setup-2.0.0-beta.5.exe)
- 【2023.06.18 v2.0.0-beta.5 macos】[media-downloader-windows-v2.0.0-beta.5.dmg](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.5/media-downloader-setup-2.0.0-beta.5.dmg)
- 【2023.04.01 v2.0.0-beta.4 windows】 [media-downloader-windows-v2.0.0-beta.4.exe](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.4/media-downloader-setup-2.0.0-beta.4.exe)
- 【2023.04.01 v2.0.0-beta.4 macos】 [media-downloader-windows-v2.0.0-beta.4.dmg](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.0-beta.4/media-downloader-setup-2.0.0-beta.4.dmg)
- 【2022.01.08 v1.1.5】 [media-downloader-windows-v1.1.5.exe](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.5.exe)
mediago 来自于 https://github.com/caorushizi/mediago
## 软件截图
![首页](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/home-page.png)
![设置页面](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/setting-page.png)
![资源提取](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/browser-page.png)
## 技术栈
- vite <https://cn.vitejs.dev>
- antd <https://ant.design>
- electron <https://www.electronjs.org>
## 鸣谢
N_m3u8DL-CLI 来自于 <https://github.com/nilaoda/N_m3u8DL-CLI>
mediago 来自于 <https://github.com/caorushizi/mediago>
-14
View File
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="referrer" content="never">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>在线视频下载</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/main.tsx"></script>
</body>
</html>
+14 -138
View File
@@ -1,144 +1,20 @@
{
"name": "media-downloader",
"version": "1.1.4",
"description": "在线视频下载",
"author": "caorushizi",
"main": "dist/main/index.js",
"license": "MIT",
"homepage": "./",
"name": "electron-template",
"version": "0.1.0",
"description": "electron-template",
"main": "index.js",
"scripts": {
"dist": "cross-env NODE_ENV=production node script/build.js",
"watch": "cross-env NODE_ENV=development node script/watch.js",
"electron": "cross-env NODE_ENV=production electron .",
"pack": "electron-builder --dir",
"build": "electron-builder",
"publish": "npm run dist && npm run build",
"build-debug": "npm run dist && npm run pack && ./build/win-unpacked/在线视频下载.exe"
"dev": "pnpm --parallel -F \"./packages/*\" run dev",
"build": "pnpm --parallel -F \"./packages/*\" run build",
"pack": "pnpm -F media-downloader run pack",
"release": "pnpm run build && pnpm -F media-downloader run release"
},
"dependencies": {
"@ant-design/icons": "^4.6.2",
"@ant-design/pro-form": "^1.49.6",
"@ant-design/pro-table": "^2.59.2",
"@chakra-ui/react": "^1.7.2",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@sentry/electron": "^2.5.4",
"@sentry/react": "^6.16.1",
"@sentry/tracing": "^6.16.1",
"antd": "^4.17.2",
"classnames": "^2.3.1",
"connected-react-router": "^6.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-log": "^4.3.2",
"electron-squirrel-startup": "^1.0.0",
"electron-store": "^7.0.2",
"framer-motion": "^4",
"glob": "^7.1.6",
"localforage": "^1.9.0",
"m3u8-parser": "^4.7.0",
"moment": "^2.29.1",
"nanoid": "^3.1.30",
"prop-types": "^15.7.2",
"qs": "^6.10.1",
"re-resizable": "^6.9.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-file-drop": "^3.1.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9",
"redux-saga": "^1.1.3",
"semver": "^7.3.4"
},
"devDependencies": {
"@rollup/plugin-eslint": "^8.0.1",
"@types/glob": "^7.1.3",
"@types/node": "^14.14.35",
"@types/qs": "^6.9.7",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-router": "^5.1.16",
"@types/react-router-dom": "^5.1.8",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/semver": "^7.3.4",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"@vitejs/plugin-react-refresh": "^1.3.1",
"babel-eslint": "^10.0.0",
"chalk": "^4.1.2",
"concurrently": "^6.0.0",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"electron": "16.0.4",
"electron-builder": "^22.14.2",
"electron-updater": "^4.6.1",
"esbuild": "^0.9.5",
"eslint": "^7.5.0",
"eslint-config-prettier": "^8.1.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.2.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.8",
"less": "^4.1.1",
"prettier": "2.2.1",
"rimraf": "^3.0.2",
"sass": "^1.32.8",
"typescript": "^4.2.4",
"vite": "^2.5.0"
},
"build": {
"productName": "在线视频下载",
"appId": "mediago.ziying.site",
"copyright": "caorushizi",
"artifactName": "media-downloader-setup-${version}.${ext}",
"directories": {
"output": "build"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
".bin/**"
],
"win": {
"icon": "assets/icon.ico",
"requestedExecutionLevel": "requireAdministrator",
"target": [
{
"target": "nsis"
}
]
},
"dmg": {
"contents": []
},
"mac": {
"icon": "build/icons/icon.icns"
},
"linux": {
"icon": "build/icons"
},
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"installerIcon": "",
"uninstallerIcon": "",
"installerHeaderIcon": "",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "",
"include": "",
"script": ""
"keywords": [],
"author": "",
"license": "MIT",
"pnpm": {
"patchedDependencies": {
"@cliqz/adblocker@1.26.6": "patches/@cliqz__adblocker@1.26.6.patch"
}
}
}
View File
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
env: {
node: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint", "prettier"],
rules: {
"prettier/prettier": "warn",
},
};
+5
View File
@@ -0,0 +1,5 @@
.bin
node_modules
build
dist
bin/Logs
+1
View File
@@ -0,0 +1 @@
{}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

+74
View File
@@ -0,0 +1,74 @@
{
"name": "media-downloader",
"version": "2.0.0",
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild -f -w better-sqlite3",
"dev": "cross-env NODE_ENV=development node script/dev.mjs",
"build": "cross-env NODE_ENV=production node script/build.mjs",
"pack": "cross-env NODE_ENV=development node script/release.mjs",
"release": "cross-env NODE_ENV=production node script/release.mjs"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@electron/rebuild": "^3.2.13",
"@rushstack/eslint-patch": "^1.3.2",
"@types/fs-extra": "^11.0.1",
"@types/koa": "^2.13.6",
"@types/koa-range": "^0.3.2",
"@types/koa-send": "^4.1.3",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/lodash": "^4.14.195",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "5.60.0",
"@typescript-eslint/parser": "5.60.0",
"chokidar": "^3.5.3",
"cross-env": "^7.0.3",
"dotenv": "^16.3.1",
"electron": "^25.2.0",
"electron-builder": "^24.4.0",
"electron-updater": "^6.1.1",
"esbuild": "^0.18.6",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard-with-typescript": "^35.0.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-n": "15.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "6.1.1",
"prebuild-install": "^7.1.1",
"prettier": "2.8.8",
"semver": "^7.5.2",
"typescript": "^5.1.3"
},
"dependencies": {
"@cliqz/adblocker-electron": "^1.26.6",
"@cliqz/adblocker-electron-preload": "^1.26.6",
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"@types/koa-static": "^4.0.2",
"axios": "^1.4.0",
"better-sqlite3": "^8.4.0",
"cross-fetch": "^3.1.6",
"dayjs": "^1.11.8",
"electron-devtools-installer": "^3.2.0",
"electron-is-dev": "^2.0.0",
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"fs-extra": "^11.1.1",
"glob": "^10.3.0",
"iconv-lite": "^0.6.3",
"inversify": "^6.0.1",
"koa": "^2.14.2",
"koa-range": "^0.3.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.17"
}
}
+30
View File
@@ -0,0 +1,30 @@
import * as esbuild from "esbuild";
import { rmSync } from "node:fs";
import { mainResolve, loadDotEnvDefined } from "./utils.mjs";
const mainDefined = loadDotEnvDefined();
rmSync(mainResolve("build"), { recursive: true, force: true });
rmSync(mainResolve("dist"), { recursive: true, force: true });
esbuild.build({
entryPoints: [mainResolve("src/index.ts"), mainResolve("src/preload.ts")],
bundle: true,
platform: "node",
sourcemap: false,
target: ["node16.13"],
external: [
"electron",
"nock",
"aws-sdk",
"mock-aws-s3",
"@cliqz/adblocker-electron-preload",
],
define: {
"process.env.NODE_ENV": '"production"',
...mainDefined,
},
outdir: mainResolve("build/main"),
loader: { ".png": "file" },
minify: true,
});
+90
View File
@@ -0,0 +1,90 @@
import { spawn } from "child_process";
import { cpSync } from "node:fs";
import electron from "electron";
import * as esbuild from "esbuild";
import chokidar from "chokidar";
import { loadDotEnvRuntime, mainResolve, log } from "./utils.mjs";
let electronProcess = null;
process.env.NODE_ENV = "development";
loadDotEnvRuntime();
async function copySource() {
const oldSqlite3Path = mainResolve(
"node_modules/better-sqlite3/build/Release"
);
const newSqlite3Path = mainResolve("build/Release");
cpSync(oldSqlite3Path, newSqlite3Path, {
recursive: true,
});
const oldBinPath = mainResolve("bin");
const newBinPath = mainResolve("build/bin");
cpSync(oldBinPath, newBinPath, {
recursive: true,
});
}
const ctx = await esbuild.context({
entryPoints: [mainResolve("src/index.ts"), mainResolve("src/preload.ts")],
bundle: true,
platform: "node",
sourcemap: true,
target: ["node16.13"],
external: [
"electron",
"mock-aws-s3",
"aws-sdk",
"nock",
"@cliqz/adblocker-electron-preload",
],
define: {
// 开发环境中二进制可执行文件的路径
__bin__: `"${mainResolve("bin").replace(/\\/g, "\\\\")}"`,
},
plugins: [],
outdir: mainResolve("build/main"),
loader: { ".png": "file" },
});
const watcher = chokidar.watch("./src");
watcher.on("change", async () => {
await ctx.rebuild();
log("watch build succeed.");
if (electronProcess && electronProcess.kill) {
if (process.platform === "darwin") {
spawn("kill", ["-9", electronProcess.pid]);
} else {
process.kill(electronProcess.pid);
}
electronProcess = null;
startElectron();
}
});
function startElectron() {
const args = ["--inspect=5858", mainResolve("build/main/index.js")];
electronProcess = spawn(String(electron), args);
electronProcess.stdout.on("data", (data) => {
log(String(data));
});
electronProcess.stderr.on("data", (data) => {
log(String(data));
});
}
try {
await ctx.rebuild();
await copySource();
await startElectron();
} catch (e) {
console.error(e);
process.exit();
}
+122
View File
@@ -0,0 +1,122 @@
import builder from "electron-builder";
import { readFileSync } from "node:fs";
import semver from "semver";
import { mainResolve, loadDotEnvRuntime, log } from "./utils.mjs";
const packageJson = JSON.parse(readFileSync(mainResolve("./package.json")));
loadDotEnvRuntime();
if (semver.neq(process.env.APP_VERSION, packageJson.version)) {
log("请先同步构建版本和发布版本");
process.exit(0);
}
const extraResources = [
{
from: "build/mobile",
to: "mobile",
},
];
if (process.platform === "win32") {
// windows
extraResources.push("bin/ffmpeg.exe", "bin/N_m3u8DL-CLI_v3.0.2.exe");
} else {
// mac
extraResources.push("bin/N_m3u8DL-RE", "bin/ffmpeg");
}
// Let's get that intellisense working
/**
* @type {import('electron-builder').Configuration}
* @see https://www.electron.build/configuration/configuration
*/
const options = {
productName: process.env.APP_NAME,
buildVersion: process.env.APP_VERSION,
appId: process.env.APP_ID,
copyright: process.env.APP_COPYRIGHT,
artifactName: "${productName}-setup-${buildVersion}.${ext}",
directories: {
output: "./dist",
},
files: [
{
from: "./build",
to: "./",
filter: ["**/*"],
},
"./package.json",
{
from: "./node_modules/better-sqlite3/build/Release",
to: "./build/Release",
},
],
extraResources,
win: {
icon: "../assets/icon.ico",
target: [
{
target: "nsis",
},
],
},
dmg: {
contents: [
{
x: 410,
y: 150,
type: "link",
path: "/Applications",
},
{
x: 130,
y: 150,
type: "file",
},
],
},
mac: {
icon: "../assets/icon.icns",
target: {
target: "default",
arch: ["x64", "arm64"],
},
},
linux: {},
nsis: {
oneClick: true,
allowElevation: true,
allowToChangeInstallationDirectory: false,
installerIcon: "",
uninstallerIcon: "",
installerHeaderIcon: "",
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: "",
include: "",
script: "",
},
publish: {
provider: "github",
repo: "m3u8-downloader",
owner: "caorushizi",
releaseType: "prerelease",
},
};
const target =
process.env.NODE_ENV === "development"
? builder.DIR_TARGET
: builder.DEFAULT_TARGET;
try {
const targets =
process.platform === "win32"
? builder.Platform.WINDOWS.createTarget(target)
: builder.Platform.MAC.createTarget(target);
await builder.build({
targets,
config: options,
});
} catch (e) {
log(e);
}
+65
View File
@@ -0,0 +1,65 @@
import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// FIXME: 有没有什么办法可以不用这么写?
const con = console;
export const log = con.log;
const __dirname = dirname(fileURLToPath(import.meta.url));
export const mainResolve = (r) => resolve(__dirname, "..", r);
export const rootResolve = (r) => resolve(__dirname, "../../..", r);
const nodeEnv = process.env.NODE_ENV;
log("当前的环境是: ", nodeEnv);
function loadEnv(path) {
const result = {};
const _loadEnv = (path) => {
if (!existsSync(path)) {
return null;
}
const { error, parsed } = dotenv.config({ path });
if (error != null) {
return null;
}
Object.keys(parsed).forEach((key) => {
result[key] = parsed[key];
});
};
_loadEnv(path);
_loadEnv(`${path}.local`);
return result;
}
function loadDotEnv() {
const env = loadEnv(rootResolve(".env"));
const envMode = loadEnv(rootResolve(`.env.${nodeEnv}`));
return { ...env, ...envMode };
}
export function loadDotEnvRuntime() {
const env = loadDotEnv();
Object.keys(env).forEach((key) => {
if (!process.env[key]) {
process.env[key] = env[key];
}
});
}
export function loadDotEnvDefined() {
const env = loadDotEnv();
return Object.keys(env).reduce((prev, cur) => {
if (!cur.startsWith("APP_")) return prev;
prev[`process.env.${[cur]}`] = JSON.stringify(env[cur]);
return prev;
}, {});
}
+80
View File
@@ -0,0 +1,80 @@
import { inject, injectable } from "inversify";
import {
DatabaseService,
DownloadStatus,
IpcHandlerService,
MainWindowService,
ProtocolService,
UpdateService,
VideoRepository,
WebviewService,
type App,
WebService,
DevToolsService,
StoreService,
} from "./interfaces";
import { TYPES } from "./types";
import { app, nativeTheme } from "electron";
@injectable()
export default class ElectronApp implements App {
constructor(
@inject(TYPES.MainWindowService)
private readonly mainWindow: MainWindowService,
@inject(TYPES.ProtocolService)
private readonly protocolService: ProtocolService,
@inject(TYPES.UpdateService)
private readonly updateService: UpdateService,
@inject(TYPES.IpcHandlerService)
private readonly ipcHandler: IpcHandlerService,
@inject(TYPES.DatabaseService)
private readonly dataService: DatabaseService,
@inject(TYPES.WebviewService)
private readonly webview: WebviewService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.DevToolsService)
private readonly devTools: DevToolsService,
@inject(TYPES.WebService)
private readonly webService: WebService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {}
private async seriveInit(): Promise<void> {
this.protocolService.create();
await this.dataService.init();
this.mainWindow.init();
this.ipcHandler.init();
this.updateService.init();
this.webview.init(), this.devTools.init();
this.webService.init();
}
async init(): Promise<void> {
await this.seriveInit();
app.on("activate", () => {
this.mainWindow.init();
});
this.initAppTheme();
this.resetDownloadStatus();
}
initAppTheme(): void {
const theme = this.storeService.get("theme");
nativeTheme.themeSource = theme;
}
// 如果重启后还有正在下载的视频,就将状态改成下载失败
async resetDownloadStatus(): Promise<void> {
// 重启后如果还有 downloading 状态的数据, 全部重置为失败
const videos = await this.videoRepository.findWattingAndDownloadingVideos();
const videoIds = videos.map((video) => video.id);
await this.videoRepository.changeVideoStatus(
videoIds,
DownloadStatus.Failed
);
}
}
@@ -0,0 +1,98 @@
import { IpcMainEvent } from "electron/main";
import { inject, injectable } from "inversify";
import { handle } from "../helper/decorator";
import {
StoreService,
LoggerService,
type Controller,
VideoRepository,
DownloadItem,
DownloadItemPagination,
DownloadService,
Task,
DownloadStatus,
} from "../interfaces";
import { TYPES } from "../types";
import { spawnDownload } from "helper";
import MainWindowServiceImpl from "services/MainWindowServiceImpl";
@injectable()
export default class DownloadController implements Controller {
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.DownloadService)
private readonly downloadService: DownloadService,
@inject(TYPES.MainWindowService)
private readonly mainWindow: MainWindowServiceImpl
) {}
@handle("add-download-item")
async addDownloadItem(e: IpcMainEvent, video: DownloadItem) {
const item = await this.videoRepository.addVideo(video);
// 这里向页面发送消息,通知页面更新
this.mainWindow.window?.webContents.send("download-item-notifier", item);
return item;
}
@handle("edit-download-item")
async editDownloadItem(e: IpcMainEvent, video: DownloadItem) {
const item = await this.videoRepository.editVideo(video);
return item;
}
@handle("download-now")
async downloadNow(e: IpcMainEvent, video: DownloadItem) {
// 添加下载项
const item = await this.addDownloadItem(e, video);
// 开始下载
await this.startDownload(e, item.id);
return item;
}
@handle("get-download-items")
async getDownloadItems(e: IpcMainEvent, pagination: DownloadItemPagination) {
return await this.videoRepository.findVideos(pagination);
}
@handle("start-download")
async startDownload(e: IpcMainEvent, vid: number) {
// 查找将要下载的视频
const video = await this.videoRepository.findVideo(vid);
if (!video) {
return Promise.reject("没有找到该视频");
}
const { name, url } = video;
const local = this.storeService.get("local");
// 从配置中添加参数
const deleteSegments = this.storeService.get("deleteSegments");
const task: Task = {
id: vid,
params: {
url,
local,
name,
deleteSegments,
},
process: spawnDownload,
};
await this.videoRepository.changeVideoStatus(vid, DownloadStatus.Watting);
this.downloadService.addTask(task);
}
@handle("stop-download")
async stopDownload(e: IpcMainEvent, id: number) {
this.downloadService.stopTask(id);
}
@handle("delete-download-item")
async deleteDownloadItem(e: IpcMainEvent, id: number) {
return await this.videoRepository.deleteDownloadItem(id);
}
}
@@ -0,0 +1,257 @@
import {
dialog,
IpcMainEvent,
Menu,
MenuItem,
MenuItemConstructorOptions,
nativeTheme,
shell,
} from "electron";
import { Favorite } from "entity/Favorite";
import { convertToAudio } from "helper";
import { db, workspace } from "helper/variables";
import { inject, injectable } from "inversify";
import { AppStore, EnvPath } from "main";
import path from "path";
import { handle, getLocalIP } from "../helper";
import {
StoreService,
LoggerService,
type Controller,
FavoriteRepository,
MainWindowService,
VideoRepository,
BrowserWindowService,
PlayerWindowService,
WebviewService,
} from "../interfaces";
import { TYPES } from "../types";
import fs from "fs-extra";
@injectable()
export default class HomeController implements Controller {
private sharedState: Record<string, any> = {};
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService,
@inject(TYPES.FavoriteRepository)
private readonly favoriteRepository: FavoriteRepository,
@inject(TYPES.MainWindowService)
private readonly mainWindow: MainWindowService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.BrowserWindowService)
private readonly browserWindow: BrowserWindowService,
@inject(TYPES.PlayerWindowService)
private readonly playerWindow: PlayerWindowService,
@inject(TYPES.WebviewService)
private readonly webviewService: WebviewService
) {}
@handle("get-env-path")
async getEnvPath(): Promise<EnvPath> {
return {
binPath: __bin__,
dbPath: db,
workspace: workspace,
platform: process.platform,
local: this.storeService.get("local"),
};
}
@handle("get-favorites")
getFavorites() {
return this.favoriteRepository.findFavorites();
}
@handle("add-favorite")
addFavorite(e: IpcMainEvent, favorite: Favorite) {
return this.favoriteRepository.addFavorite(favorite);
}
@handle("remove-favorite")
removeFavorite(e: IpcMainEvent, id: number): Promise<void> {
return this.favoriteRepository.removeFavorite(id);
}
@handle("get-app-store")
getAppStore() {
return this.storeService.store;
}
@handle("on-favorite-item-context-menu")
async onFavoriteItemContextMenu(e: IpcMainEvent, id: number) {
const send = (action: string) => {
const window = this.mainWindow.window;
if (!window) return;
window.webContents.send("favorite-item-event", {
action,
payload: id,
});
};
const template: Array<MenuItemConstructorOptions | MenuItem> = [
{
label: "打开",
click: () => {
send("open");
},
},
{ type: "separator" },
{
label: "删除",
click: () => {
send("delete");
},
},
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
}
@handle("select-download-dir")
async selectDownloadDir(): Promise<string> {
const window = this.mainWindow.window;
if (!window) return Promise.reject("未找到主窗口");
const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory"],
});
if (!result.canceled) {
const dir = result.filePaths[0];
this.storeService.set("local", dir);
return dir;
}
return "";
}
@handle("set-app-store")
async setAppStore(e: IpcMainEvent, key: keyof AppStore, val: any) {
// useProxy
if (key === "useProxy") {
const proxy = this.storeService.get("proxy");
this.webviewService.setProxy(val, proxy);
}
// proxy
if (key === "proxy") {
const useProxy = this.storeService.get("useProxy");
useProxy && this.webviewService.setProxy(true, val);
}
// block
if (key === "blockAds") {
this.webviewService.setBlocking(val);
}
// theme
if (key === "theme") {
nativeTheme.themeSource = val;
}
this.storeService.set(key, val);
}
@handle("open-dir")
async openDir(e: IpcMainEvent, dir: string) {
await shell.openPath(dir);
}
@handle("open-url")
async openUrl(e: IpcMainEvent, url: string) {
await shell.openExternal(url);
}
@handle("on-download-list-context-menu")
async downloadListContextMenu(e: IpcMainEvent, id: number) {
const send = (action: string) => {
const window = this.mainWindow.window;
if (!window) return;
window.webContents.send("download-item-event", {
action,
payload: id,
});
};
const template: Array<MenuItemConstructorOptions | MenuItem> = [
{
label: "选择",
click: () => {
send("select");
},
},
{
label: "下载",
click: () => {
send("download");
},
},
{
label: "刷新",
click: () => {
send("refresh");
},
},
{ type: "separator" },
{
label: "删除",
click: () => {
send("delete");
},
},
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
}
@handle("convert-to-audio")
async convertToAudio(e: IpcMainEvent, id: number) {
const video = await this.videoRepository.findVideo(id);
const local = this.storeService.get("local");
const input = path.join(local, `${video?.name}.mp4`);
const output = path.join(local, `${video?.name}.mp3`);
const exist = await fs.exists(input);
if (exist) {
return await convertToAudio(input, output);
} else {
return Promise.reject("未找到文件,可能是文件已经删除");
}
}
@handle("show-browser-window")
async showBrowserWindow() {
this.browserWindow.showWindow();
}
@handle("combine-to-home-page")
async combineToHomePage() {
// 关闭浏览器窗口
this.browserWindow.hideWindow();
// 修改设置中的属性
this.storeService.set("openInNewWindow", false);
}
@handle("open-player-window")
async openPlayerWindow(event: IpcMainEvent, id: number) {
// 打开播放器窗口
this.playerWindow.openWindow(id);
}
@handle("get-local-ip")
async getLocalIp() {
return getLocalIP();
}
@handle("get-shared-state")
async getSharedState() {
return this.sharedState;
}
@handle("set-shared-state")
async setSharedState(event: IpcMainEvent, state: any) {
this.sharedState = state;
}
}
@@ -0,0 +1,50 @@
import { IpcMainEvent } from "electron";
import { inject, injectable } from "inversify";
import { handle } from "../helper/decorator";
import { LoggerService, type Controller, WebviewService } from "../interfaces";
import { TYPES } from "../types";
@injectable()
export default class WebviewController implements Controller {
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.WebviewService)
private readonly webview: WebviewService
) {}
@handle("set-webview-bounds")
async setWebviewBounds(e: IpcMainEvent, bounds: Electron.Rectangle) {
this.webview.setBounds(bounds);
}
@handle("webview-load-url")
async browserViewLoadUrl(e: IpcMainEvent, url?: string): Promise<void> {
await this.webview.loadURL(url);
}
@handle("webview-go-back")
async webviewGoBack(): Promise<boolean> {
return this.webview.goBack();
}
@handle("webview-reload")
async webviewReload() {
await this.webview.reload();
}
@handle("webview-show")
async webviewShow() {
this.webview.show();
}
@handle("webview-hide")
async webviewHide() {
this.webview.hide();
}
@handle("webview-go-home")
async webviewGoHome() {
await this.webview.goHome();
}
}
+39
View File
@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({
name: "favorite",
})
export class Favorite {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: "text",
nullable: false,
})
title: string;
@Column({
type: "text",
nullable: false,
})
url: string;
@Column({
type: "text",
nullable: true,
})
icon?: string;
@CreateDateColumn()
createdDate: Date;
@UpdateDateColumn()
updatedDate: Date;
}
+41
View File
@@ -0,0 +1,41 @@
import { DownloadStatus } from "interfaces";
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({
name: "video",
})
export class Video {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: "text",
nullable: true,
})
name: string;
@Column({
type: "text",
nullable: false,
})
url: string;
@Column({
type: "text",
nullable: false,
default: DownloadStatus.Ready,
})
status: DownloadStatus;
@CreateDateColumn()
createdDate: Date;
@UpdateDateColumn()
updatedDate: Date;
}
+6
View File
@@ -0,0 +1,6 @@
/* eslint-disable no-var */
declare global {
var __bin__: string;
}
export {};
+13
View File
@@ -0,0 +1,13 @@
export const handle = (route: string) => {
return (target: any, propertyKey: string): void => {
Reflect.defineMetadata("ipc-method", "handle", target, propertyKey);
Reflect.defineMetadata("ipc-channel", route, target, propertyKey);
};
};
export const on = (route: string) => {
return (target: any, propertyName: string): void => {
Reflect.defineMetadata("ipc-method", "on", target, propertyName);
Reflect.defineMetadata("ipc-channel", route, target, propertyName);
};
};
+120
View File
@@ -0,0 +1,120 @@
import { spawn } from "child_process";
import { ffmpegPath, macDownloaderPath, winDownloaderPath } from "./variables";
import iconv from "iconv-lite";
import { event, stripColors } from "./utils";
import { DownloadParams, DownloadProgress } from "interfaces";
export const spawnDownload = (params: DownloadParams): Promise<void> => {
if (process.platform === "win32") {
return winSpawnDownload(params);
} else {
return macSpawnDownload(params);
}
};
const winSpawnDownload = (params: DownloadParams): Promise<void> => {
const { id, abortSignal, url, local, name, deleteSegments } = params;
const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g;
return new Promise((resolve, reject) => {
const spawnParams = [url, "--workDir", local, "--saveName", name];
if (deleteSegments) {
spawnParams.push("--enableDelAfterDone");
}
const downloader = spawn(winDownloaderPath, spawnParams, {
signal: abortSignal.signal,
});
downloader.stdout.on("data", (data) => {
const str = iconv.decode(Buffer.from(data), "gbk");
str.split("\n").forEach((item) => {
if (item.trim() == "") {
return;
}
const result = progressReg.exec(item);
if (!result) {
return;
}
const [, cur, total, speed] = result;
const progress: DownloadProgress = { id, cur, total, speed };
event.emit("download-progress", progress);
});
});
downloader.on("error", (err) => {
reject(err);
});
downloader.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error("未知错误"));
}
});
});
};
const macSpawnDownload = (params: DownloadParams): Promise<void> => {
const { id, abortSignal, url, local, name, deleteSegments } = params;
const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
return new Promise((resolve, reject) => {
const spawnParams = [
url,
"--tmp-dir",
local,
"--save-dir",
local,
"--save-name",
name,
];
if (deleteSegments) {
spawnParams.push("--del-after-done");
}
const downloader = spawn(macDownloaderPath, spawnParams, {
signal: abortSignal.signal,
});
downloader.stdout.on("data", (data) => {
const str = String(Buffer.from(data));
str.split("\n").forEach((item) => {
if (item.trim() == "") {
return;
}
const result = progressReg.exec(stripColors(item));
if (!result) {
return;
}
const [, precentage, speed] = result;
const cur = String(Number(precentage) * 10);
if (cur === "0") {
return;
}
const total = "1000";
const progress: DownloadProgress = { id, cur, total, speed };
event.emit("download-progress", progress);
});
});
downloader.on("error", (err) => {
reject(err);
});
downloader.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error("未知错误"));
}
});
});
};
+39
View File
@@ -0,0 +1,39 @@
import { spawn } from "child_process";
import { ffmpegPath } from "./variables";
export const convertToAudio = async (
input: string,
output: string
): Promise<void> => {
return new Promise((resolve, reject) => {
const ffmpeg = spawn(ffmpegPath, [
"-y",
"-v",
"error",
"-i",
input,
"-acodec",
"mp3",
"-format",
"mp3",
output,
]);
let errData = "";
ffmpeg.stderr.on("data", (data) => {
errData += String(data);
});
ffmpeg.on("error", (err) => {
reject(err);
});
ffmpeg.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(errData));
}
});
});
};
+30
View File
@@ -0,0 +1,30 @@
import os from "os";
export function getLocalIP() {
const interfaces = os.networkInterfaces();
let localIP = "";
// 遍历网络接口
for (const key in interfaces) {
const iface = interfaces[key];
if (!iface) continue;
// 过滤出 IPv4 地址且非回环地址
const filteredIface = iface.filter(
(details) => details.family === "IPv4" && !details.internal
);
if (filteredIface.length > 0) {
localIP = filteredIface[0].address;
break;
}
}
return localIP;
}
export { sleep } from "./utils";
export * from "./variables";
export { on, handle } from "./decorator";
export { spawnDownload } from "./download";
export { convertToAudio } from "./ffmpeg";
+15
View File
@@ -0,0 +1,15 @@
import EventEmitter from "events";
export async function sleep(second = 1): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, second * 1000));
}
export function stripColors(str: string) {
// 匹配控制台颜色字符的正则表达式
// eslint-disable-next-line no-control-regex
const colorRegex = /\x1b\[(\d+)(;\d+)*m/g;
// 将所有颜色字符替换为空字符串
return str.replace(colorRegex, "");
}
export const event = new EventEmitter();
+26
View File
@@ -0,0 +1,26 @@
import { app } from "electron";
import isDev from "electron-is-dev";
import path from "path";
if (!isDev) {
global.__bin__ = path.resolve(app.getAppPath(), "../bin");
}
export const appData = app.getPath("appData");
export const appName = process.env.APP_NAME || "electron-template";
export const workspace = path.resolve(appData, appName);
export const defaultScheme = "mediago";
export const download = app.getPath("downloads");
export const PERSIST_MEDIAGO = "persist:mediago";
export const PERSIST_WEBVIEW = "persist:webview";
export const db = path.resolve(workspace, "app.db");
export const macDownloaderPath = path.resolve(__bin__, "N_m3u8DL-RE");
export const winDownloaderPath = path.resolve(
__bin__,
"N_m3u8DL-CLI_v3.0.2.exe"
);
export const ffmpegPath =
process.platform === "win32"
? path.resolve(__bin__, "ffmpeg.exe")
: path.resolve(__bin__, "ffmpeg");
export const mobilePath = path.resolve(app.getAppPath(), "../mobile");
+34
View File
@@ -0,0 +1,34 @@
import "reflect-metadata";
import { app, protocol } from "electron";
import { defaultScheme } from "./helper/variables";
import { type App } from "./interfaces";
import { container } from "./inversify.config";
import { TYPES } from "./types";
const gotTheLock = app.requestSingleInstanceLock();
const start = async (): Promise<void> => {
if (!gotTheLock) {
app.quit();
}
protocol.registerSchemesAsPrivileged([
{
scheme: defaultScheme,
privileges: {
secure: true,
standard: true,
},
},
]);
await app.whenReady();
const mediago = container.get<App>(TYPES.App);
mediago.init();
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
};
void start();
+163
View File
@@ -0,0 +1,163 @@
import { type BrowserWindow } from "electron";
import { ElectronLog } from "electron-log";
import Store from "electron-store";
import { Favorite } from "entity/Favorite";
import { Video } from "entity/Video";
import EventEmitter from "events";
import { AppStore } from "main";
import { DataSource, EntityManager, UpdateResult, DeleteResult } from "typeorm";
export interface MainWindowService {
show: boolean;
window: BrowserWindow | null;
init: () => void;
}
export interface PlayerWindowService {
openWindow: (id: number) => void;
}
export interface BrowserWindowService {
show: boolean;
window: BrowserWindow | null;
showWindow: () => void;
hideWindow: () => void;
}
export interface App {
init: () => void;
}
export interface IpcHandlerService {
init: () => void;
}
export interface ProtocolService {
create: () => void;
}
export interface UpdateService {
init: () => void;
}
export interface DatabaseService {
manager: EntityManager;
appDataSource: DataSource;
init: () => void;
}
export type Controller = Record<string | symbol, any>;
export interface LoggerService {
logger: ElectronLog;
info: (...args: any[]) => void;
error: (...args: any[]) => void;
warn: (...args: any[]) => void;
debug: (...args: any[]) => void;
}
export interface StoreService extends Store<AppStore> {
init: () => void;
}
export interface DownloadItem {
id?: number;
name: string;
url: string;
}
export enum DownloadFilter {
list = "list",
done = "done",
}
export interface DownloadItemPagination {
current?: number;
pageSize?: number;
filter?: DownloadFilter;
}
export interface VideoResponse {
total: number;
list: DownloadItem[];
}
export interface VideoRepository {
addVideo: (video: DownloadItem) => Promise<Video>;
editVideo: (video: DownloadItem) => Promise<Video>;
findVideos: (pagiantion: DownloadItemPagination) => Promise<VideoResponse>;
findVideo: (id: number) => Promise<Video | null>;
changeVideoStatus: (
id: number | number[],
status: DownloadStatus
) => Promise<UpdateResult>;
findWattingAndDownloadingVideos: () => Promise<Video[]>;
deleteDownloadItem: (id: number) => Promise<DeleteResult>;
findAllVideos(): Promise<Video[]>;
}
export interface FavoriteRepository {
findFavorites: () => Promise<Favorite[]>;
addFavorite: (favorite: Favorite) => Promise<Favorite>;
removeFavorite: (id: number) => Promise<void>;
}
export interface WebviewService {
init: () => void;
getBounds: () => Electron.Rectangle;
setAutoResize: (options: Electron.AutoResizeOptions) => void;
setBackgroundColor: (color: string) => void;
setBounds: (bounds: Electron.Rectangle) => void;
loadURL: (url?: string) => Promise<void>;
goBack: () => Promise<boolean>;
reload: () => Promise<void>;
goHome: () => Promise<void>;
hide: () => void;
show: () => void;
setProxy: (useProxy: boolean, proxy: string) => void;
setBlocking: (enableBlocking: boolean) => void;
}
export interface WebService {
init: () => void;
}
export enum DownloadStatus {
Ready = "ready",
Watting = "watting",
Downloading = "downloading",
Stopped = "stopped",
Success = "success",
Failed = "failed",
}
export interface DownloadService extends EventEmitter {
addTask: (task: Task) => Promise<void>;
stopTask: (id: number) => Promise<void>;
}
export type Task = {
id: number;
params: Omit<DownloadParams, "id" | "abortSignal">;
process: (params: DownloadParams) => Promise<void>;
};
export interface DownloadProgress {
id: number;
cur: string;
total: string;
speed: string;
}
export interface DownloadParams {
id: number;
url: string;
local: string;
name: string;
abortSignal: AbortController;
deleteSegments?: boolean;
}
export interface DevToolsService {
init: () => Promise<void>;
}
+82
View File
@@ -0,0 +1,82 @@
import DownloadController from "controller/DownloadController";
import HomeController from "controller/HomeController";
import WebviewController from "controller/WebviewController";
import { Container } from "inversify";
import FavoriteRepositoryImpl from "repository/favoriteRepositoryImpl";
import VideoRepositoryImpl from "repository/videoRepositoryImpl";
import BrowserWindowServiceImpl from "services/BrowserWindowServiceImpl";
import DatabaseServiceImpl from "services/DatabaseServiceImpl";
import DevToolsServiceImpl from "services/DevToolsServiceImpl";
import DownloadServiceImpl from "services/DownloadServiceImpl";
import StoreServiceImpl from "services/StoreServiceImpl";
import WebviewServiceImpl from "services/WebviewServiceImpl";
import ElectronApp from "./app";
import {
App,
StoreService,
Controller,
IpcHandlerService,
LoggerService,
MainWindowService,
ProtocolService,
UpdateService,
VideoRepository,
DatabaseService,
FavoriteRepository,
WebviewService,
BrowserWindowService,
DevToolsService,
WebService,
PlayerWindowService,
} from "./interfaces";
import IpcHandlerServiceImpl from "./services/IpcHandlerServiceImpl";
import LoggerServiceImpl from "./services/LoggerServiceImpl";
import MainWindowServiceImpl from "./services/MainWindowServiceImpl";
import ProtocolServiceImpl from "./services/ProtocolServiceImpl";
import UpdateServiceImpl from "./services/UpdateServiceImpl";
import { TYPES } from "./types";
import WebServiceImpl from "services/WebServiceImpl";
import PlayerWindowServiceImpl from "services/PlayerWindowServiceImpl";
const container = new Container({
skipBaseClassChecks: true,
defaultScope: "Singleton",
autoBindInjectable: true,
});
container
.bind<MainWindowService>(TYPES.MainWindowService)
.to(MainWindowServiceImpl);
container
.bind<BrowserWindowService>(TYPES.BrowserWindowService)
.to(BrowserWindowServiceImpl);
container.bind<App>(TYPES.App).to(ElectronApp);
container
.bind<IpcHandlerService>(TYPES.IpcHandlerService)
.to(IpcHandlerServiceImpl);
container.bind<ProtocolService>(TYPES.ProtocolService).to(ProtocolServiceImpl);
container.bind<UpdateService>(TYPES.UpdateService).to(UpdateServiceImpl);
container.bind<LoggerService>(TYPES.LoggerService).to(LoggerServiceImpl);
container.bind<StoreService>(TYPES.StoreService).to(StoreServiceImpl);
container.bind<DatabaseService>(TYPES.DatabaseService).to(DatabaseServiceImpl);
container.bind<WebviewService>(TYPES.WebviewService).to(WebviewServiceImpl);
container
.bind<DownloadServiceImpl>(TYPES.DownloadService)
.to(DownloadServiceImpl);
container.bind<DevToolsService>(TYPES.DevToolsService).to(DevToolsServiceImpl);
container.bind<WebService>(TYPES.WebService).to(WebServiceImpl);
container
.bind<PlayerWindowService>(TYPES.PlayerWindowService)
.to(PlayerWindowServiceImpl);
// === controller
container.bind<Controller>(TYPES.Controller).to(HomeController);
container.bind<Controller>(TYPES.Controller).to(WebviewController);
container.bind<Controller>(TYPES.Controller).to(DownloadController);
// === repository
container.bind<VideoRepository>(TYPES.VideoRepository).to(VideoRepositoryImpl);
container
.bind<FavoriteRepository>(TYPES.FavoriteRepository)
.to(FavoriteRepositoryImpl);
export { container };
+90
View File
@@ -0,0 +1,90 @@
import { Video, Rectangle } from "electron";
import { type Favorite } from "entity/Favorite";
import {
DownloadItem,
DownloadItemPagination,
VideoResponse,
} from "interfaces";
declare interface EnvPath {
binPath: string;
dbPath: string;
workspace: string;
platform: string;
local: string;
}
declare interface BrowserWindowInitialVal {
url?: string;
sourceList?: LinkMessage[];
}
declare interface ElectronAPI {
getEnvPath: () => Promise<EnvPath>;
getFavorites: () => Promise<Favorite>;
addFavorite: (favorite: Favorite) => Promise<Favorite>;
removeFavorite: (id: number) => Promise<void>;
setWebviewBounds: (rect: Electron.Rectangle) => Promise<void>;
webviewLoadURL: (url?: string) => Promise<void>;
webviewGoBack: () => Promise<boolean>;
webviewReload: () => Promise<void>;
webwiewGoHome: () => Promise<void>;
getAppStore: () => Promise<AppStore>;
onSelectDownloadDir: () => Promise<string>;
setAppStore: (key: keyof AppStore, val: any) => Promise<void>;
openDir: (dir: string) => Promise<void>;
addDownloadItem: (video: DownloadItem) => Promise<Video>;
getDownloadItems: (
pagiantion: DownloadItemPagination
) => Promise<VideoResponse>;
startDownload: (vid: number) => Promise<void>;
openUrl: (url: string) => Promise<void>;
stopDownload: (id: number) => Promise<void>;
onDownloadListContextMenu: (id: number) => Promise<void>;
onFavoriteItemContextMenu: (id: number) => Promise<void>;
deleteDownloadItem: (id: number) => Promise<void>;
convertToAudio: (id: number) => Promise<void>;
rendererEvent: (channel: string, funcId: string, listener: any) => void;
removeEventListener: (channel: string, funcId: string) => void;
showBrowserWindow: () => Promise<void>;
webviewHide: () => Promise<void>;
webviewShow: () => Promise<void>;
downloadNow: (video: DownloadItem) => Promise<void>;
combineToHomePage: (initialVal: BrowserStore) => Promise<void>;
editDownloadItem: (video: DownloadItem) => Promise<void>;
openPlayerWindow: (videoId: number) => Promise<void>;
getLocalIP: () => Promise<string>;
openBrowser: (url: string) => Promise<void>;
getSharedState: () => Promise<any>;
setSharedState: (state: any) => Promise<void>;
}
declare interface LinkMessage {
url: string;
title: string;
}
declare interface AppStore {
// 本地存储地址
local: string;
// 下载完成提示音
promptTone: boolean;
// 代理地址
proxy: string;
// 是否开启代理
useProxy: boolean;
// 下载完成后删除原始文件
deleteSegments: boolean;
// 新窗口打开浏览器
openInNewWindow: boolean;
mainBounds?: Rectangle;
browserBounds?: Rectangle;
blockAds: boolean;
// 主题
theme: "system" | "light" | "dark";
}
declare interface BrowserStore {
url: string;
sourceList: LinkMessage[];
}
+58
View File
@@ -0,0 +1,58 @@
import { contextBridge, ipcRenderer, shell } from "electron";
import { ElectronAPI } from "./main";
const apiFunctions: Record<string, any> = {};
const apiKey = "electron";
const handleApi: ElectronAPI = {
getEnvPath: () => ipcRenderer.invoke("get-env-path"),
getFavorites: () => ipcRenderer.invoke("get-favorites"),
addFavorite: (favorite) => ipcRenderer.invoke("add-favorite", favorite),
removeFavorite: (id) => ipcRenderer.invoke("remove-favorite", id),
setWebviewBounds: (rect) => ipcRenderer.invoke("set-webview-bounds", rect),
webviewGoBack: () => ipcRenderer.invoke("webview-go-back"),
webviewReload: () => ipcRenderer.invoke("webview-reload"),
webviewLoadURL: (url?: string) => ipcRenderer.invoke("webview-load-url", url),
webwiewGoHome: () => ipcRenderer.invoke("webview-go-home"),
getAppStore: () => ipcRenderer.invoke("get-app-store"),
onSelectDownloadDir: () => ipcRenderer.invoke("select-download-dir"),
setAppStore: (key, val) => ipcRenderer.invoke("set-app-store", key, val),
openDir: (dir) => ipcRenderer.invoke("open-dir", dir),
addDownloadItem: (video) => ipcRenderer.invoke("add-download-item", video),
getDownloadItems: (p) => ipcRenderer.invoke("get-download-items", p),
startDownload: (vid) => ipcRenderer.invoke("start-download", vid),
openUrl: (url: string) => ipcRenderer.invoke("open-url", url),
stopDownload: (id) => ipcRenderer.invoke("stop-download", id),
onDownloadListContextMenu: (id) =>
ipcRenderer.invoke("on-download-list-context-menu", id),
onFavoriteItemContextMenu: (id) =>
ipcRenderer.invoke("on-favorite-item-context-menu", id),
deleteDownloadItem: (id) => ipcRenderer.invoke("delete-download-item", id),
convertToAudio: (id) => ipcRenderer.invoke("convert-to-audio", id),
rendererEvent: (channel, funcId, listener) => {
const key = `${channel}-${funcId}`;
apiFunctions[key] = listener;
ipcRenderer.on(channel, listener);
},
removeEventListener: (channel, funcId) => {
const key = `${channel}-${funcId}`;
const fun = apiFunctions[key];
ipcRenderer.removeListener(channel, fun);
delete apiFunctions[key];
},
showBrowserWindow: () => ipcRenderer.invoke("show-browser-window"),
webviewHide: () => ipcRenderer.invoke("webview-hide"),
webviewShow: () => ipcRenderer.invoke("webview-show"),
downloadNow: (video) => ipcRenderer.invoke("download-now", video),
combineToHomePage: (store) =>
ipcRenderer.invoke("combine-to-home-page", store),
editDownloadItem: (video) => ipcRenderer.invoke("edit-download-item", video),
openPlayerWindow: (videoId) =>
ipcRenderer.invoke("open-player-window", videoId),
getLocalIP: () => ipcRenderer.invoke("get-local-ip"),
openBrowser: (url: string) => shell.openExternal(url),
getSharedState: () => ipcRenderer.invoke("get-shared-state"),
setSharedState: (state) => ipcRenderer.invoke("set-shared-state", state),
};
contextBridge.exposeInMainWorld(apiKey, handleApi);
@@ -0,0 +1,48 @@
import { inject, injectable } from "inversify";
import {
DatabaseService,
FavoriteRepository,
LoggerService,
} from "../interfaces";
import { TYPES } from "../types";
import { Favorite } from "entity/Favorite";
@injectable()
export default class FavoriteRepositoryImpl implements FavoriteRepository {
constructor(
@inject(TYPES.DatabaseService)
private readonly dataService: DatabaseService,
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {}
async findFavorites(): Promise<Favorite[]> {
return await this.dataService.manager.find(Favorite, {
order: {
createdDate: "desc",
},
});
}
async addFavorite(favorite: Favorite): Promise<Favorite> {
const exist = await this.dataService.manager.findOne(Favorite, {
where: {
url: favorite.url,
},
});
if (exist) {
throw new Error("网址已经存在");
}
const item = new Favorite();
item.title = favorite.title;
item.url = favorite.url;
item.icon = favorite.icon;
return await this.dataService.manager.save(item);
}
async removeFavorite(id: number): Promise<void> {
await this.dataService.manager.getRepository(Favorite).delete(id);
}
}
@@ -0,0 +1,109 @@
import { Video } from "../entity/Video";
import { inject, injectable } from "inversify";
import {
DatabaseService,
DownloadFilter,
DownloadItem,
DownloadItemPagination,
DownloadStatus,
LoggerService,
VideoRepository,
} from "../interfaces";
import { TYPES } from "../types";
import { In, Not } from "typeorm";
@injectable()
export default class VideoRepositoryImpl implements VideoRepository {
constructor(
@inject(TYPES.DatabaseService)
private readonly dataService: DatabaseService,
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {}
async addVideo(video: DownloadItem) {
const item = new Video();
item.name = video.name;
item.url = video.url;
return await this.dataService.manager.save(item);
}
// 编辑视频
async editVideo(video: DownloadItem) {
const item = await this.dataService.appDataSource
.getRepository(Video)
.findOneBy({ id: video.id });
if (!item) {
throw new Error("视频不存在");
}
item.name = video.name;
item.url = video.url;
return await this.dataService.manager.save(item);
}
// 查找所有视频
async findAllVideos() {
return await this.dataService.appDataSource.getRepository(Video).find({
order: {
createdDate: "DESC",
},
});
}
async findVideos(pagination: DownloadItemPagination) {
const {
current = 0,
pageSize = 50,
filter = DownloadFilter.list,
} = pagination;
const filterCondition =
filter === DownloadFilter.done
? DownloadStatus.Success
: Not(DownloadStatus.Success);
const [items, count] = await this.dataService.appDataSource
.getRepository(Video)
.findAndCount({
where: {
status: filterCondition,
},
order: {
createdDate: "DESC",
},
skip: (current - 1) * pageSize,
take: pageSize,
});
return {
total: count,
list: items,
};
}
async findVideo(id: number) {
return this.dataService.appDataSource.getRepository(Video).findOneBy({
id,
});
}
async changeVideoStatus(id: number | number[], status: DownloadStatus) {
const ids = !Array.isArray(id) ? [id] : id;
return this.dataService.appDataSource
.createQueryBuilder()
.update(Video)
.set({ status })
.where({ id: In(ids) })
.execute();
}
async findWattingAndDownloadingVideos() {
return await this.dataService.appDataSource.getRepository(Video).find({
where: {
status: In([DownloadStatus.Downloading, DownloadStatus.Watting]),
},
});
}
async deleteDownloadItem(id: number) {
return await this.dataService.appDataSource.getRepository(Video).delete(id);
}
}
@@ -0,0 +1,83 @@
import { BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { TYPES } from "../types";
import { BrowserWindowService, StoreService } from "../interfaces";
import _ from "lodash";
@injectable()
export default class BrowserWindowServiceImpl implements BrowserWindowService {
window: BrowserWindow | null = null;
private options: BrowserWindowConstructorOptions;
constructor(
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
this.options = {
width: 1100,
minWidth: 1100,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
};
}
get show() {
return !!this.window && !this.window.isDestroyed();
}
private create() {
const window = new BrowserWindow(this.options);
const url = isDev
? "http://localhost:8555/browser"
: "mediago://index.html/browser";
void window.loadURL(url);
this.storeService.onDidChange("openInNewWindow", (newValue) => {
// 向所有窗口发送通知
if (newValue === false) {
if (window && !window.isDestroyed()) {
window.close();
}
}
});
window.on("resized", this.handleResize);
return window;
}
handleResize = () => {
if (!this.window) return;
const bounds = this.window.getBounds();
this.storeService.set("browserBounds", _.omit(bounds, ["x", "y"]));
};
showWindow = () => {
if (!this.window || this.window.isDestroyed()) {
this.window = this.create();
}
this.window.show();
isDev && this.window.webContents.openDevTools();
const browserBounds = this.storeService.get("browserBounds");
if (browserBounds) {
this.window.setBounds(browserBounds);
}
};
hideWindow = () => {
if (!this.window) return;
this.window.close();
};
}
@@ -0,0 +1,36 @@
import { db } from "../helper/variables";
import { inject, injectable } from "inversify";
import { DataSource, EntityManager } from "typeorm";
import { TYPES } from "../types";
import { DatabaseService, LoggerService } from "../interfaces";
import { Video } from "../entity/Video";
import { Favorite } from "entity/Favorite";
@injectable()
export default class DatabaseServiceImpl implements DatabaseService {
appDataSource: DataSource;
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {
this.appDataSource = new DataSource({
type: "better-sqlite3",
database: db,
synchronize: true,
logging: false,
entities: [Video, Favorite],
migrations: [],
subscribers: [],
});
}
async init(): Promise<void> {
this.logger.info("数据库地址是: ", db);
await this.appDataSource.initialize();
}
get manager(): EntityManager {
return this.appDataSource.manager;
}
}
@@ -0,0 +1,34 @@
import { DevToolsService, LoggerService } from "../interfaces";
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import isDev from "electron-is-dev";
import installExtension, {
REDUX_DEVTOOLS,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer";
@injectable()
export default class DevToolsServiceImpl implements DevToolsService {
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {}
async init(): Promise<void> {
if (!isDev) {
return;
}
// 开发环境中可以通过设置环境变量来控制是否加载开发者工具
if (!process.env.LOAD_DEVTOOLS) {
return;
}
try {
this.logger.debug("当前环境为开发环境,开始加载开发者工具");
await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
this.logger.debug("加载开发者工具成功");
} catch (err: any) {
this.logger.error("加载开发者工具失败", err);
}
}
}
@@ -0,0 +1,117 @@
import EventEmitter from "events";
import { inject, injectable } from "inversify";
import {
DownloadService,
DownloadStatus,
Task,
VideoRepository,
} from "../interfaces";
import { TYPES } from "../types";
import LoggerServiceImpl from "./LoggerServiceImpl";
@injectable()
export default class DownloadServiceImpl
extends EventEmitter
implements DownloadService
{
private queue: Task[] = [];
private active: Task[] = [];
private limit = 2;
private debug = process.env.APP_DOWNLOAD_DEBUG;
private signal: Record<number, AbortController> = {};
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerServiceImpl,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
) {
super();
}
async addTask(task: Task) {
this.queue.push(task);
this.runTask();
}
async stopTask(id: number) {
if (this.signal[id]) {
this.log(`taskId: ${id} stop`);
this.signal[id].abort();
}
}
async execute(task: Task) {
try {
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Downloading
);
this.emit("download-start", task.id);
this.log(`taskId: ${task.id} start`);
const controller = new AbortController();
this.signal[task.id] = controller;
await task.process({
...task.params,
id: task.id,
abortSignal: controller,
});
delete this.signal[task.id];
this.log(`taskId: ${task.id} success`);
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Success
);
this.emit("download-success", task.id);
} catch (err: any) {
this.log(`taskId: ${task.id} failed`);
if (err.name === "AbortError") {
// 下载暂停
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Stopped
);
this.emit("download-stop", task.id);
} else {
// 下载失败
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Failed
);
this.emit("download-failed", task.id, err);
}
} finally {
// 处理当前正在活动的任务
const doneId = this.active.findIndex((i) => i.id === task.id);
this.active.splice(doneId, 1);
// 处理完成的任务
this.runTask();
// 传输完成
if (this.queue.length === 0 && this.active.length === 0) {
// this.emit("download-finish");
}
}
}
runTask() {
while (this.active.length < this.limit && this.queue.length > 0) {
const task = this.queue.shift();
if (task) {
this.active.push(task);
this.execute(task);
}
}
}
log(...args: any[]) {
if (this.debug) {
this.logger.info(`[DownloadService] `, ...args);
}
}
}
@@ -0,0 +1,76 @@
import { ipcMain } from "electron";
import { inject, injectable, multiInject } from "inversify";
import { Controller, IpcHandlerService, LoggerService } from "../interfaces";
import { TYPES } from "../types";
@injectable()
export default class IpcHandlerServiceImpl implements IpcHandlerService {
constructor(
@multiInject(TYPES.Controller)
private readonly controllers: Controller[],
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {}
private registerIpc(controller: Controller, property: string | symbol): void {
const fun = controller[property];
const channel: string = Reflect.getMetadata(
"ipc-channel",
controller,
property
);
const method: "on" | "handle" = Reflect.getMetadata(
"ipc-method",
controller,
property
);
if (typeof fun === "function" && method && channel) {
ipcMain[method](channel, async (...args: any[]) => {
try {
const handler = fun.bind(controller);
let res = handler(...args);
if (res.then) {
res = await res;
}
return this.success(res);
} catch (e: any) {
this.logger.error("处理 ipc 事件时异常: ", e);
if (e instanceof Error) {
return this.error(e.message);
} else if (typeof e === "string") {
return this.error(e);
} else {
return this.error(String(e));
}
}
});
}
}
success(data: any) {
return {
code: 0,
message: "success",
data,
};
}
error(message = "fail") {
return {
code: -1,
message,
data: null,
};
}
private bindIpcMethods(controller: Controller): void {
const Class = Object.getPrototypeOf(controller);
Object.getOwnPropertyNames(Class).forEach((property) =>
this.registerIpc(controller, property as never)
);
}
init(): void {
this.controllers.forEach((controller) => this.bindIpcMethods(controller));
}
}
@@ -0,0 +1,36 @@
import logger, { ElectronLog } from "electron-log";
import { injectable } from "inversify";
import dayjs from "dayjs";
import path from "path";
import { appName, workspace } from "../helper";
import { LoggerService } from "../interfaces";
@injectable()
export default class LoggerServiceImpl implements LoggerService {
logger: ElectronLog;
constructor() {
const datetime = dayjs().format("YYYY-MM-DD");
const logPath = path.resolve(workspace, `logs/${datetime}-${appName}.log`);
logger.transports.console.format = "{h}:{i}:{s} {text}";
logger.transports.file.getFile();
logger.transports.file.resolvePath = () => logPath;
this.logger = logger;
}
info(...args: any[]) {
return this.logger.info(...args);
}
warn(...args: any[]) {
return this.logger.warn(...args);
}
error(...args: any[]) {
return this.logger.error(...args);
}
debug(...args: any[]) {
return this.logger.debug(...args);
}
}
@@ -0,0 +1,172 @@
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Menu,
Notification,
app,
} from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { TYPES } from "../types";
import {
DownloadProgress,
DownloadService,
LoggerService,
MainWindowService,
StoreService,
VideoRepository,
} from "../interfaces";
import { event } from "helper/utils";
import _ from "lodash";
@injectable()
export default class MainWindowServiceImpl implements MainWindowService {
private readonly options: BrowserWindowConstructorOptions;
window: BrowserWindow | null = null;
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.DownloadService)
private readonly downloadService: DownloadService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
this.options = {
width: 1100,
minWidth: 1100,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
};
}
create(): BrowserWindow {
if (this.window && !this.window.isDestroyed()) {
return this.window;
}
const window = new BrowserWindow(this.options);
const url = isDev ? "http://localhost:8555/" : "mediago://index.html/";
void window.loadURL(url);
window.once("ready-to-show", this.readyToShow);
event.on("download-progress", this.onDownloadProgress);
this.downloadService.on("download-success", this.onDownloadSuccess);
this.downloadService.on("download-failed", this.onDownloadFailed);
this.downloadService.on("download-start", this.onDownloadStart);
this.downloadService.on("download-stop", this.onDownloadStart);
this.storeService.onDidAnyChange((store) => {
// 向所有窗口发送通知
window.webContents.send("store-change", store);
});
// 处理当前窗口改变大小
window.on("resized", this.handleResize);
app.on("second-instance", () => {
if (process.platform === "win32") {
if (window) {
if (window.isMinimized()) {
window.restore();
}
if (window.isVisible()) {
window.focus();
} else {
window.show();
}
}
}
});
return window;
}
get show() {
return !!this.window && !this.window.isDestroyed();
}
init(): void {
Menu.setApplicationMenu(null);
this.window = this.create();
}
handleResize = () => {
if (!this.window) return;
const bounds = this.window.getBounds();
this.storeService.set("mainBounds", _.omit(bounds, ["x", "y"]));
};
readyToShow = () => {
if (!this.window) return;
this.window.show();
isDev && this.window.webContents.openDevTools();
const mainBounds = this.storeService.get("mainBounds");
if (mainBounds) {
this.window.setBounds(mainBounds);
}
};
onDownloadProgress = (progress: DownloadProgress) => {
if (!this.window) return;
this.window.webContents.send("download-progress", progress);
};
onDownloadSuccess = async (id: number) => {
if (!this.window) return;
const promptTone = this.storeService.get("promptTone");
if (promptTone) {
const video = await this.videoRepository.findVideo(id);
new Notification({
title: "下载成功",
body: `${video?.name} 下载成功`,
}).show();
}
this.window.webContents.send("download-success", id);
};
onDownloadFailed = async (id: number, err: any) => {
if (!this.window) return;
const promptTone = this.storeService.get("promptTone");
if (promptTone) {
const video = await this.videoRepository.findVideo(id);
new Notification({
title: "下载失败",
body: `${video?.name} 下载失败`,
}).show();
}
this.logger.error("下载失败:", err);
this.window.webContents.send("download-failed", id);
};
onDownloadStart = async (id: number) => {
if (!this.window) return;
this.window.webContents.send("download-start", id);
};
onDownloadStop = async (id: number) => {
if (!this.window) return;
this.window.webContents.send("download-stop", id);
};
}
@@ -0,0 +1,54 @@
import { BrowserWindow, BrowserWindowConstructorOptions, Menu } from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { PlayerWindowService, VideoRepository } from "../interfaces";
import { TYPES } from "types";
@injectable()
export default class PlayerWindowServiceImpl implements PlayerWindowService {
private options: BrowserWindowConstructorOptions;
private window: BrowserWindow | null = null;
constructor(
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
) {
this.options = {
width: 1100,
minWidth: 1100,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
};
}
create(): BrowserWindow {
const window = new BrowserWindow(this.options);
Menu.setApplicationMenu(null);
const url = isDev
? "http://localhost:8555/player"
: "mediago://index.html/player";
void window.loadURL(url);
return window;
}
openWindow = async (id: number) => {
if (!this.window || this.window.isDestroyed()) {
this.window = this.create();
}
const video = await this.videoRepository.findVideo(id);
if (!video) throw new Error("video not found");
isDev && this.window.webContents.openDevTools();
this.window.webContents.send("open-player-window", video.id);
this.window.show();
};
}
@@ -0,0 +1,51 @@
import { protocol } from "electron";
import isDev from "electron-is-dev";
import { pathExists, readFile } from "fs-extra";
import { injectable } from "inversify";
import { extname, join } from "path";
import { URL } from "url";
import { defaultScheme } from "../helper/variables";
import { ProtocolService } from "../interfaces";
@injectable()
export default class ProtocolServiceImpl implements ProtocolService {
create(): void {
if (isDev) return;
protocol.registerBufferProtocol(
defaultScheme,
async (request, callback) => {
let pathName = new URL(request.url).pathname;
pathName = decodeURI(pathName);
const filePath = join(__dirname, "../renderer", pathName);
const fileExist = await pathExists(filePath);
if (fileExist) {
const data = await readFile(filePath);
const extension = extname(pathName).toLowerCase();
let mimeType = "";
if (extension === ".js") {
mimeType = "text/javascript";
} else if (extension === ".html") {
mimeType = "text/html";
} else if (extension === ".css") {
mimeType = "text/css";
} else if (extension === ".svg" || extension === ".svgz") {
mimeType = "image/svg+xml";
} else if (extension === ".json") {
mimeType = "application/json";
}
callback({ mimeType, data });
} else {
// 如果没有找到文件,直接返回 index.html react history 模式
const filePath = join(__dirname, "../renderer/index.html");
const data = await readFile(filePath);
callback({ mimeType: "text/html", data });
}
}
);
}
}
@@ -0,0 +1,35 @@
import Store from "electron-store";
import { injectable } from "inversify";
import { AppStore } from "main";
import { download, workspace } from "../helper/variables";
import { StoreService } from "../interfaces";
import { AppTheme } from "types";
@injectable()
export default class StoreServiceImpl
extends Store<AppStore>
implements StoreService
{
constructor() {
super({
name: "config",
cwd: workspace,
fileExtension: "json",
watch: true,
defaults: {
local: download,
promptTone: true,
proxy: "",
useProxy: false,
deleteSegments: true,
openInNewWindow: false,
blockAds: true,
theme: AppTheme.System,
},
});
}
init(): void {
// empty
}
}
@@ -0,0 +1,23 @@
import { autoUpdater } from "electron-updater";
import { inject, injectable } from "inversify";
import { LoggerService, type UpdateService } from "../interfaces";
import { TYPES } from "../types";
import isDev from "electron-is-dev";
@injectable()
export default class UpdateServiceImpl implements UpdateService {
constructor(
@inject(TYPES.LoggerService) private readonly logger: LoggerService
) {}
init(): void {
if (isDev) return;
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
autoUpdater.checkForUpdatesAndNotify({
title: "自动更新完成",
body: "下次重启时将会自动安装",
});
}
}
@@ -0,0 +1,84 @@
import { StoreService, VideoRepository, WebService } from "../interfaces";
import { inject, injectable } from "inversify";
import Koa, { Context } from "koa";
import Router from "@koa/router";
import serve from "koa-static";
import range from "koa-range";
import { TYPES } from "types";
import { glob } from "glob";
import send from "koa-send";
import cors from "@koa/cors";
import { getLocalIP, mobilePath } from "helper";
@injectable()
export default class WebServiceImpl implements WebService {
private readonly app: Koa;
private readonly router: Router;
private readonly baseUrl: string;
constructor(
@inject(TYPES.StoreService)
private readonly storeService: StoreService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
) {
this.app = new Koa();
this.router = new Router();
this.baseUrl = `http://${getLocalIP()}:${
process.env.APP_SERVER_PORT
}/video/`;
}
async init(): Promise<void> {
this.router.get("/api/video-list", this.videoList);
this.router.get("/video/:filename", this.serveVideo);
const local = this.storeService.get("local");
this.app.use(cors());
this.app.use(range);
this.app.use(serve(local));
if (process.env.NODE_ENV === "production") {
this.app.use(
serve(mobilePath, {
extensions: ["html", "js", "css"],
index: "index.html",
})
);
}
this.app.use(this.router.routes());
this.app.use(this.router.allowedMethods());
this.app.listen(process.env.APP_SERVER_PORT);
}
private videoList = async (ctx: Context) => {
const local = this.storeService.get("local");
const mp4Files = await glob("*.mp4", {
cwd: local,
});
const videoList = await this.videoRepository.findAllVideos();
const res = mp4Files
.map((file) => {
const name = file.split(".").shift();
const video = videoList.find((video) => video.name === name);
if (!video) return null;
return {
id: video.id,
name: video.name,
url: `${this.baseUrl}${file}`,
};
})
.filter(Boolean);
ctx.body = res;
};
private serveVideo = async (ctx: Context) => {
const { filename } = ctx.params;
const local = this.storeService.get("local");
const file = filename;
await send(ctx, file, { root: local, index: false });
};
}
@@ -0,0 +1,242 @@
import {
BrowserView,
HeadersReceivedResponse,
OnHeadersReceivedListenerDetails,
OnResponseStartedListenerDetails,
WebContents,
session,
} from "electron";
import {
BrowserWindowService,
LoggerService,
MainWindowService,
StoreService,
WebviewService,
} from "../interfaces";
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import isDev from "electron-is-dev";
import { PERSIST_WEBVIEW } from "helper/variables";
import { LinkMessage } from "main";
import { ElectronBlocker } from "@cliqz/adblocker-electron";
import fetch from "cross-fetch";
@injectable()
export default class WebviewServiceImpl implements WebviewService {
private _view: BrowserView;
private blocker?: ElectronBlocker;
constructor(
@inject(TYPES.MainWindowService)
private readonly mainWindow: MainWindowService,
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.BrowserWindowService)
private readonly browserWindow: BrowserWindowService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
// 初始化 blocker
// this.initBlocker();
}
private create(): void {
this._view = new BrowserView({
webPreferences: {
partition: PERSIST_WEBVIEW,
},
});
const webContents = this._view.webContents;
this._view.setBackgroundColor("#fff");
webContents.setAudioMuted(true);
const useProxy = this.storeService.get("useProxy");
const proxy = this.storeService.get("proxy");
this.setProxy(useProxy, proxy);
}
get view(): BrowserView {
if (!this._view) this.create();
return this._view;
}
onHeadersReceived = (details: OnResponseStartedListenerDetails): void => {
const { url } = details;
const sourceReg = /\.m3u8$/;
const detailsUrl = new URL(url);
if (sourceReg.test(detailsUrl.pathname)) {
this.logger.info("在窗口中捕获 m3u8 链接: ", detailsUrl.toString());
const webContents = details.webContents;
const linkMessage: LinkMessage = {
url: detailsUrl.toString(),
title: webContents?.getTitle() || "没有获取到名称",
};
this.curWindow?.webContents.send("webview-link-message", linkMessage);
}
};
async init(): Promise<void> {
this.view.webContents.on("dom-ready", () => {
const title = this.view.webContents.getTitle();
const url = this.view.webContents.getURL();
this.curWindow?.webContents.send("webview-dom-ready", { title, url });
});
this.view.webContents.setWindowOpenHandler(({ url }) => {
if (url === "about:blank") {
// 兼容一些网站跳转到 about:blank
this.view.webContents.once("will-redirect", async (event, url) => {
this.loadURL(url, true);
});
} else {
this.loadURL(url, true);
}
return { action: "deny" };
});
this.session.webRequest.onResponseStarted(
{ urls: ["<all_urls>"] },
this.onHeadersReceived
);
}
getBounds(): Electron.Rectangle {
return this.view.getBounds();
}
setAutoResize(options: Electron.AutoResizeOptions): void {
this.view.setAutoResize(options);
}
setBackgroundColor(color: string): void {
this.view.setBackgroundColor(color);
}
show() {
this.curWindow?.setBrowserView(this.view);
isDev && this.view.webContents.openDevTools();
}
hide() {
this.curWindow?.setBrowserView(null);
isDev && this.view.webContents.closeDevTools();
}
setBounds(bounds: Electron.Rectangle): void {
if (process.platform === "darwin") {
bounds.y = bounds.y + 30;
}
this.view.setBounds(bounds);
}
async loadURL(url?: string, isNewWindow?: boolean) {
const canGoBack = this.view.webContents.canGoBack();
try {
await this.view.webContents.loadURL(url || "");
} catch (err: any) {
this.logger.error("加载 url 时出现错误: ", err);
}
if (!canGoBack && !isNewWindow) {
this.view.webContents.clearHistory();
}
}
async goBack() {
if (this.view.webContents.canGoBack()) {
this.view.webContents.goBack();
return true;
} else {
return false;
}
}
async reload() {
this.view.webContents.reload();
}
async goHome() {
this.view.webContents.clearHistory();
}
get curWindow() {
if (this.browserWindow.window && this.browserWindow.show) {
return this.browserWindow.window;
}
if (this.mainWindow.window && this.mainWindow.show) {
return this.mainWindow.window;
}
return null;
}
private get session() {
return session.fromPartition(PERSIST_WEBVIEW);
}
private enableProxy(proxy: string) {
if (!proxy) {
this.logger.error("[proxy] 代理地址不能为空");
return;
}
// 处理 proxy 地址的合法性
if (!/https?:\/\//.test(proxy)) {
proxy = `http://${proxy}`;
}
this.session.setProxy({ proxyRules: proxy });
this.logger.info(`[proxy] 代理开启成功,代理地址为${proxy}`);
}
private disableProxy() {
this.session.setProxy({ proxyRules: "" });
this.logger.info("[proxy] 代理关闭成功");
}
setProxy(useProxy: boolean, proxy: string): void {
if (useProxy) {
this.enableProxy(proxy);
} else {
this.disableProxy();
}
}
setBlocking(enableBlocking: boolean): void {
if (enableBlocking) {
this.enableBlocking();
} else {
this.disableBlocking();
}
}
async initBlocker() {
this.blocker = await ElectronBlocker.fromPrebuiltAdsAndTracking(fetch);
const enableBlocking = this.storeService.get("blockAds");
this.setBlocking(enableBlocking);
}
private enableBlocking() {
if (!this.blocker) {
this.logger.error("开启 blocker 失败,未初始化");
return;
}
this.blocker.enableBlockingInSession(this.session);
this.logger.info("开启 blocker 成功");
}
private disableBlocking() {
if (!this.blocker) {
this.logger.error("关闭 blocker 失败,未初始化");
return;
}
if (!this.blocker.isBlockingEnabled(this.session)) {
return;
}
this.blocker.disableBlockingInSession(this.session);
this.logger.info("关闭 blocker 成功");
}
}
+26
View File
@@ -0,0 +1,26 @@
export const TYPES = {
App: Symbol.for("App"),
MainWindowService: Symbol.for("MainWindowService"),
StoreService: Symbol.for("StoreService"),
SessionService: Symbol.for("SessionService"),
DatabaseService: Symbol.for("DatabaseService"),
ProtocolService: Symbol.for("ProtocolService"),
UpdateService: Symbol.for("UpdateService"),
LoggerService: Symbol.for("LoggerService"),
IpcHandlerService: Symbol.for("IpcHandlerService"),
Controller: Symbol.for("Controller"),
VideoRepository: Symbol.for("VideoRepository"),
FavoriteRepository: Symbol.for("FavoriteRepository"),
WebviewService: Symbol.for("WebviewService"),
DownloadService: Symbol.for("DownloadService"),
BrowserWindowService: Symbol.for("BrowserWindowService"),
DevToolsService: Symbol.for("DevToolsService"),
WebService: Symbol.for("WebService"),
PlayerWindowService: Symbol.for("PlayerWindowService"),
};
export enum AppTheme {
System = "system",
Light = "light",
Dark = "dark",
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "CommonJS",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./src",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"strictPropertyInitialization": false,
"typeRoots": ["node_modules/@types"],
"outDir": "build/main"
},
"include": ["./src/**/*"]
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}
+31
View File
@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
test-results/
playwright-report/
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
+71
View File
@@ -0,0 +1,71 @@
# mobile-player
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
npm run build
# Runs the end-to-end tests
npm run test:e2e
# Runs the tests only on Chromium
npm run test:e2e -- --project=chromium
# Runs the tests of a specific file
npm run test:e2e -- tests/example.spec.ts
# Runs the tests in debug mode
npm run test:e2e -- --debug
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+17
View File
@@ -0,0 +1,17 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanCell: typeof import('vant/es')['Cell']
VanEmpty: typeof import('vant/es')['Empty']
VanList: typeof import('vant/es')['List']
VanNavBar: typeof import('vant/es')['NavBar']
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["./**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('div.greetings > h1')).toHaveText('You did it!');
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{
"name": "mobile",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.4.0",
"pinia": "^2.1.4",
"vant": "^4.5.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"xgplayer": "^3.0.4"
},
"devDependencies": {
"@playwright/test": "^1.35.1",
"@rushstack/eslint-patch": "^1.3.2",
"@tsconfig/node18": "^2.0.1",
"@types/jsdom": "^21.1.1",
"@types/node": "^20.3.1",
"@vitejs/plugin-vue": "^4.2.3",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.4.0",
"@vue/tsconfig": "^0.4.0",
"eslint": "^8.43.0",
"eslint-plugin-vue": "^9.15.0",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.8",
"typescript": "~5.1.3",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.3.9",
"vitest": "^0.32.2",
"vue-tsc": "^1.8.1"
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome']
}
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox']
}
},
{
name: 'webkit',
use: {
...devices['Desktop Safari']
}
}
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'vite preview --port 5173' : 'vite dev',
port: 5173,
reuseExistingServer: !process.env.CI
}
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<van-nav-bar title="视频播放" />
<RouterView />
</template>
<style scoped></style>
View File
View File
+14
View File
@@ -0,0 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+14
View File
@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue')
}
]
})
export default router
+12
View File
@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Player, { type IPlayerOptions } from 'xgplayer'
import 'xgplayer/dist/index.min.css'
import axios from 'axios'
interface VideoData {
id: number
name: string
url: string
}
const videoRef = ref(null)
const player = ref<Player | null>(null)
const list = ref<VideoData[]>([])
onMounted(async () => {
let baseUrl = location.href
if (import.meta.env.MODE === 'development') {
baseUrl = `http://${location.hostname}:8433/`
}
const res = await axios.get(`${baseUrl}api/video-list`)
if (videoRef.value) {
const options: IPlayerOptions = {
el: videoRef.value,
fluid: true,
videoInit: true
}
if (Array.isArray(res.data) && res.data.length > 0) {
list.value = res.data
options.src = list.value[0].url
}
player.value = new Player(options)
}
})
function itemClick(item: VideoData) {
if (!player.value) return
player.value.src = item.url
}
</script>
<template>
<main>
<div v-show="list.length" ref="videoRef"></div>
<div v-if="list.length">
<van-list finished-text="没有更多了">
<van-cell v-for="item in list" :key="item.id" :title="item.name" @click="itemClick(item)" />
</van-list>
</div>
<van-empty v-else description="暂无视频" />
</main>
</template>
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"types": ["node"]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
}
}
+33
View File
@@ -0,0 +1,33 @@
import { fileURLToPath, URL } from 'node:url'
import path from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
Components({
resolvers: [VantResolver()]
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
envDir: '../..',
server: {
host: true,
port: 8556
},
build: {
outDir: path.resolve(__dirname, "../main/build/mobile"),
emptyOutDir: true,
}
})
+18
View File
@@ -0,0 +1,18 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig } from 'vite'
import { configDefaults, defineConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url)),
transformMode: {
web: [/\.[jt]sx$/],
},
}
})
)
View File
+23
View File
@@ -0,0 +1,23 @@
/* eslint-env node */
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react", "@typescript-eslint", "prettier"],
rules: {
"prettier/prettier": "warn",
},
};
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
View File
+1
View File
@@ -0,0 +1 @@
{}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name=referrer content=no-referrer>
<title>media downloader</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
{
"name": "renderer",
"private": true,
"version": "0.1.0",
"description": "electron-template renderer",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.1.4",
"@ant-design/pro-components": "^2.6.2",
"@reduxjs/toolkit": "^1.9.5",
"ahooks": "^3.7.7",
"antd": "^5.6.2",
"axios": "^1.4.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.8",
"match-sorter": "^6.3.1",
"nanoid": "^4.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.1",
"react-resizable-panels": "^0.0.51",
"react-router-dom": "^6.13.0",
"sort-by": "^1.2.0",
"xgplayer": "^3.0.4"
},
"devDependencies": {
"@types/node": "^20.3.1",
"@types/react": "^18.2.13",
"@types/react-dom": "^18.2.6",
"@types/sort-by": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "5.60.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard-with-typescript": "^35.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.32.2",
"prettier": "2.8.8",
"sass": "^1.63.6",
"typescript": "~5.1.3",
"vite": "^4.3.9"
},
"license": "MIT"
}
View File
+24
View File
@@ -0,0 +1,24 @@
.container {
height: 100vh;
width: 100vw;
overflow: hidden;
.container-sider {
.like-item {
display: flex;
flex-direction: row;
align-items: center;
}
}
.container-footer {
padding: 5px;
background: #fff;
text-align: right;
border-top: #f0f0f0 solid 1px;
}
@media (prefers-color-scheme: dark) {
.container-footer {
background: #141414;
border-top: #313131 solid 1px;
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import React, { FC, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Badge, Button, Layout, Menu, MenuProps } from "antd";
import "./App.scss";
import {
DownloadOutlined,
ExportOutlined,
ProfileOutlined,
QuestionCircleOutlined,
SettingOutlined,
} from "@ant-design/icons";
import useElectron from "./hooks/electron";
import { useDispatch, useSelector } from "react-redux";
import { selectStore, setAppStore } from "./store/appSlice";
import { useAsyncEffect } from "ahooks";
import { clearCount, selectCount } from "./store/downloadSlice";
import { tdApp } from "./utils";
const { Footer, Sider, Content } = Layout;
type MenuItem = Required<MenuProps>["items"][number];
const App: FC = () => {
const {
getAppStore: ipcGetAppStore,
openUrl,
setAppStore: ipcSetAppStore,
showBrowserWindow,
rendererEvent,
removeEventListener,
} = useElectron();
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const [showExport, setShowExport] = useState(false);
const count = useSelector(selectCount);
const appStore = useSelector(selectStore);
const items: MenuItem[] = [
{
label: (
<Link
to="/"
className="like-item"
onClick={() => {
dispatch(clearCount());
}}
>
<DownloadOutlined />
<span></span>
{count > 0 && (
<Badge count={count} offset={[5, 1]} size="small"></Badge>
)}
</Link>
),
key: "home",
},
{
label: (
<Link to="/source-extract" className="like-item">
<ProfileOutlined />
<span></span>
{showExport && (
<Button
title="在新窗口中打开"
type="text"
style={{ marginLeft: "auto" }}
icon={<ExportOutlined />}
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
dispatch(setAppStore({ openInNewWindow: true }));
if (location.pathname === "/source-extract") {
navigate("/");
}
// FIXME: 有可能 webview 还没有完全隐藏
await ipcSetAppStore("openInNewWindow", true);
await showBrowserWindow();
}}
/>
)}
</Link>
),
key: "source",
onMouseEnter: () => {
setShowExport(true);
},
onMouseLeave: () => {
setShowExport(false);
},
},
{
label: (
<Link to="/settings" className="like-item">
<SettingOutlined />
<span></span>
</Link>
),
key: "settings",
},
];
// 监听store变化
const onAppStoreChange = (event: any, store: AppStore) => {
dispatch(setAppStore(store));
};
useEffect(() => {
rendererEvent("store-change", onAppStoreChange);
return () => {
removeEventListener("store-change", onAppStoreChange);
};
}, []);
const finalItems = items.filter((item) =>
appStore.openInNewWindow ? item?.key !== "source" : true
);
const openHelpUrl = () => {
tdApp.openHelpPage();
const url =
"https://blog.ziying.site/post/media-downloader-how-to-use/?form=client";
openUrl(url);
};
useAsyncEffect(async () => {
const store = await ipcGetAppStore();
dispatch(setAppStore(store));
}, []);
return (
<Layout className="container">
<Layout>
<Sider className="container-sider" theme="light">
<Menu
style={{ height: "100%" }}
defaultSelectedKeys={["home"]}
mode="vertical"
theme="light"
items={finalItems}
/>
</Sider>
<Layout>
<Content className="container-inner">
<Outlet />
</Content>
<Footer className="container-footer">
<Button
type={"link"}
onClick={openHelpUrl}
icon={<QuestionCircleOutlined />}
>
使
</Button>
</Footer>
</Layout>
</Layout>
</Layout>
);
};
export default App;
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More