Compare commits

...

106 Commits

Author SHA1 Message Date
士子☀️ c8ac4182fc Merge pull request #34 from caorushizi/release/v2.0.2 2023-07-09 13:25:36 +08:00
士子☀️ 55b70d3e6d release: v2.0.2 2023-07-09 13:24:06 +08:00
士子☀️ a19765b0d3 fix: browser loading status. 2023-07-08 23:05:13 +08:00
士子☀️ 5c7b65385a remove postinstall 2023-07-08 16:34:34 +08:00
caorushizi b6b84269a3 fix: mac app. 2023-07-08 15:55:12 +08:00
士子☀️ e44cb249d0 Merge pull request #30 from caorushizi/feat/v2.0.2 2023-07-08 12:11:51 +08:00
士子☀️ 8bea071865 release size. 2023-07-08 12:00:50 +08:00
caorushizi a782652fca fix: tray icon. 2023-07-06 22:49:11 +08:00
caorushizi 3a02803d3c fix: mac download. 2023-07-06 22:16:28 +08:00
士子☀️ 263a08ab4c Merge pull request #29 from caorushizi/feat/v2.0.2 2023-07-06 00:32:55 +08:00
士子☀️ ecdbd985ce feat: tray context menu. 2023-07-06 00:32:05 +08:00
士子☀️ dd403564cc docs: addition function. 2023-07-06 00:15:57 +08:00
士子☀️ 69d741b39a Merge pull request #28 from caorushizi/dev 2023-07-05 23:53:59 +08:00
士子☀️ 4c9c2e2d68 add: tray icon. 2023-07-05 23:52:38 +08:00
士子☀️ 8660701340 download list. 2023-07-05 22:48:41 +08:00
士子☀️ 341c5570af support live mode. 2023-07-05 01:13:29 +08:00
士子☀️ ee12753818 Update README.md 2023-07-04 12:56:52 +08:00
士子☀️ 055638278c feat: something 2023-07-04 07:52:43 +08:00
士子☀️ bf06ff6482 docs: dark mode. 2023-07-02 15:32:34 +08:00
士子☀️ 459a5bc5b7 docs: comments dark theme. 2023-07-02 14:08:04 +08:00
士子☀️ a93f18037c release: v2.0.1 2023-07-02 13:50:22 +08:00
士子☀️ 67b5218002 release: v2.0.1 2023-07-02 13:38:46 +08:00
士子☀️ d3c745dda4 docs: update. 2023-07-02 13:19:31 +08:00
士子☀️ e694818f8f docs: support dark mode. 2023-07-02 13:10:30 +08:00
士子☀️ 729d7645e1 docs 2023-07-02 12:30:58 +08:00
士子☀️ 795e21df9c docs: content done. 2023-07-02 01:22:14 +08:00
士子☀️ bdc15e6c50 feat: max runner. 2023-07-01 23:02:42 +08:00
士子☀️ c3398ec572 baidu 2023-07-01 22:08:48 +08:00
士子☀️ 6b38b348fc actions 2023-07-01 21:37:43 +08:00
士子☀️ d650529842 actions 2023-07-01 21:32:21 +08:00
士子☀️ 2094408f52 actions 2023-07-01 21:28:26 +08:00
士子☀️ 89cb8b3a3a actions 2023-07-01 21:24:36 +08:00
士子☀️ 7a2d546972 actions. 2023-07-01 21:21:57 +08:00
士子☀️ 69e801ba2b Merge pull request #25 from caorushizi/dev
Dev
2023-07-01 21:16:54 +08:00
士子☀️ 14edc27ed1 add: docs and deploy. 2023-07-01 21:14:45 +08:00
士子☀️ 6f7fa7f067 add: docs 2023-07-01 20:15:40 +08:00
caorushizi a5a1c02e10 update: mac downloader 2023-07-01 19:01:51 +08:00
士子☀️ ce08cbce4b feat: some feature. 2023-07-01 18:12:35 +08:00
士子☀️ e03a5d38bb video header & web extension 2023-07-01 14:40:49 +08:00
士子☀️ 720db5a476 video headers & params format 2023-07-01 01:27:04 +08:00
士子☀️ e97783b08e Merge branch 'dev' of https://github.com/caorushizi/m3u8-downloader into dev 2023-06-30 00:45:34 +08:00
士子☀️ 12566f8668 add webview preload script. 2023-06-30 00:44:47 +08:00
caorushizi d686819417 npmrc 2023-06-29 19:26:47 +08:00
士子☀️ ea7b4a77af feat: prettier & web extension. 2023-06-28 23:53:46 +08:00
士子☀️ 6427a06879 test: do somthing. 2023-06-27 23:54:43 +08:00
士子☀️ f2f7a50e95 refactor: window service 2023-06-27 22:22:42 +08:00
caorushizi fbc57ebfca fix: webContents is null. 2023-06-27 11:46:55 +08:00
士子☀️ 26a5681dab feat: web extension. 2023-06-26 23:34:54 +08:00
士子☀️ 4640ff51e8 feat: add web extension. 2023-06-26 00:59:36 +08:00
士子☀️ 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
132 changed files with 8346 additions and 2356 deletions
+6
View File
@@ -0,0 +1,6 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.2
APP_SERVER_PORT=8433
+2 -5
View File
@@ -1,8 +1,5 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.0
APP_TD_APPID=
APP_DOWNLOAD_DEBUG=true
DOWNLOAD_DEBUG=true
GH_TOKEN=
LOAD_DEVTOOLS=
+1 -5
View File
@@ -1,8 +1,4 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.0
APP_TD_APPID=
APP_DOWNLOAD_DEBUG=
DOWNLOAD_DEBUG=false
GH_TOKEN=
+54
View File
@@ -0,0 +1,54 @@
# build.yml
# Workflow's name
name: Build Downloader App.
# 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 }}
- uses: actions/upload-artifact@v3
with:
name: artifact
path: packages/main/release/media-downloader-setup-*
+49
View File
@@ -0,0 +1,49 @@
name: Deploy Downloader Docs.
on:
push:
branches: ["master"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
env:
BUILD_PATH: "."
BUCKET: downloader-docs
ENDPOINT: oss-cn-beijing.aliyuncs.com
ACCESS_KEY: LTAI5tLckcUrBtj7bCiUYwWz
ACCESS_KEY_SECRET: ${{ secrets.ACCESS_KEY_SECRET }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: "16"
- name: Install PNPM
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install
working-directory: ${{ env.BUILD_PATH }}
- name: Build with Vitepress
run: pnpm run docs:build
working-directory: ${{ env.BUILD_PATH }}
- name: Install Alibaba Cloud OSSUTIL
run: wget http://gosspublic.alicdn.com/ossutil/1.6.10/ossutil64 && chmod +x ossutil64
- name: Configure Alibaba Cloud OSSUTIL
run: ./ossutil64 config -i ${ACCESS_KEY} -k ${ACCESS_KEY_SECRET} -e ${ENDPOINT} -c .ossutilconfig
- name: Upload the web folder to the chosen OSS bucket
run: ./ossutil64 --config-file .ossutilconfig cp ${{ github.workspace }}/docs/.vitepress/dist oss://${BUCKET} -r -f
+1
View File
@@ -2,3 +2,4 @@ node_modules
.vscode
*.local
.idea
.parcel-cache
+3 -2
View File
@@ -1,2 +1,3 @@
registry=https://registry.npmmirror.com/
electron_mirror=https://npm.taobao.org/mirrors/electron/
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
package-manager=pnpm
+24 -7
View File
@@ -1,9 +1,7 @@
# media-downloader
m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows mac。
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……
模板: <https://github.com/caorushizi/electron-template>
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……详情请参考[使用帮助](https://downloader.caorushizi.cn/guides.html?form=github)
## 上手指南
@@ -11,7 +9,7 @@ m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows
## 安装要求
运行代码需要 node 和 pnpm,node需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
运行代码需要 node 和 pnpmnode 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
## 运行代码
@@ -23,8 +21,27 @@ m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows
## 下载链接
- [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)
**v2.0.12023.7.1 发布)**
- [windows media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.exe)
- [macos media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.dmg)
**更新日志**
- 暗黑模式
- 更多下载配置
- 支持请求标头自动带入
- 支持开启广告过滤
- 支持开启沉浸式嗅探
- 支持切换手机和 PC 模式
- 支持修改同时下载数量
- 修复了一些 bug
**v1.1.52022.2.5 发布)**
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
**更新日志**
## 软件截图
+2
View File
@@ -0,0 +1,2 @@
.vitepress/dist
.vitepress/cache
+63
View File
@@ -0,0 +1,63 @@
import { HeadConfig } from "vitepress";
import { defineConfig } from "vitepress";
const isDev = process.env.NODE_ENV === "development";
const head: HeadConfig[] = [
["link", { rel: "shortcut icon", href: "/favicon.svg" }],
];
if (!isDev) {
head.push([
"script",
{},
`var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?eefcbd14f0323044aa0ca678cd278381";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();`,
]);
}
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "media-downloader",
description: "简单易用,快速下载",
lastUpdated: true,
head,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: "Home", link: "/" },
{ text: "教程", link: "/guides" },
],
sidebar: [
{
text: "开始",
items: [
{ text: "快速开始", link: "/guides" },
{ text: "使用说明", link: "/documents" },
],
},
{
text: "其他",
items: [
{ text: "旧版本", link: "/history" },
{ text: "意见收集", link: "/proposal" },
{ text: "支持列表", link: "/list" },
],
},
],
socialLinks: [
{ icon: "github", link: "https://github.com/caorushizi/m3u8-downloader" },
],
footer: {
message: '<a href="https://beian.miit.gov.cn">豫ICP备20012967号-2</a>',
copyright: "Copyright © 2019-present caorushizi",
},
},
});
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { onMounted, watch, ref, watchEffect } from "vue";
import { init, WalineInstance } from "@waline/client";
import "@waline/client/waline.css";
import { useData, useRoute } from "vitepress";
const route = useRoute();
const { isDark } = useData();
const commentsRef = ref<WalineInstance | null>(null);
watchEffect(() => {
commentsRef.value?.update({
dark: isDark.value,
});
});
watch(
() => route.path,
() => {
commentsRef.value?.destroy();
initWaline();
}
);
function initWaline() {
commentsRef.value = init({
el: "#waline",
serverURL: "https://comments.ziying.site",
dark: isDark.value,
lang: "zh-CN",
reaction: true,
pageview: true,
});
}
onMounted(() => {
initWaline();
});
</script>
<template>
<div id="waline">hello world</div>
</template>
<style scoped>
#waline {
margin-top: 20px;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import { h } from "vue";
import Theme from "vitepress/theme";
import "./style/var.css";
import Comments from "./components/Comments.vue";
export default {
...Theme,
Layout() {
return h(Theme.Layout, null, {
"doc-after": () => h(Comments),
});
},
};
+30
View File
@@ -0,0 +1,30 @@
:root {
/* 标题 */
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(
135deg,
#5e9ef3 10%,
#2a82f6 100%
);
/* 图标背景 */
--vp-home-hero-image-background-image: linear-gradient(
135deg,
#5e9ef3 10%,
#2a82f6 100%
);
--vp-home-hero-image-filter: blur(150px);
/* brand按钮 */
--vp-button-brand-border: #5e9ef3;
--vp-button-brand-text: #f2f7fd;
--vp-button-brand-bg: #2a82f6;
--vp-button-brand-hover-border: #5e9ef3;
--vp-button-brand-hover-text: #fff;
--vp-button-brand-hover-bg: #2a82f6;
--vp-button-brand-active-border: #f6ceec;
--vp-c-brand: #2a82f6;
}
+83
View File
@@ -0,0 +1,83 @@
---
layout: doc
outline: deep
---
# 使用说明
这个页面主要讲解的是设置页面的参数的含义
![step 1](./images/documents-step1.png)
## 基础设置
::: tip
下载器全局设置
:::
1. 选择文件夹: 视频下载的路径
2. 下载器主题: 支持浅色和深色模式
3. 在新窗口中打开浏览器: 开启了之后浏览器页面会独立出一个窗口。
![step 1](./images/documents-step2.png)
4. 下载完成提示: 开启之后下载完成会出现系统提示
## 浏览器设置
::: tip
浏览器窗口相关的设置
:::
1. 代理设置: 填写自己的代理地址
2. 代理开关: 打开开关后浏览器默认会走填写的代理地址,如果代理地址这个开关是打不开的
3. 开启广告过滤: 打开开关后会过滤页面中的广告
4. 以手机模式进入: 打开后会将浏览器伪装成手机浏览器,请求移动端的站点(左侧是开启手机模式的截图)
![step 2](./images/documents-step3.png)
5. 使用浏览器插件
- 打开状态: 从浏览器中嗅探到的资源不会自动添加到下载列表,需要手动点击页面下方的下载的链接来添加视频到下载列表 **(后面会有很强大的功能哦)**
![step 3](./images/documents-step4.png)
- 关闭状态: 浏览器中嗅探到的资源会自动添加到下载列表
## 下载设置
::: tip
和下载相关的设置
:::
1. 下载完成删除分片: 开启后下载完成后会将分片文件删除
2. 最大同时下载数量: 最多同时可以下载多少视频文件,目前最大是 10,最小是 1
3. 更多操作
- 配置文件目录: 软件数据库路径、日志等等路径
- 可执行文件目录: 下载器二进制文件的路径
- 本地存储路径: 下载视频的本地路径
4. 当前版本: 当前软件版本信息
## 其他问题
**Q: 关于直播视频的下载**
A: 软件支持直播下载,软件目前没有好的办法区分直播流,所以将所有的下载控制台开启。用户自行分辨下载数据源
**Q: 关于 MacOS 版本**
A: 目前 MacOS 版本属于非稳定版本,2023 年 5 月发布。需要在 Mac 的安全设置中打开来自身份不明开发者的 Mac App
![QA1](./images/documents-qa1.png)
**Q: 关于旧版本**
A: 1.1.5 版本确实发布了很长时间,经过大量用户的验证,比较稳定。所以想要使用旧版软件的话请访问[此链接](/history.html)
**Q: 关于 Win7 用户**
A: v2.0.0 之后的版本已经放弃不支持 Win7 如果需要在 win7 上面使用同样需要下载 1.1.5 版本
+96
View File
@@ -0,0 +1,96 @@
---
layout: doc
outline: deep
---
# 快速开始
这篇文章是软件的简单说明,可以快速上手使用本本软件。
::: tip
我建了一个 QQ 群,方便大家一起沟通交流: 574209001
:::
## 下载安装
**v2.0.12023.7.1 发布)**
- [windows media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.exe)
- [macos media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.dmg)
**更新日志**
- 暗黑模式
- 更多下载配置
- 支持请求标头自动带入
- 支持开启广告过滤
- 支持开启沉浸式嗅探
- 支持切换手机和 PC 模式
- 支持修改同时下载数量
- 修复了一些 bug
**v1.1.52022.2.5 发布)**
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
**更新日志**
- 支持视频下载
## 操作说明
### 视频自动嗅探
1. 选择【资源提取】
![step 1](./images/guides-step1.png)
2. 进入视频的网址
![step 2](./images/guides-step2.png)
3. 开始下载,即可下载视频
![step 3](./images/guides-step3.png)
### 手动下载
1. 点击页面右上角的【新建下载】
![step 1](./images/guides-step4.png)
2. 在新建下载弹窗中填入【视频名称】和【m3u8 地址】即可
![step 2](./images/guides-step5.png)
3. 在列表中点击下载,即可下载视频
![step 3](./images/guides-step3.png)
## 附加功能
1. 下载完成后可以在下载列表中点击转换音频按钮将视频转换为音频
![step 1](./images/addition-step1.png)
2. 下载完成后播放视频
- PC 播放
![step 2](./images/addition-step2.png)
- 移动端播放
![step 3](./images/addition-step3.png)
3. 批量下载
![step 3](./images/addition-step4.png)
## 开始你的视频下载吧
是不是很简单,快去下载你的视频吧
::: warning
本软件仅可用于学习交流
:::
+21
View File
@@ -0,0 +1,21 @@
---
layout: doc
outline: deep
---
# 旧版本
::: info
这里是旧版本的永久链接,如果有需求的话,可以在这里下载
**这个链接的软件不会自动升级, 可放心使用**
:::
**v1.1.52022.2.5 发布)**
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
- ~~macos media-downloader v1.1.5 无~~
**更新日志**
- 支持视频下载
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+30
View File
@@ -0,0 +1,30 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "在线视频下载"
text: "简单易用,快速下载"
tagline: 简单易学,无需抓包,不需要安装插件
image:
src: /home.png
alt: home
actions:
- theme: brand
text: 快速开始
link: /guides
- theme: alt
text: 使用说明
link: /documents
features:
- icon: ⏩
title: 无需抓包
details: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
- icon: 📱
title: 移动播放
details: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
- icon: ⚡️
title: 支持批量下载
details: 支持同时最多下载10个视频和直播资源,高速带宽不闲置。
---
+22
View File
@@ -0,0 +1,22 @@
---
layout: doc
outline: deep
---
# 支持列表
::: tip
大家有什么网站想要支持的在这里提
:::
**模板**
---
- 网站首页: xxx
- 视频(直播)列表页: xxx
- 视频(直播)页: xxx
---
支持完成后我会在评论区回复
+16
View File
@@ -0,0 +1,16 @@
---
layout: doc
outline: deep
---
# 意见收集
::: info
这里手机大家使用软件的问题,或者意见建议
:::
如果软件报错,需要描述清楚在什么网站下载什么视频的时候报错
如果是建议请描述清楚你的需求
如果是意见请尽量提出来,我会尽量满足大家的需求
+40
View File
@@ -0,0 +1,40 @@
<svg t="1686326276609" class="icon" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="6962" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="200">
<path
d="M110.933333 42.666667h716.8A68.266667 68.266667 0 0 1 896 110.933333v802.133334a68.266667 68.266667 0 0 1-68.266667 68.266666H110.933333A68.266667 68.266667 0 0 1 42.666667 913.066667V110.933333A68.266667 68.266667 0 0 1 110.933333 42.666667z"
fill="#111314" fill-opacity=".72" p-id="6963"></path>
<path
d="M853.333333 42.666667h85.333334a68.266667 68.266667 0 0 1 68.266666 68.266666v802.133334A68.266667 68.266667 0 0 1 938.666667 981.333333h-85.333334a68.266667 68.266667 0 0 0 68.266667-68.266666V110.933333A68.266667 68.266667 0 0 0 853.333333 42.666667z"
fill="#CACBCC" p-id="6964"></path>
<path
d="M162.133333 128h614.4a34.133333 34.133333 0 0 1 34.133334 34.133333v699.733334a34.133333 34.133333 0 0 1-34.133334 34.133333H162.133333a34.133333 34.133333 0 0 1-34.133333-34.133333V162.133333a34.133333 34.133333 0 0 1 34.133333-34.133333z"
fill="#FFFFFF" p-id="6965"></path>
<path
d="M187.733333 170.666667h563.2c9.386667 0 17.066667 7.68 17.066667 17.066666v221.866667a17.066667 17.066667 0 0 1-17.066667 17.066667H187.733333a17.066667 17.066667 0 0 1-17.066666-17.066667V187.733333c0-9.386667 7.68-17.066667 17.066666-17.066666z"
fill="#EDEEF0" p-id="6966"></path>
<path
d="M256 298.666667m17.066667 0l136.533333 0q17.066667 0 17.066667 17.066666l0 136.533334q0 17.066667-17.066667 17.066666l-136.533333 0q-17.066667 0-17.066667-17.066666l0-136.533334q0-17.066667 17.066667-17.066666Z"
fill="#FFC13C" p-id="6967"></path>
<path
d="M341.333333 392.533333a34.133333 34.133333 0 1 1 0-68.266666 34.133333 34.133333 0 0 1 0 68.266666z m65.109334 56.192H276.224C276.224 426.666667 276.224 401.066667 341.333333 401.066667s65.109333 25.6 65.109334 47.658666z"
fill="#FFFFFF" p-id="6968"></path>
<path
d="M469.333333 341.333333m17.066667 0l136.533333 0q17.066667 0 17.066667 17.066667l0 8.533333q0 17.066667-17.066667 17.066667l-136.533333 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
fill="#5BADFF" p-id="6969"></path>
<path
d="M512 554.666667m17.066667 0l179.2 0q17.066667 0 17.066666 17.066666l0 51.2q0 17.066667-17.066666 17.066667l-179.2 0q-17.066667 0-17.066667-17.066667l0-51.2q0-17.066667 17.066667-17.066666Z"
fill="#EDEEF0" p-id="6970"></path>
<path
d="M512 682.666667m17.066667 0l179.2 0q17.066667 0 17.066666 17.066666l0 51.2q0 17.066667-17.066666 17.066667l-179.2 0q-17.066667 0-17.066667-17.066667l0-51.2q0-17.066667 17.066667-17.066666Z"
fill="#EDEEF0" p-id="6971"></path>
<path
d="M213.333333 554.666667m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066666l0 8.533334q0 17.066667-17.066666 17.066666l-221.866667 0q-17.066667 0-17.066667-17.066666l0-8.533334q0-17.066667 17.066667-17.066666Z"
fill="#EDEEF0" p-id="6972"></path>
<path
d="M213.333333 640m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066667l0 8.533333q0 17.066667-17.066666 17.066667l-221.866667 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
fill="#EDEEF0" p-id="6973"></path>
<path
d="M213.333333 725.333333m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066667l0 8.533333q0 17.066667-17.066666 17.066667l-221.866667 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
fill="#EDEEF0" p-id="6974"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+21 -5
View File
@@ -4,12 +4,28 @@
"description": "electron-template",
"main": "index.js",
"scripts": {
"dev": "pnpm -r --parallel -F main -F renderer run dev",
"build": "pnpm -r --parallel -F main -F renderer run build",
"pack": "pnpm -F main run pack",
"release": "pnpm run build && pnpm -F main run release"
"dev": "pnpm run build:mobile && pnpm --parallel -F \"./packages/*\" run dev",
"build": "pnpm run build:mobile && pnpm run build:main && pnpm run build:renderer",
"build:mobile": "pnpm -F mobile run build",
"build:main": "pnpm -F main run build",
"build:renderer": "pnpm -F renderer run build",
"pack": "pnpm -F media-downloader run pack",
"release": "pnpm run build && pnpm -F main run release",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"keywords": [],
"author": "",
"license": "MIT"
"license": "MIT",
"pnpm": {
"patchedDependencies": {
"@cliqz/adblocker@1.26.6": "patches/@cliqz__adblocker@1.26.6.patch"
}
},
"devDependencies": {
"@waline/client": "^2.15.5",
"vitepress": "1.0.0-beta.3",
"vue": "^3.3.4"
}
}
+1
View File
@@ -3,3 +3,4 @@ node_modules
build
dist
bin/Logs
release
+4
View File
@@ -0,0 +1,4 @@
node_modules
output
bin
mobile
+13
View File
@@ -0,0 +1,13 @@
{
"name": "media-downloader",
"version": "2.0.2",
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {},
"author": "",
"license": "MIT",
"dependencies": {
"@cliqz/adblocker-electron": "^1.26.6",
"@cliqz/adblocker-electron-preload": "^1.26.6"
}
}
Binary file not shown.
+46 -27
View File
@@ -1,7 +1,7 @@
{
"name": "main",
"version": "2.0.0-beta.2",
"description": "electron-template main",
"version": "0.1.0",
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {
"rebuild": "electron-rebuild -f -w better-sqlite3",
@@ -13,43 +13,62 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@rushstack/eslint-patch": "^1.2.0",
"@electron/rebuild": "^3.2.13",
"@rushstack/eslint-patch": "^1.3.2",
"@types/fs-extra": "^11.0.1",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "5.50.0",
"@typescript-eslint/parser": "5.50.0",
"@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.0.3",
"electron": "^23.1.1",
"electron-builder": "^23.6.0",
"electron-updater": "^5.3.0",
"esbuild": "^0.17.10",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"eslint-config-standard-with-typescript": "^24.0.0",
"eslint-plugin-import": "2.25.2",
"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.0.0",
"eslint-plugin-promise": "6.1.1",
"prebuild-install": "^7.1.1",
"prettier": "2.8.3",
"semver": "^7.3.8",
"typescript": "^4.9.3"
"prettier": "2.8.8",
"semver": "^7.5.2",
"typescript": "^5.1.3"
},
"dependencies": {
"axios": "^1.3.4",
"better-sqlite3": "^8.2.0",
"dayjs": "^1.11.7",
"@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.3.2",
"electron-store": "^7.0.2",
"fs-extra": "^11.1.0",
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"execa": "^7.1.1",
"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.12"
"typeorm": "^0.3.17"
}
}
+29 -23
View File
@@ -1,40 +1,46 @@
import * as esbuild from "esbuild";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, rmSync } from "node:fs";
import dotenv from "dotenv";
import {
mainResolve,
loadDotEnvDefined,
copyResource,
removeResource,
} from "./utils.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainResolve = (r) => resolve(__dirname, "..", r);
const rootResolve = (r) => resolve(__dirname, "../../..", r);
const mainDefined = loadDotEnvDefined();
const nodeEnv = process.env.NODE_ENV;
console.log("当前的环境是: ", nodeEnv);
removeResource([mainResolve("app/build")]);
const env = existsSync(rootResolve(`.env.${nodeEnv}.local`))
? rootResolve(`.env.${nodeEnv}.local`)
: rootResolve(`.env.${nodeEnv}`);
const { parsed } = dotenv.config({ path: env });
const path = "build/Release/better_sqlite3.node";
const mainDefined = Object.keys(parsed || {}).reduce((prev, cur) => {
prev[`process.env.${[cur]}`] = JSON.stringify(parsed[cur]);
return prev;
}, {});
rmSync(mainResolve("build/main"), { recursive: true, force: true });
rmSync(mainResolve("build/Release"), { recursive: true, force: true });
copyResource([
{
from: mainResolve("node_modules/better-sqlite3", path),
to: mainResolve("app", path),
},
]);
esbuild.build({
entryPoints: [mainResolve("src/index.ts"), mainResolve("src/preload.ts")],
entryPoints: [
mainResolve("src/index.ts"),
mainResolve("src/preload.ts"),
mainResolve("src/webview.ts"),
],
bundle: true,
platform: "node",
sourcemap: false,
target: ["node16.13"],
external: ["electron", "nock", "aws-sdk", "mock-aws-s3"],
external: [
"electron",
"nock",
"aws-sdk",
"mock-aws-s3",
"@cliqz/adblocker-electron-preload",
],
define: {
"process.env.NODE_ENV": '"production"',
...mainDefined,
},
outdir: mainResolve("build/main"),
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
minify: true,
});
+37 -38
View File
@@ -1,57 +1,52 @@
import { spawn } from "child_process";
import { cpSync, existsSync } from "node:fs";
import electron from "electron";
import * as esbuild from "esbuild";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import chokidar from "chokidar";
import dotenv from "dotenv";
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainResolve = (r) => resolve(__dirname, "..", r);
const rootResolve = (r) => resolve(__dirname, "../../..", r);
import { loadDotEnvRuntime, mainResolve, log, copyResource } from "./utils.mjs";
let electronProcess = null;
const nodeEnv = process.env.NODE_ENV;
console.log("当前的环境是: ", nodeEnv);
const env = existsSync(rootResolve(`.env.${nodeEnv}.local`))
? rootResolve(`.env.${nodeEnv}.local`)
: rootResolve(`.env.${nodeEnv}`);
dotenv.config({ path: env });
process.env.NODE_ENV = "development";
loadDotEnvRuntime();
async function copySource() {
const oldSqlite3Path = mainResolve(
"node_modules/better-sqlite3/build/Release"
);
const newSqlite3Path = mainResolve("build/Release");
const path = "build/Release/better_sqlite3.node";
cpSync(oldSqlite3Path, newSqlite3Path, {
recursive: true,
});
const oldBinPath = mainResolve("bin");
const newBinPath = mainResolve("build/bin");
cpSync(oldBinPath, newBinPath, {
recursive: true,
});
copyResource([
{
from: mainResolve("node_modules/better-sqlite3", path),
to: mainResolve("app", path),
},
{
from: mainResolve("bin"),
to: mainResolve("app/bin"),
},
]);
}
const ctx = await esbuild.context({
entryPoints: [mainResolve("src/index.ts"), mainResolve("src/preload.ts")],
entryPoints: [
mainResolve("src/index.ts"),
mainResolve("src/preload.ts"),
mainResolve("src/webview.ts"),
],
bundle: true,
platform: "node",
sourcemap: true,
target: ["node16.13"],
external: ["electron", "mock-aws-s3", "aws-sdk", "nock"],
external: [
"electron",
"mock-aws-s3",
"aws-sdk",
"nock",
"@cliqz/adblocker-electron-preload",
],
define: {
// 开发环境中二进制可执行文件的路径
__bin__: `"${mainResolve("bin").replace(/\\/g, "\\\\")}"`,
__bin__: `"${mainResolve("bin", process.platform).replace(/\\/g, "\\\\")}"`,
},
plugins: [],
outdir: mainResolve("build/main"),
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
});
@@ -59,25 +54,29 @@ const watcher = chokidar.watch("./src");
watcher.on("change", async () => {
await ctx.rebuild();
console.log("watch build succeed.");
log("watch build succeed.");
if (electronProcess && electronProcess.kill) {
process.kill(electronProcess.pid);
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")];
const args = ["--inspect=5858", mainResolve("app/build/main/index.js")];
electronProcess = spawn(String(electron), args);
electronProcess.stdout.on("data", (data) => {
console.log(String(data));
log(String(data));
});
electronProcess.stderr.on("data", (data) => {
console.log(String(data));
log(String(data));
});
}
+43 -30
View File
@@ -1,25 +1,20 @@
import builder from "electron-builder";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import dotenv from "dotenv";
import { readFileSync } from "node:fs";
import semver from "semver";
import {
mainResolve,
loadDotEnvRuntime,
log,
removeResource,
} from "./utils.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainResolve = (r) => resolve(__dirname, "..", r);
const rootResolve = (r) => resolve(__dirname, "../../..", r);
removeResource([mainResolve("release")]);
const packageJson = JSON.parse(readFileSync(mainResolve("./package.json")));
const nodeEnv = process.env.NODE_ENV;
console.log("当前的环境是: ", nodeEnv);
const env = existsSync(rootResolve(`.env.${nodeEnv}.local`))
? rootResolve(`.env.${nodeEnv}.local`)
: rootResolve(`.env.${nodeEnv}`);
dotenv.config({ path: env });
const packageJson = JSON.parse(readFileSync(mainResolve("./app/package.json")));
loadDotEnvRuntime();
if (semver.neq(process.env.APP_VERSION, packageJson.version)) {
console.log("请先同步构建版本和发布版本");
log("请先同步构建版本和发布版本");
process.exit(0);
}
@@ -35,21 +30,25 @@ const options = {
copyright: process.env.APP_COPYRIGHT,
artifactName: "${productName}-setup-${buildVersion}.${ext}",
directories: {
output: "./dist",
output: "./release",
},
files: [
{
from: "./build",
to: "./",
filter: ["**/*"],
},
"./package.json",
],
extraResources: [
{
from: "./node_modules/better-sqlite3/build/Release",
to: "./build/Release",
from: "./app/mobile",
to: "mobile",
},
{
from: "./app/bin/${platform}/",
to: "bin",
},
],
extraResources: ["bin/**/*"],
win: {
icon: "../assets/icon.ico",
target: [
@@ -59,22 +58,32 @@ const options = {
],
},
dmg: {
contents: [],
contents: [
{
x: 410,
y: 150,
type: "link",
path: "/Applications",
},
{
x: 130,
y: 150,
type: "file",
},
],
},
mac: {
icon: "../icons/icon.icns",
icon: "../assets/icon.icns",
target: {
target: "default",
arch: ["x64", "arm64"],
},
},
linux: {
icon: "../build/icons",
},
linux: {},
nsis: {
oneClick: false,
oneClick: true,
allowElevation: true,
allowToChangeInstallationDirectory: true,
allowToChangeInstallationDirectory: false,
installerIcon: "",
uninstallerIcon: "",
installerHeaderIcon: "",
@@ -97,10 +106,14 @@ const target =
? 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: builder.Platform.WINDOWS.createTarget(target),
targets,
config: options,
});
} catch (e) {
console.log(e);
log(e);
}
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, cpSync, rmSync } 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;
}, {});
}
export function copyResource(resource) {
resource.forEach((r) => {
const { from, to } = r;
cpSync(from, to, {
recursive: true,
});
});
}
export function removeResource(resource) {
resource.forEach((r) => {
rmSync(r, { recursive: true, force: true });
});
}
+52 -35
View File
@@ -1,25 +1,22 @@
import { app } from "electron";
import { inject, injectable } from "inversify";
import {
BrowserWindowService,
DatabaseService,
DownloadStatus,
IpcHandlerService,
LoggerService,
MainWindowService,
ProtocolService,
StoreService,
UpdateService,
VideoRepository,
WebviewService,
type App,
WebService,
DevToolsService,
StoreService,
} from "./interfaces";
import { TYPES } from "./types";
import installExtension, {
REDUX_DEVTOOLS,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer";
import isDev from "electron-is-dev";
import { Menu, Tray, app, nativeImage, nativeTheme } from "electron";
import TrayIcon from "./tray-icon.png";
import path from "path";
@injectable()
export default class ElectronApp implements App {
@@ -36,32 +33,61 @@ export default class ElectronApp implements App {
private readonly dataService: DatabaseService,
@inject(TYPES.WebviewService)
private readonly webview: WebviewService,
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.BrowserWindowService)
private readonly browserWindow: BrowserWindowService
@inject(TYPES.DevToolsService)
private readonly devTools: DevToolsService,
@inject(TYPES.WebService)
private readonly webService: WebService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {}
async init(): Promise<void> {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
private async seriveInit(): Promise<void> {
this.protocolService.create();
await this.dataService.init();
this.mainWindow.init();
this.browserWindow.init();
this.ipcHandler.init();
this.updateService.init();
await this.dataService.init();
this.webview.init();
this.storeService.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();
this.initTray();
}
initAppTheme(): void {
const theme = this.storeService.get("theme");
nativeTheme.themeSource = theme;
}
initTray() {
const iconPath = path.resolve(__dirname, TrayIcon);
const icon = nativeImage.createFromPath(iconPath);
const tray = new Tray(icon);
tray.setToolTip("在线视频下载");
tray.addListener("click", () => {
this.mainWindow.init();
});
const contextMenu = Menu.buildFromTemplate([
{ label: "显示主窗口", click: () => this.mainWindow.init() },
{ label: "退出 app", role: "quit" },
]);
tray.setContextMenu(contextMenu);
}
// 如果重启后还有正在下载的视频,就将状态改成下载失败
async resetDownloadStatus(): Promise<void> {
// 重启后如果还有 downloading 状态的数据, 全部重置为失败
const videos = await this.videoRepository.findWattingAndDownloadingVideos();
const videoIds = videos.map((video) => video.id);
@@ -69,14 +95,5 @@ export default class ElectronApp implements App {
videoIds,
DownloadStatus.Failed
);
if (isDev) {
try {
await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
this.logger.info("devtools installed");
} catch (err) {
this.logger.error("devtools install error", err);
}
}
}
}
@@ -3,7 +3,6 @@ import { inject, injectable } from "inversify";
import { handle } from "../helper/decorator";
import {
StoreService,
LoggerService,
type Controller,
VideoRepository,
DownloadItem,
@@ -14,13 +13,11 @@ import {
} from "../interfaces";
import { TYPES } from "../types";
import { spawnDownload } from "helper";
import MainWindowServiceImpl from "services/MainWindowServiceImpl";
import MainWindowServiceImpl from "windows/Main";
@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)
@@ -35,7 +32,13 @@ export default class DownloadController implements Controller {
async addDownloadItem(e: IpcMainEvent, video: DownloadItem) {
const item = await this.videoRepository.addVideo(video);
// 这里向页面发送消息,通知页面更新
this.mainWindow.webContents.send("download-item-notifier", item);
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;
}
@@ -60,7 +63,7 @@ export default class DownloadController implements Controller {
if (!video) {
return Promise.reject("没有找到该视频");
}
const { name, url } = video;
const { name, url, headers } = video;
const local = this.storeService.get("local");
// 从配置中添加参数
@@ -72,6 +75,7 @@ export default class DownloadController implements Controller {
url,
local,
name,
headers,
deleteSegments,
},
process: spawnDownload,
+70 -13
View File
@@ -4,7 +4,9 @@ import {
Menu,
MenuItem,
MenuItemConstructorOptions,
nativeTheme,
shell,
clipboard,
} from "electron";
import { Favorite } from "entity/Favorite";
import { convertToAudio } from "helper";
@@ -12,24 +14,25 @@ import { db, workspace } from "helper/variables";
import { inject, injectable } from "inversify";
import { AppStore, EnvPath } from "main";
import path from "path";
import { handle } from "../helper/decorator";
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)
@@ -39,7 +42,11 @@ export default class HomeController implements Controller {
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.BrowserWindowService)
private readonly browserWindow: BrowserWindowService
private readonly browserWindow: BrowserWindowService,
@inject(TYPES.PlayerWindowService)
private readonly playerWindow: PlayerWindowService,
@inject(TYPES.WebviewService)
private readonly webviewService: WebviewService
) {}
@handle("get-env-path")
@@ -76,7 +83,9 @@ export default class HomeController implements Controller {
@handle("on-favorite-item-context-menu")
async onFavoriteItemContextMenu(e: IpcMainEvent, id: number) {
const send = (action: string) => {
this.mainWindow.webContents.send("favorite-item-event", {
const window = this.mainWindow.window;
if (!window) return;
window.webContents.send("favorite-item-event", {
action,
payload: id,
});
@@ -103,7 +112,10 @@ export default class HomeController implements Controller {
@handle("select-download-dir")
async selectDownloadDir(): Promise<string> {
const result = await dialog.showOpenDialog(this.mainWindow, {
const window = this.mainWindow.window;
if (!window) return Promise.reject("未找到主窗口");
const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory"],
});
@@ -117,13 +129,27 @@ export default class HomeController implements Controller {
@handle("set-app-store")
async setAppStore(e: IpcMainEvent, key: keyof AppStore, val: any) {
// useProxy
if (key === "useProxy") {
const proxy = this.storeService.get("proxy");
await this.storeService.setProxy(val, proxy);
} else if (key === "proxy") {
if (this.storeService.get("useProxy")) {
this.storeService.setProxy(true, val);
}
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;
}
// isMobile
if (key === "isMobile") {
this.webviewService.setUserAgent(val);
}
this.storeService.set(key, val);
@@ -142,12 +168,22 @@ export default class HomeController implements Controller {
@handle("on-download-list-context-menu")
async downloadListContextMenu(e: IpcMainEvent, id: number) {
const send = (action: string) => {
this.mainWindow.webContents.send("download-item-event", {
const window = this.mainWindow.window;
if (!window) return;
window.webContents.send("download-item-event", {
action,
payload: id,
});
};
const item = await this.videoRepository.findVideo(id);
const template: Array<MenuItemConstructorOptions | MenuItem> = [
{
label: "拷贝链接地址",
click: () => {
clipboard.writeText(item?.url || "");
},
},
{
label: "选择",
click: () => {
@@ -206,4 +242,25 @@ export default class HomeController implements Controller {
// 修改设置中的属性
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;
}
}
@@ -1,16 +1,16 @@
import { IpcMainEvent } from "electron";
import { inject, injectable } from "inversify";
import { handle } from "../helper/decorator";
import { LoggerService, type Controller, WebviewService } from "../interfaces";
import { type Controller, WebviewService, StoreService } 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
private readonly webview: WebviewService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {}
@handle("set-webview-bounds")
@@ -47,4 +47,10 @@ export default class WebviewController implements Controller {
async webviewGoHome() {
await this.webview.goHome();
}
@handle("webview-change-user-agent")
async webviewChangeUserAgent(e: IpcMainEvent, isMobile: boolean) {
this.webview.setUserAgent(isMobile);
this.storeService.set("isMobile", isMobile);
}
}
+13
View File
@@ -26,6 +26,19 @@ export class Video {
})
url: string;
@Column({
type: "text",
nullable: true,
})
headers: string;
@Column({
type: "boolean",
default: false,
nullable: false,
})
isLive: boolean;
@Column({
type: "text",
nullable: false,
+168 -21
View File
@@ -1,45 +1,192 @@
import { spawn } from "child_process";
import { downloaderPath } from "./variables";
import { execa } from "execa";
import { macDownloaderPath, winDownloaderPath } from "./variables";
import iconv from "iconv-lite";
import { event } from "./utils";
import { formatHeaders, stripColors } from "./utils";
import { DownloadParams, DownloadProgress } from "interfaces";
const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g;
export const spawnDownload = (params: DownloadParams): Promise<void> => {
const { id, abortSignal, url, local, name, deleteSegments } = params;
if (process.platform === "win32") {
return winSpawnDownload(params);
} else {
return macSpawnDownload(params);
}
};
const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
const {
id,
abortSignal,
url,
local,
name,
deleteSegments,
headers,
callback,
} = params;
const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g;
const isLiveReg = /识别为直播流, 开始录制/g;
const startDownloadReg = /开始下载文件/g;
return new Promise((resolve, reject) => {
// const downloader = spawn(downloaderPath, [
// url,
// "--save-dir",
// local,
// "--save-name",
// name,
// ]);
const spawnParams = [url, "--workDir", local, "--saveName", name];
if (headers) {
spawnParams.push("--headers", formatHeaders(headers));
}
if (deleteSegments) {
spawnParams.push("--enableDelAfterDone");
}
const downloader = spawn(downloaderPath, spawnParams, {
const downloader = execa(winDownloaderPath, spawnParams, {
signal: abortSignal.signal,
});
downloader.stdout.on("data", (data) => {
let isLive = false;
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) {
const [, cur, total, speed] = result;
const progress: DownloadProgress = { id, cur, total, speed };
event.emit("download-progress", progress);
if (isLiveReg.test(item) || startDownloadReg.test(item)) {
callback({
id,
type: "ready",
isLive,
cur: "",
total: "",
speed: "",
});
isLive = true;
}
const result = progressReg.exec(item);
if (!result) {
return;
}
const [, cur, total, speed] = result;
const progress: DownloadProgress = {
id,
type: "progress",
cur,
total,
speed,
isLive,
};
callback(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,
headers,
callback,
} = params;
// const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
const progressReg = /([\d.]+)%/g;
const errorReg = /ERROR/g;
const startDownloadReg = /保存文件名:/g;
const isLiveReg = /检测到直播流/g;
return new Promise((resolve, reject) => {
const spawnParams = [
url,
"--tmp-dir",
local,
"--save-dir",
local,
"--save-name",
name,
"--auto-select",
];
if (headers) {
const h: Record<string, unknown> = JSON.parse(headers);
Object.entries(h).forEach(([k, v]) => {
spawnParams.push("-H", `${k}: ${v}`);
});
}
if (deleteSegments) {
spawnParams.push("--del-after-done");
}
const downloader = execa(macDownloaderPath, spawnParams, {
signal: abortSignal.signal,
});
let isLive = false;
downloader.stdout?.on("data", (data) => {
const str = String(Buffer.from(data));
str.split("\n").forEach((item) => {
if (item.trim() == "") {
return;
}
if (isLiveReg.test(item) || startDownloadReg.test(item)) {
callback({
id,
type: "ready",
isLive,
cur: "",
total: "",
speed: "",
});
isLive = true;
}
const log = stripColors(item);
if (errorReg.test(log)) {
reject(new Error(log));
return;
}
const result = progressReg.exec(log);
if (!result) {
return;
}
const [, precentage, speed] = result;
const cur = String(Number(precentage) * 10);
if (cur === "0") {
return;
}
const total = "1000";
// FIXME: 无法获取是否为直播流
const progress: DownloadProgress = {
id,
type: "progress",
cur,
total,
speed,
isLive,
};
callback(progress);
});
});
+13
View File
@@ -0,0 +1,13 @@
import axios from "axios";
import https from "https";
const agent = new https.Agent({
rejectUnauthorized: false,
});
// 创建 axios 实例,并指定自定义的 HTTPS Agent
const instance = axios.create({
httpsAgent: agent,
});
export default instance;
+26
View File
@@ -1,5 +1,31 @@
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";
export { default as http } from "./http";
+17
View File
@@ -4,4 +4,21 @@ 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 function formatHeaders(headersStr: string): string {
const headers: Record<string, any> | null = JSON.parse(headersStr);
if (!headers) return "";
const formatted = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join("|");
return formatted;
}
export const event = new EventEmitter();
+15 -3
View File
@@ -14,6 +14,18 @@ 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 downloaderPath = path.resolve(__bin__, "N_m3u8DL-RE.exe");
export const downloaderPath = path.resolve(__bin__, "N_m3u8DL-CLI_v3.0.2.exe");
export const ffmpegPath = path.resolve(__bin__, "ffmpeg.exe");
export const macDownloaderPath = path.resolve(__bin__, "N_m3u8DL-RE");
export const winDownloaderPath = path.resolve(__bin__, "N_m3u8DL-CLI.exe");
export const ffmpegPath =
process.platform === "win32"
? path.resolve(__bin__, "ffmpeg.exe")
: path.resolve(__bin__, "ffmpeg");
export const mobilePath = isDev
? path.resolve(app.getAppPath(), "../../mobile")
: path.resolve(app.getAppPath(), "../mobile");
// user agent
export const pcUA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
export const mobileUA =
"Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36";
+7
View File
@@ -0,0 +1,7 @@
declare module "*.svg";
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.bmp";
declare module "*.tiff";
+9
View File
@@ -5,7 +5,12 @@ 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,
@@ -18,6 +23,10 @@ const start = async (): Promise<void> => {
await app.whenReady();
const mediago = container.get<App>(TYPES.App);
mediago.init();
app.on("window-all-closed", () => {
// empty
});
};
void start();
+35 -11
View File
@@ -1,4 +1,4 @@
import { type BrowserWindow } from "electron";
import { type BrowserWindow, BrowserView } from "electron";
import { ElectronLog } from "electron-log";
import Store from "electron-store";
import { Favorite } from "entity/Favorite";
@@ -7,12 +7,17 @@ import EventEmitter from "events";
import { AppStore } from "main";
import { DataSource, EntityManager, UpdateResult, DeleteResult } from "typeorm";
export interface MainWindowService extends BrowserWindow {
export interface MainWindowService {
window: BrowserWindow | null;
init: () => void;
}
export interface BrowserWindowService extends BrowserWindow {
init: () => void;
export interface PlayerWindowService {
openWindow: (id: number) => void;
}
export interface BrowserWindowService {
window: BrowserWindow | null;
showWindow: () => void;
hideWindow: () => void;
}
@@ -51,16 +56,13 @@ export interface LoggerService {
export interface StoreService extends Store<AppStore> {
init: () => void;
setProxy: (
useProxy: boolean,
proxy: string,
isInit?: boolean
) => Promise<void>;
}
export interface DownloadItem {
id?: number;
name: string;
url: string;
headers: string;
}
export enum DownloadFilter {
@@ -81,6 +83,7 @@ export interface VideoResponse {
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: (
@@ -89,6 +92,12 @@ export interface VideoRepository {
) => Promise<UpdateResult>;
findWattingAndDownloadingVideos: () => Promise<Video[]>;
deleteDownloadItem: (id: number) => Promise<DeleteResult>;
findAllVideos(): Promise<Video[]>;
changeVideoIsLive: (
id: number | number[],
isLive: boolean
) => Promise<UpdateResult>;
findVideoByUrl: (url: string) => Promise<Video | null>;
}
export interface FavoriteRepository {
@@ -98,18 +107,25 @@ export interface FavoriteRepository {
}
export interface WebviewService {
webContents: Electron.WebContents;
view: BrowserView;
init: () => void;
getBounds: () => Electron.Rectangle;
setAutoResize: (options: Electron.AutoResizeOptions) => void;
setBackgroundColor: (color: string) => void;
setBounds: (bounds: Electron.Rectangle) => void;
loadURL: (url?: string) => 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;
setUserAgent: (isMobile: boolean) => void;
}
export interface WebService {
init: () => void;
}
export enum DownloadStatus {
@@ -134,9 +150,11 @@ export type Task = {
export interface DownloadProgress {
id: number;
type: string;
cur: string;
total: string;
speed: string;
isLive: boolean;
}
export interface DownloadParams {
@@ -144,6 +162,12 @@ export interface DownloadParams {
url: string;
local: string;
name: string;
headers: string;
abortSignal: AbortController;
deleteSegments?: boolean;
callback: (progress: DownloadProgress) => void;
}
export interface DevToolsService {
init: () => Promise<void>;
}
+16 -8
View File
@@ -4,8 +4,8 @@ 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";
@@ -24,25 +24,26 @@ import {
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 MainWindow from "./windows/Main";
import PlayerWindow from "windows/Player";
import BrowserWin from "windows/Browser";
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)
@@ -56,6 +57,13 @@ 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);
// windows
container.bind<MainWindowService>(TYPES.MainWindowService).to(MainWindow);
container.bind<BrowserWindowService>(TYPES.BrowserWindowService).to(BrowserWin);
container.bind<PlayerWindowService>(TYPES.PlayerWindowService).to(PlayerWindow);
// === controller
container.bind<Controller>(TYPES.Controller).to(HomeController);
+33 -4
View File
@@ -1,4 +1,4 @@
import { Video } from "electron";
import { Video, Rectangle } from "electron";
import { type Favorite } from "entity/Favorite";
import {
DownloadItem,
@@ -14,6 +14,11 @@ declare interface EnvPath {
local: string;
}
declare interface BrowserWindowInitialVal {
url?: string;
sourceList?: WebSource[];
}
declare interface ElectronAPI {
getEnvPath: () => Promise<EnvPath>;
getFavorites: () => Promise<Favorite>;
@@ -45,12 +50,20 @@ declare interface ElectronAPI {
webviewHide: () => Promise<void>;
webviewShow: () => Promise<void>;
downloadNow: (video: DownloadItem) => Promise<void>;
combineToHomePage: () => 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>;
setUserAgent: (isMobile: boolean) => Promise<void>;
}
declare interface LinkMessage {
declare interface WebSource {
url: string;
title: string;
name: string;
headers: string;
}
declare interface AppStore {
@@ -66,4 +79,20 @@ declare interface AppStore {
deleteSegments: boolean;
// 新窗口打开浏览器
openInNewWindow: boolean;
mainBounds?: Rectangle;
browserBounds?: Rectangle;
blockAds: boolean;
// 主题
theme: "system" | "light" | "dark";
// 使用浏览器插件
useExtension: boolean;
// 是否使用手机UA
isMobile: boolean;
// 最大同时下载数
maxRunner: number;
}
declare interface BrowserStore {
url: string;
sourceList: WebSource[];
}
+12 -2
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, shell } from "electron";
import { ElectronAPI } from "./main";
const apiFunctions: Record<string, any> = {};
@@ -44,7 +44,17 @@ const handleApi: ElectronAPI = {
webviewHide: () => ipcRenderer.invoke("webview-hide"),
webviewShow: () => ipcRenderer.invoke("webview-show"),
downloadNow: (video) => ipcRenderer.invoke("download-now", video),
combineToHomePage: () => ipcRenderer.invoke("combine-to-home-page"),
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),
setUserAgent: (isMobile: boolean) =>
ipcRenderer.invoke("webview-change-user-agent", isMobile),
};
contextBridge.exposeInMainWorld(apiKey, handleApi);
@@ -32,7 +32,7 @@ export default class FavoriteRepositoryImpl implements FavoriteRepository {
});
if (exist) {
return exist;
throw new Error("网址已经存在");
}
const item = new Favorite();
@@ -25,14 +25,38 @@ export default class VideoRepositoryImpl implements VideoRepository {
const item = new Video();
item.name = video.name;
item.url = video.url;
item.headers = video.headers;
return await this.dataService.manager.save(item);
}
async findVideos({
current = 0,
pageSize = 50,
filter = DownloadFilter.list,
}: DownloadItemPagination) {
// 编辑视频
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
@@ -72,6 +96,16 @@ export default class VideoRepositoryImpl implements VideoRepository {
.execute();
}
async changeVideoIsLive(id: number | number[], isLive: boolean) {
const ids = !Array.isArray(id) ? [id] : id;
return this.dataService.appDataSource
.createQueryBuilder()
.update(Video)
.set({ isLive })
.where({ id: In(ids) })
.execute();
}
async findWattingAndDownloadingVideos() {
return await this.dataService.appDataSource.getRepository(Video).find({
where: {
@@ -83,4 +117,10 @@ export default class VideoRepositoryImpl implements VideoRepository {
async deleteDownloadItem(id: number) {
return await this.dataService.appDataSource.getRepository(Video).delete(id);
}
async findVideoByUrl(url: string) {
return this.dataService.appDataSource.getRepository(Video).findOneBy({
url,
});
}
}
@@ -1,86 +0,0 @@
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Event,
} from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { TYPES } from "../types";
import {
BrowserWindowService,
LoggerService,
StoreService,
} from "../interfaces";
@injectable()
export default class BrowserWindowServiceImpl
extends BrowserWindow
implements BrowserWindowService
{
private ready = false;
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
const options: BrowserWindowConstructorOptions = {
width: 1100,
minWidth: 1100,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
};
super(options);
}
init(): void {
const url = isDev
? "http://localhost:8555/browser"
: "mediago://index.html/browser";
void this.loadURL(url);
this.once("ready-to-show", this.readyToShow);
this.on("close", (e: Event) => {
e.preventDefault();
this.hideWindow();
});
this.storeService.onDidChange("openInNewWindow", (newValue) => {
// 向所有窗口发送通知
if (newValue === false) {
this.hideWindow();
}
});
}
readyToShow = () => {
this.ready = true;
};
showWindow = () => {
if (!this.ready) {
this.logger.error("BrowserWindow is not ready to show.");
return;
}
this.show();
isDev && this.webContents.openDevTools();
};
hideWindow = () => {
this.hide();
// 关闭开发者工具
if (isDev) {
this.webContents.closeDevTools();
this.getBrowserView()?.webContents.closeDevTools();
}
};
}
@@ -14,7 +14,6 @@ export default class DatabaseServiceImpl implements DatabaseService {
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {
logger.info("数据库地址是: ", db);
this.appDataSource = new DataSource({
type: "better-sqlite3",
database: db,
@@ -27,6 +26,7 @@ export default class DatabaseServiceImpl implements DatabaseService {
}
async init(): Promise<void> {
this.logger.info("数据库地址是: ", db);
await this.appDataSource.initialize();
}
@@ -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);
}
}
}
@@ -3,6 +3,7 @@ import { inject, injectable } from "inversify";
import {
DownloadService,
DownloadStatus,
StoreService,
Task,
VideoRepository,
} from "../interfaces";
@@ -18,9 +19,9 @@ export default class DownloadServiceImpl
private active: Task[] = [];
private limit = 2;
private limit: number;
private debug = process.env.DOWNLOAD_DEBUG;
private debug = process.env.APP_DOWNLOAD_DEBUG;
private signal: Record<number, AbortController> = {};
@@ -28,9 +29,18 @@ export default class DownloadServiceImpl
@inject(TYPES.LoggerService)
private readonly logger: LoggerServiceImpl,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
private readonly videoRepository: VideoRepository,
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
super();
const maxRunner = this.storeService.get("maxRunner");
this.limit = maxRunner;
this.storeService.onDidChange("maxRunner", (maxRunner) => {
maxRunner && (this.limit = maxRunner);
});
}
async addTask(task: Task) {
@@ -60,6 +70,16 @@ export default class DownloadServiceImpl
...task.params,
id: task.id,
abortSignal: controller,
callback: (progress) => {
if (progress.type === "progress") {
this.emit("download-progress", progress);
} else if (progress.type === "ready") {
this.emit("download-ready-start", progress);
if (progress.isLive) {
this.removeTask(progress.id);
}
}
},
});
delete this.signal[task.id];
this.log(`taskId: ${task.id} success`);
@@ -87,11 +107,8 @@ export default class DownloadServiceImpl
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();
this.removeTask(task.id);
// 传输完成
if (this.queue.length === 0 && this.active.length === 0) {
// this.emit("download-finish");
@@ -99,6 +116,16 @@ export default class DownloadServiceImpl
}
}
removeTask(id: number) {
// 处理当前正在活动的任务
const doneId = this.active.findIndex((i) => i.id === id);
this.active.splice(doneId, 1);
// 处理完成的任务
if (this.active.length < this.limit) {
this.runTask();
}
}
runTask() {
while (this.active.length < this.limit && this.queue.length > 0) {
const task = this.queue.shift();
+11 -40
View File
@@ -1,20 +1,16 @@
import Store from "electron-store";
import { session } from "electron/main";
import { inject, injectable } from "inversify";
import { injectable } from "inversify";
import { AppStore } from "main";
import { download, PERSIST_WEBVIEW, workspace } from "../helper/variables";
import { LoggerService, StoreService } from "../interfaces";
import { TYPES } from "../types";
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(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
) {
constructor() {
super({
name: "config",
cwd: workspace,
@@ -27,41 +23,16 @@ export default class StoreServiceImpl
useProxy: false,
deleteSegments: true,
openInNewWindow: false,
blockAds: true,
theme: AppTheme.System,
useExtension: false,
isMobile: false,
maxRunner: 2,
},
});
}
init(): void {
const useProxy = this.get("useProxy");
const proxy = this.get("proxy");
this.setProxy(useProxy, proxy, true);
}
async setProxy(
useProxy: boolean,
proxy: string,
isInit?: boolean
): Promise<void> {
try {
if (useProxy) {
if (!proxy) {
return Promise.reject("请先设置代理地址");
}
if (!/https?:\/\//.test(proxy)) {
proxy = `http://${proxy}`;
}
session.fromPartition(PERSIST_WEBVIEW).setProxy({ proxyRules: proxy });
this.logger.info(
`[proxy] ${isInit ? "初始化" : "开启"}成功,代理地址为${proxy}`
);
} else {
session.fromPartition(PERSIST_WEBVIEW).setProxy({});
if (!isInit) this.logger.info("[proxy] 关闭成功");
}
} catch (e: any) {
this.logger.error(
`[proxy] ${isInit ? "初始化" : ""}设置代理失败:\n${e.message}`
);
}
// empty
}
}
@@ -10,14 +10,18 @@ export default class UpdateServiceImpl implements UpdateService {
@inject(TYPES.LoggerService) private readonly logger: LoggerService
) {}
init(): void {
async init(): Promise<void> {
if (isDev) return;
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
autoUpdater.checkForUpdatesAndNotify({
title: "自动更新完成",
body: "下次重启时将会自动安装",
});
try {
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
await autoUpdater.checkForUpdatesAndNotify({
title: "自动更新完成",
body: "下次重启时将会自动安装",
});
} catch (e) {
this.logger.info("update error", e);
}
}
}
@@ -0,0 +1,95 @@
import {
LoggerService,
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,
@inject(TYPES.LoggerService)
private readonly logger: LoggerService
) {
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));
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, () => {
this.logger.info("web server init success.");
})
.on("error", (err: unknown) => {
this.logger.error("server error", err);
});
}
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 });
};
}
+195 -62
View File
@@ -1,26 +1,32 @@
import {
BrowserView,
HeadersReceivedResponse,
OnHeadersReceivedListenerDetails,
CallbackResponse,
OnBeforeSendHeadersListenerDetails,
session,
} from "electron";
import {
BrowserWindowService,
LoggerService,
MainWindowService,
StoreService,
VideoRepository,
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 { PERSIST_WEBVIEW, mobileUA, pcUA } from "helper/variables";
import { ElectronBlocker } from "@cliqz/adblocker-electron";
import fetch from "cross-fetch";
import path from "path";
import { WebSource } from "main";
// FIXME: 需要重构
@injectable()
export default class WebviewServiceImpl implements WebviewService {
private readonly filter = { urls: ["*://*/*"] };
private readonly view: BrowserView;
webContents: Electron.WebContents;
public view: BrowserView;
private blocker?: ElectronBlocker;
private sources = new Set();
constructor(
@inject(TYPES.MainWindowService)
@@ -28,57 +34,51 @@ export default class WebviewServiceImpl implements WebviewService {
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@inject(TYPES.BrowserWindowService)
private readonly browserWindow: BrowserWindowService
private readonly browserWindow: BrowserWindowService,
@inject(TYPES.StoreService)
private readonly storeService: StoreService,
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
) {
const view = new BrowserView({
webPreferences: {
partition: PERSIST_WEBVIEW,
},
});
this.view = view;
this.webContents = this.view.webContents;
this.webContents.setAudioMuted(true);
this.onHeadersReceived = this.onHeadersReceived.bind(this);
}
async onHeadersReceived(
details: OnHeadersReceivedListenerDetails,
callback: (headersReceivedResponse: HeadersReceivedResponse) => void
): Promise<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);
}
callback({});
// 初始化 blocker
this.initBlocker();
}
async init(): Promise<void> {
this.view = new BrowserView({
webPreferences: {
partition: PERSIST_WEBVIEW,
preload: path.resolve(__dirname, "./webview.js"),
},
});
this.view.setBackgroundColor("#fff");
this.view.webContents.setAudioMuted(true);
const { useProxy, proxy, isMobile } = this.storeService.store;
this.setProxy(useProxy, proxy);
this.setUserAgent(isMobile);
this.view.webContents.on("dom-ready", () => {
this.sources.clear();
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);
}
this.curWindow.webContents.send("webview-dom-ready", { title, url });
this.view.webContents.setWindowOpenHandler((details) => {
void this.view.webContents.loadURL(details.url);
return { action: "deny" };
});
return { action: "deny" };
});
session
.fromPartition(PERSIST_WEBVIEW)
.webRequest.onHeadersReceived(this.filter, this.onHeadersReceived);
const urls = ["<all_urls>"];
this.session.webRequest.onBeforeSendHeaders({ urls }, this.before);
}
getBounds(): Electron.Rectangle {
@@ -94,12 +94,12 @@ export default class WebviewServiceImpl implements WebviewService {
}
show() {
this.curWindow.setBrowserView(this.view);
this.curWindow?.setBrowserView(this.view);
isDev && this.view.webContents.openDevTools();
}
hide() {
this.curWindow.setBrowserView(null);
this.curWindow?.setBrowserView(null);
isDev && this.view.webContents.closeDevTools();
}
@@ -110,17 +110,23 @@ export default class WebviewServiceImpl implements WebviewService {
this.view.setBounds(bounds);
}
async loadURL(url?: string) {
const canGoBack = this.webContents.canGoBack();
await this.webContents.loadURL(url || "");
if (!canGoBack) {
this.webContents.clearHistory();
async loadURL(url?: string, isNewWindow?: boolean) {
const canGoBack = this.view.webContents.canGoBack();
try {
await this.view.webContents.loadURL(url || "");
} catch (err: unknown) {
this.logger.error("加载 url 时出现错误: ", err);
throw err;
} finally {
if (!canGoBack && !isNewWindow) {
this.view.webContents.clearHistory();
}
}
}
async goBack() {
if (this.webContents.canGoBack()) {
this.webContents.goBack();
if (this.view.webContents.canGoBack()) {
this.view.webContents.goBack();
return true;
} else {
return false;
@@ -128,17 +134,144 @@ export default class WebviewServiceImpl implements WebviewService {
}
async reload() {
this.webContents.reload();
this.view.webContents.reload();
}
async goHome() {
this.webContents.clearHistory();
this.view.webContents.clearHistory();
}
get curWindow() {
if (this.browserWindow.isVisible()) {
return this.browserWindow;
if (this.browserWindow.window) return this.browserWindow.window;
if (this.mainWindow.window) 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;
}
return this.mainWindow;
// 处理 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 成功");
}
before = (
details: OnBeforeSendHeadersListenerDetails,
callback: (response: CallbackResponse) => void
): void => {
const { url } = details;
const sourceReg = /\.m3u8$/;
const detailsUrl = new URL(url);
if (sourceReg.test(detailsUrl.pathname)) {
try {
this.handleM3u8(details);
} catch (e) {
// empty
}
}
callback({});
};
handleM3u8 = async (
details: OnBeforeSendHeadersListenerDetails
): Promise<void> => {
const { id, url } = details;
this.logger.info(`在窗口中捕获 m3u8 链接: ${url} id: ${id}`);
const webContents = details.webContents;
const source: WebSource = {
url,
name: webContents?.getTitle() || "没有获取到名称",
headers: JSON.stringify(details.requestHeaders),
};
// 这里需要判断是否使用浏览器插件
const useExtension = this.storeService.get("useExtension");
if (!this.sources.has(source.url)) {
this.sources.add(source.url);
if (useExtension) {
this.view.webContents.send("webview-link-message", source);
} else {
const item = await this.videoRepository.addVideo(source);
// 这里向页面发送消息,通知页面更新
this.mainWindow.window?.webContents.send(
"download-item-notifier",
item
);
}
}
};
setUserAgent(isMobile?: boolean) {
if (isMobile) {
this.view.webContents.setUserAgent(mobileUA);
} else {
this.view.webContents.setUserAgent(pcUA);
}
this.logger.info("设置 user-agent 成功", isMobile);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

+9 -2
View File
@@ -1,4 +1,4 @@
const TYPES = {
export const TYPES = {
App: Symbol.for("App"),
MainWindowService: Symbol.for("MainWindowService"),
StoreService: Symbol.for("StoreService"),
@@ -14,6 +14,13 @@ const TYPES = {
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 { TYPES };
export enum AppTheme {
System = "system",
Light = "light",
Dark = "dark",
}
+107
View File
@@ -0,0 +1,107 @@
import { WebSource } from "main";
import { IpcRendererEvent, ipcRenderer } from "electron/renderer";
// 创建浮窗容器元素
const floatingContainer = document.createElement("div");
floatingContainer.style.cssText = `
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 200px;
z-index: 10000;
display: none;
font-size: 16px;
`;
// 创建浮窗按钮元素
const floatingButton = document.createElement("div");
floatingButton.textContent = "检测到视频资源……";
floatingButton.style.cssText = `
background-color: #000;
color: #fff;
padding: 10px;
cursor: pointer;
opacity: 0.8;
`;
// 创建浮窗列表元素
const floatingList = document.createElement("ul");
floatingList.style.cssText = `
display: none;
background-color: #000;
list-style: none;
padding: 0;
margin: 0;
`;
const itemStyle = `
padding: 10px;
color: #fff;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const items: WebSource[] = [];
ipcRenderer.on(
"webview-link-message",
(e: IpcRendererEvent, data: WebSource) => {
floatingContainer.style.display = "block";
items.push(data);
// 清空列表
while (floatingList?.firstChild) {
floatingList.removeChild(floatingList.firstChild);
}
for (let i = 0; i < items.length; i++) {
const item = items[i];
// 创建列表项元素
const elItem = document.createElement("li");
elItem.textContent = item.name;
elItem.title = item.name;
elItem.style.cssText = itemStyle;
elItem.addEventListener("click", () => {
ipcRenderer.invoke("add-download-item", {
name: data.name,
url: data.url,
});
});
elItem.addEventListener("mouseenter", () => {
elItem.style.backgroundColor = "#fff";
elItem.style.color = "#000";
});
elItem.addEventListener("mouseleave", () => {
elItem.style.backgroundColor = "#000";
elItem.style.color = "#fff";
});
// 将列表项添加到浮窗列表
floatingList.appendChild(elItem);
}
}
);
// 将按钮和列表添加到浮窗容器
floatingContainer.appendChild(floatingList);
floatingContainer.appendChild(floatingButton);
// 鼠标进入浮窗按钮时的处理函数
floatingContainer.addEventListener("mouseenter", () => {
floatingButton.style.opacity = "1";
floatingList.style.display = "block";
});
// 鼠标离开浮窗按钮时的处理函数
floatingContainer.addEventListener("mouseleave", () => {
floatingButton.style.opacity = "0.8";
floatingList.style.display = "none";
});
document.addEventListener("DOMContentLoaded", () => {
// 将浮窗容器添加到body元素
document.body.appendChild(floatingContainer);
});
+82
View File
@@ -0,0 +1,82 @@
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";
import Window from "./window";
@injectable()
export default class BrowserWindowServiceImpl
extends Window
implements BrowserWindowService
{
url = isDev
? "http://localhost:8555/browser"
: "mediago://index.html/browser";
constructor(
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
super({
width: 1100,
minWidth: 414,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
});
this.storeService.onDidChange("openInNewWindow", this.handleNewWindowsVal);
this.storeService.onDidAnyChange(this.storeChange);
}
storeChange = (store: any) => {
if (!this.window) return;
// 向所有窗口发送通知
this.window.webContents.send("store-change", store);
};
handleNewWindowsVal = (newValue: any) => {
if (!this.window) return;
// 向所有窗口发送通知
if (newValue === false) {
if (this.window && !this.window.isDestroyed()) {
this.window.close();
}
}
};
handleResize = () => {
if (!this.window) return;
const bounds = this.window.getBounds();
this.storeService.set("browserBounds", _.omit(bounds, ["x", "y"]));
};
showWindow = () => {
if (!this.window) {
this.window = this.create();
this.window.on("resized", this.handleResize);
}
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();
};
}
@@ -1,9 +1,4 @@
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Menu,
Notification,
} from "electron";
import { Menu, Notification, app } from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
@@ -17,12 +12,15 @@ import {
VideoRepository,
} from "../interfaces";
import { event } from "helper/utils";
import _ from "lodash";
import Window from "./window";
@injectable()
export default class MainWindowServiceImpl
extends BrowserWindow
extends Window
implements MainWindowService
{
url = isDev ? "http://localhost:8555/" : "mediago://index.html/";
constructor(
@inject(TYPES.LoggerService)
private readonly logger: LoggerService,
@@ -33,7 +31,7 @@ export default class MainWindowServiceImpl
@inject(TYPES.StoreService)
private readonly storeService: StoreService
) {
const options: BrowserWindowConstructorOptions = {
super({
width: 1100,
minWidth: 1100,
height: 680,
@@ -43,36 +41,73 @@ export default class MainWindowServiceImpl
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
};
super(options);
}
});
init(): void {
Menu.setApplicationMenu(null);
const url = isDev ? "http://localhost:8555/" : "mediago://index.html/";
void this.loadURL(url);
this.once("ready-to-show", this.readyToShow);
event.on("download-progress", this.onDownloadProgress);
this.downloadService.on("download-ready-start", this.onDownloadReadyStart);
this.downloadService.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) => {
// 向所有窗口发送通知
this.webContents.send("store-change", store);
});
this.storeService.onDidAnyChange(this.storeChange);
app.on("second-instance", this.secondInstance);
}
readyToShow = () => {
this.show();
isDev && this.webContents.openDevTools();
onDownloadReadyStart = ({ id, isLive }: { id: number; isLive: boolean }) => {
this.videoRepository.changeVideoIsLive(id, isLive);
this.send("change-video-is-live", { id, isLive });
};
init(): void {
if (this.window) {
// 如果窗口已经存在,则直接显示
this.window.show();
return;
}
Menu.setApplicationMenu(null);
this.window = this.create();
const mainBounds = this.storeService.get("mainBounds");
if (mainBounds) {
this.window.setBounds(mainBounds);
}
// 处理当前窗口改变大小
this.window.on("resized", this.handleResize);
}
handleResize = () => {
if (!this.window) return;
const bounds = this.window.getBounds();
this.storeService.set("mainBounds", _.omit(bounds, ["x", "y"]));
};
secondInstance = () => {
if (!this.window) return;
if (process.platform === "win32") {
if (this.window) {
if (this.window.isMinimized()) {
this.window.restore();
}
if (this.window.isVisible()) {
this.window.focus();
} else {
this.window.show();
}
}
}
};
storeChange = (store: any) => {
// 向所有窗口发送通知
this.send("store-change", store);
};
onDownloadProgress = (progress: DownloadProgress) => {
this.webContents.send("download-progress", progress);
this.send("download-progress", progress);
};
onDownloadSuccess = async (id: number) => {
@@ -86,7 +121,7 @@ export default class MainWindowServiceImpl
}).show();
}
this.webContents.send("download-success", id);
this.send("download-success", id);
};
onDownloadFailed = async (id: number, err: any) => {
@@ -100,14 +135,20 @@ export default class MainWindowServiceImpl
}).show();
}
this.logger.error("下载失败:", err);
this.webContents.send("download-failed", id);
this.send("download-failed", id);
};
onDownloadStart = async (id: number) => {
this.webContents.send("download-start", id);
this.send("download-start", id);
};
onDownloadStop = async (id: number) => {
this.webContents.send("download-stop", id);
this.send("download-stop", id);
};
send(channel: string, ...args: any[]) {
if (!this.window) return;
this.window.webContents.send(channel, ...args);
}
}
+44
View File
@@ -0,0 +1,44 @@
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { PlayerWindowService, VideoRepository } from "../interfaces";
import { TYPES } from "types";
import Window from "./window";
@injectable()
export default class PlayerWindowServiceImpl
extends Window
implements PlayerWindowService
{
url = isDev ? "http://localhost:8555/player" : "mediago://index.html/player";
constructor(
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository
) {
super({
width: 1100,
minWidth: 1100,
height: 680,
minHeight: 680,
show: false,
frame: true,
webPreferences: {
preload: resolve(__dirname, "./preload.js"),
},
});
}
openWindow = async (id: number) => {
if (!this.window) {
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();
};
}
+43
View File
@@ -0,0 +1,43 @@
import { BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import isDev from "electron-is-dev";
export default class Window {
window: BrowserWindow | null = null;
options: BrowserWindowConstructorOptions;
url: string;
constructor(options: BrowserWindowConstructorOptions) {
this.options = options;
}
create() {
if (!this.url) {
throw new Error("url is required");
}
const window = new BrowserWindow(this.options);
void window.loadURL(this.url);
window.once("ready-to-show", this.readyToShow);
window.on("close", this.windowClose);
return window;
}
readyToShow = () => {
if (!this.window) return;
this.window.show();
isDev && this.window.webContents.openDevTools();
};
windowClose = () => {
if (!this.window) return;
// 防止 webview 同时被销毁
this.window.setBrowserView(null);
// 销毁窗口
this.window = null;
};
}
+2 -2
View File
@@ -10,10 +10,10 @@
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"moduleResolution": "Node",
"strictPropertyInitialization": false,
"typeRoots": ["node_modules/@types"],
"outDir": "build/main"
"outDir": "app/output/build/main"
},
"include": ["./src/**/*"]
}
+18
View File
@@ -0,0 +1,18 @@
/* 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",
},
rules: {
"prettier/prettier": "error",
},
};
+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/
+1
View File
@@ -0,0 +1 @@
{}
+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:mobile": "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
+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 };
});
+55
View File
@@ -0,0 +1,55 @@
<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 () => {
const res = await axios.get(`/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"
}
]
}

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