Compare commits

...

113 Commits

Author SHA1 Message Date
imsyy
0e0bde89bb feat: 支持批量删除歌曲 2024-09-27 18:24:48 +08:00
imsyy
acf60b8b75 🐳 chore: 支持 Docker 多平台 2024-09-27 16:53:09 +08:00
imsyy
3b5338d582 🌈 style: 更新样式优化 2024-09-27 16:14:38 +08:00
imsyy
0c82d6a096 feat: 完善本地目录管理 2024-09-27 11:34:53 +08:00
imsyy
50374e173e 🐞 fix: 托盘图标无法显示 #260 2024-09-27 09:13:02 +08:00
imsyy
62c9dc33db feat: 托盘菜单完善 2024-09-26 15:24:25 +08:00
imsyy
2b6d68ecbd 🦄 refactor: convert to TypeScript #174 2024-09-26 11:57:23 +08:00
imsyy
8a842aa6d6 🐞 fix: 修复海外地区播放问题 #238 2024-08-23 11:32:54 +08:00
底层用户
b0a08ce1b8 Merge pull request #214 from skykeyjoker/dev
更新Readme,增加一点该有的致谢🙏
2024-07-22 17:46:42 +08:00
底层用户
77ab94a59a 📃 docs: Update README.md 2024-07-22 17:45:47 +08:00
Skykey
2b65269ba9 Update README.md 2024-07-18 13:17:00 +08:00
imsyy
7ed33919ab 🔧 build: 支持 Mac Inter 部署 #203 2024-07-08 14:02:29 +08:00
imsyy
d20512e662 🔧 build: 支持 Mac Inter 部署 #203 2024-07-08 13:44:50 +08:00
底层用户
9423d2bf9e Merge pull request #186 from jcfun/dev
- 修复linux下无法记录窗口大小和位置的问题
- 修复主进程端口占用时无法自动切换端口的问题
- 统一程序关闭按钮的效果
2024-06-21 09:10:58 +08:00
jcfun
54fb8e74a8 fix: 修复linux下无法记录窗口大小和位置的问题; 修复主进程端口占用时无法自动切换端口的问题; feat: 统一程序关闭按钮的效果 2024-06-21 00:11:47 +08:00
imsyy
7d3d7696da 🐞 fix: 播放器展开时可拖拽 #168 2024-06-18 09:33:21 +08:00
imsyy
e66ba52889 🐞 fix: 修复酷我音源失效 2024-06-06 09:34:52 +08:00
imsyy
b146dc011e feat: 自动切换可用端口 2024-06-04 15:03:02 +08:00
imsyy
ad2422d826 🐞 fix: 修复酷我解灰失效 2024-05-28 09:47:33 +08:00
imsyy
6b9ba74c35 🦄 refactor: 图片大小优化 2024-05-24 11:36:18 +08:00
imsyy
22e2653e20 🐞 fix: 修复图片 2024-05-24 11:19:20 +08:00
imsyy
4cac54c84a feat: 电台模式完善 2024-05-24 11:01:42 +08:00
imsyy
e5f9ecd7b5 ↩ revert: 回退部分修改 2024-05-24 10:47:14 +08:00
imsyy
d64cfb40ec Revert "🐎 ci: 修复工作流"
This reverts commit f2935f3c1c.
2024-05-24 10:40:36 +08:00
imsyy
53c30caf00 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-05-24 10:27:04 +08:00
imsyy
6fcce91b2b ↩ revert: 回退 2024-05-24 10:26:53 +08:00
imsyy
5a53dfcdf7 🐞 fix: 修正图片 2024-05-24 10:09:43 +08:00
imsyy
bbe71bd62d feat: 支持查看电台节目评论 2024-05-23 18:01:16 +08:00
imsyy
ecedf0b7b9 🦄 refactor: add .gitattributes 2024-05-23 15:21:08 +08:00
imsyy
f2935f3c1c 🐎 ci: 修复工作流 2024-05-10 16:55:15 +08:00
imsyy
d3e677d494 🐎 ci: 修复构建工作流 2024-05-10 16:24:51 +08:00
imsyy
e49f90b0da feat: 电台模式下可展开播放器 2024-05-10 16:11:31 +08:00
imsyy
0359b0e470 🔧 build: 更新依赖 2024-05-06 11:24:15 +08:00
imsyy
aa2373695b 🔧 build: 去除无关文件 2024-04-29 17:47:29 +08:00
imsyy
cf940ff405 🌈 style: 优化部分样式 2024-04-29 17:43:56 +08:00
imsyy
e7c17cf531 🐞 fix: 修复偶发性客户端状态栏异常 2024-04-26 10:02:25 +08:00
imsyy
c6a1ca4f42 feat: 新增自定义字体 #156 & 网络代理 #159 2024-04-25 18:17:50 +08:00
imsyy
8f5008df69 🐞 fix: 修复大部分历史遗留错误 #144
- 修复客户端导航栏显示异常的问题
- 优化频谱动画的显示效果
- 修复Tab栏移动效果 #144
- 优化字体粗体时的显示效果 #156
2024-04-16 17:51:15 +08:00
imsyy
428ce4be86 feat: 支持配置自动更新开关 #148 2024-03-21 14:11:49 +08:00
imsyy
d46c4c4285 🐞 fix: 修复标题栏异常消失 #142 2024-03-14 13:41:31 +08:00
imsyy
0b871175b2 🐞 fix: 修复标题栏异常消失 #142 2024-03-14 11:57:29 +08:00
imsyy
c34c4fd880 📃 docs: update Docs 2024-03-05 18:00:26 +08:00
imsyy
ff00f0c283 🐞 fix: 修复标题栏无法正常显示 #142 2024-02-22 15:18:26 +08:00
imsyy
847c2e5810 📃 docs: 更新说明 2024-01-25 09:22:11 +08:00
imsyy
e62c81bb33 🐞 fix: 修复电台未加载全部节目 2024-01-18 16:25:03 +08:00
imsyy
984fdb3459 📃 docs: 更新说明 2024-01-18 11:45:46 +08:00
imsyy
019b78bf38 🐞 fix: 修复歌单无法删除歌曲 2024-01-13 10:23:40 +08:00
imsyy
cf88c7669f 📃 docs: 更新说明 2024-01-12 11:14:09 +08:00
imsyy
f4383ba848 feat: 播放页面支持调节音量 #124 2024-01-12 11:08:35 +08:00
imsyy
adbda459ba 🐞 fix: 修复下载歌曲元信息不正确导致无法正常播放 #113 2024-01-11 16:09:09 +08:00
imsyy
984d747179 🐞 fix: 修复本地歌词翻译显示异常 #121 2024-01-11 11:39:29 +08:00
imsyy
c012f84064 🐳 chore: Docker 自动部署 2024-01-10 16:00:02 +08:00
imsyy
a57a18b9f5 feat: 播放模式支持点击切换 2024-01-10 14:47:59 +08:00
imsyy
309c323a14 🔧 build: support ESM and upgrade to Vite 5
- 临时解决下载歌曲无法正常播放 #113
2024-01-09 18:13:01 +08:00
imsyy
6a1e606d6d feat: 移动端基础适配
- 修复未登录时无法使用本地歌曲
- 修复部分样式异常
2024-01-08 18:20:47 +08:00
imsyy
af3931847e 🐞 fix: 修复音乐缓存导致播放异常 2024-01-05 11:32:40 +08:00
imsyy
41eadb5843 🌈 style: 优化部分样式 2024-01-05 11:15:24 +08:00
imsyy
8963d719d9 feat: 侧边栏支持显示歌单封面 #111 2024-01-03 16:36:54 +08:00
imsyy
0a7761ffff 🐞 fix: 解决音频资源过期问题 2024-01-03 11:33:59 +08:00
imsyy
1a63771f2d feat: 新增雷达歌单 2024-01-03 10:54:27 +08:00
imsyy
1f9141ba33 🔧 build: 更新部分依赖版本 2024-01-02 18:13:01 +08:00
imsyy
a341a69d48 feat: 卡片播放按钮可直接播放 #111 2023-12-29 14:32:10 +08:00
imsyy
0cedfe0af3 feat: 支持播放超大歌单
- 支持大于 2000 首歌曲的歌单播放
2023-12-28 17:46:57 +08:00
imsyy
59f492ed8f feat: 新增音乐频谱显示 2023-12-27 16:47:10 +08:00
imsyy
8f416ff841 🐞 fix: 修复当电台模式时播放列表出现错误 2023-12-27 10:26:42 +08:00
imsyy
99ab194e4b 🐳 chore: Change Dockerfile 2023-12-26 13:55:31 +08:00
底层用户
43fb9b48dc 🔧 Merge pull request #109 from imsyy/dependabot/npm_and_yarn/postcss-8.4.32
build(deps): bump postcss from 8.4.28 to 8.4.32
2023-12-26 09:42:46 +08:00
底层用户
c61e54d6a3 🔧 Merge pull request #108 from imsyy/dependabot/npm_and_yarn/babel/traverse-7.23.6
build(deps-dev): bump @babel/traverse from 7.22.11 to 7.23.6
2023-12-26 09:42:38 +08:00
底层用户
c8d195053f 🔧 Merge pull request #107 from imsyy/dependabot/npm_and_yarn/vite-4.4.12
build(deps-dev): bump vite from 4.4.9 to 4.4.12
2023-12-26 09:41:50 +08:00
底层用户
8cfe5d0481 🔧 Merge pull request #106 from imsyy/dependabot/npm_and_yarn/axios-1.6.0
build(deps): bump axios from 1.4.0 to 1.6.0
2023-12-26 09:41:27 +08:00
dependabot[bot]
fcc2f5015f build(deps): bump postcss from 8.4.28 to 8.4.32
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.28 to 8.4.32.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.28...8.4.32)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-26 01:33:27 +00:00
dependabot[bot]
9b98a45264 build(deps-dev): bump @babel/traverse from 7.22.11 to 7.23.6
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.11 to 7.23.6.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.6/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-26 01:22:34 +00:00
dependabot[bot]
3c4e836fb8 build(deps-dev): bump vite from 4.4.9 to 4.4.12
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.4.9 to 4.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-26 01:22:23 +00:00
dependabot[bot]
a8111b9d3f build(deps): bump axios from 1.4.0 to 1.6.0
Bumps [axios](https://github.com/axios/axios) from 1.4.0 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.4.0...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-26 01:22:18 +00:00
imsyy
8eaeffeda3 🎈 perf: 优化云盘缓存 2023-12-25 18:30:38 +08:00
imsyy
eed76966c4 feat: 新增电台模式
- 修复搜索框无法输入空格 #102
- 优化部分动画展示
2023-12-25 16:02:32 +08:00
imsyy
b095e4eb36 🐞 fix: 修复播放模式切换时无法正常播放
- 改进侧边栏收起按钮样式 #100
2023-12-21 15:56:20 +08:00
imsyy
3dbdf3e613 🐞 fix: 修复日推日期计算错误 #101
- 修复浏览器端下载出现提示
- 新增搜索页面播放控制
2023-12-21 11:04:26 +08:00
imsyy
5ceca058a7 🔧 build: Sync NeteaseCloudMusicApi #82 2023-12-20 16:38:20 +08:00
imsyy
a8e867bbf9 🐞 fix: 修复签到出错 2023-12-20 15:35:09 +08:00
imsyy
4cb8eb0213 feat: 支持 Docker 部署 #82 2023-12-20 14:40:39 +08:00
imsyy
461f216cab 🐞 fix: 修复无法添加歌单 2023-12-20 10:06:40 +08:00
imsyy
2756313e4a 🦄 refactor: 重构全局播放列表
- 修复删除歌曲时播放异常
- 使用虚拟列表以提升性能
2023-12-19 18:08:46 +08:00
imsyy
e802a2f574 feat: 支持自动签到 2023-12-19 14:12:53 +08:00
imsyy
a45940b104 🦄 refactor: 修改部分文件名称 2023-12-18 16:00:17 +08:00
imsyy
ac0ac5f4ea feat: 新增每日推荐 2023-12-18 15:51:36 +08:00
imsyy
883b6d13a5 🔧 build: 修复部分构建图标显示错误 2023-12-16 16:07:51 +08:00
imsyy
ee9bbf0687 🌈 style: 修复部分样式显示错误 2023-12-15 17:31:55 +08:00
imsyy
693dc65b07 🐎 ci: 修复部分构建步骤 2023-12-15 14:58:51 +08:00
imsyy
354d271582 🐎 ci: 修复构建失败 2023-12-15 11:16:37 +08:00
imsyy
eaaeb0f5d3 🎈 perf: 使用配置文件模板 2023-12-15 09:31:56 +08:00
imsyy
e4e8deec59 🎈 perf: 使用配置文件模板 2023-12-15 09:31:06 +08:00
imsyy
3f21704b82 feat: 添加应用内更新提醒 2023-12-14 18:17:58 +08:00
imsyy
7ad2bb8bde 🐞 fix: 修复偶发性播放错误
优化播放器动画效果
2023-12-14 15:10:23 +08:00
imsyy
3123e4f5f8 🔧 build: 切换依赖版本 2023-12-13 17:49:37 +08:00
imsyy
60e43c9f40 🔧 build: 临时切换依赖版本 2023-12-13 17:38:26 +08:00
imsyy
298813a057 🐞 fix: 修复歌手详情获取失败 2023-12-13 17:23:18 +08:00
imsyy
2db74f3a39 🐎 ci: 添加版本信息 2023-12-13 10:14:54 +08:00
imsyy
024ff1773e feat: 网页端支持 PWA #97 2023-12-12 18:11:28 +08:00
imsyy
6046e5a153 📃 docs: Change LICENSE 2023-12-12 12:49:21 +08:00
imsyy
3c39dbd87f feat: 收藏页面新增歌单 2023-12-12 11:03:20 +08:00
imsyy
c5747b6a3e fix: 修复底部播放器样式错误 2023-12-11 14:29:15 +08:00
imsyy
750d570c3d feat: 支持关闭侧边栏 2023-12-11 13:35:46 +08:00
imsyy
b811b00b9f feat: 新增我的收藏页面 2023-12-08 14:25:37 +08:00
imsyy
a372570038 feat: 新增纯净歌词模式 2023-12-07 15:27:15 +08:00
imsyy
6d5fa15098 feat: 支持云盘歌曲纠正 2023-12-07 13:34:51 +08:00
imsyy
b65369a8a6 fix: 修正部分样式错误 #95 2023-12-06 15:20:08 +08:00
imsyy
0af0ac3cce fix: 修复下载权限错误 & 播放器界面显示异常 2023-12-05 11:00:29 +08:00
imsyy
f0ed78eed5 feat: 新增手机号登录 2023-12-05 10:25:52 +08:00
imsyy
b1cda68c75 feat: 播放器新增唱片模式 & fix: 修复侧边栏样式异常 #95 2023-12-04 18:04:03 +08:00
imsyy
dd1081cfa2 fix: 修复快捷键异常占用 & 去除部分动画效果 2023-12-04 13:35:06 +08:00
imsyy
046b8f3a92 fix: 修复快捷键异常触发 #95 2023-12-02 17:25:23 +08:00
imsyy
72650a5419 feat: 完善搜索建议跳转 & fix: 修复部分播放问题 2023-12-01 15:29:14 +08:00
573 changed files with 34116 additions and 22989 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.github
.gitignore
README.md
LICENSE
.vscode
dist

View File

@@ -1,9 +1,18 @@
# 根配置文件
## 编辑器在查找配置时会停止查找更高层次的配置文件
root = true
# 通配符,匹配所有文件
[*]
# 设置字符集为 UTF-8确保文件中的文本使用 UTF-8 编码
charset = utf-8
# 使用空格作为缩进风格
indent_style = space
# 设置每个缩进级别的空格数量为 2
indent_size = 2
# 设置行尾换行符为LFLine Feed
end_of_line = lf
# 在文件的末尾插入一个新行
insert_final_newline = true
trim_trailing_whitespace = true
# 删除每一行末尾的尾随空格
trim_trailing_whitespace = true

29
.env
View File

@@ -1,29 +0,0 @@
# 程序配置
## 程序名称
MAIN_VITE_TITLE = "SPlayer"
## 程序主端口
MAIN_VITE_MAIN_PORT = 7899
## 程序开发环境运行端口
MAIN_VITE_DEV_PORT = 6944
# 全局 API 配置
## API 运行地址
MAIN_VITE_SERVER_HOST = 127.0.0.1
## API 运行端口
MAIN_VITE_SERVER_PORT = 11451
## API 在线地址( 网址结尾不要加 /
### 用于非客户端( 浏览器环境
RENDERER_VITE_SERVER_URL = /api
# 程序信息
RENDERER_VITE_SITE_TITLE = "SPlayer"
RENDERER_VITE_SITE_ANTHOR = "無名"
RENDERER_VITE_SITE_KEYWORDS = "SPlayer,云音乐,播放器,在线音乐,在线播放器,音乐播放器"
RENDERER_VITE_SITE_DES = "一个简约的在线音乐播放器具有音乐搜索、播放、每日推荐、私人FM、歌词显示、歌曲评论、网易云登录与云盘等功能"
RENDERER_VITE_SITE_URL = "imsyy.top"
RENDERER_VITE_SITE_LOGO = "/images/logo/favicon.svg"
RENDERER_VITE_SITE_APPLE_LOGO = "/images/logo/favicon-apple.png"
# Cookie
## 咪咕音乐 Cookie
MAIN_VITE_MIGU_COOKIE = ""

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
## WEB 端口
VITE_WEB_PORT = 14558
## API 端口
VITE_SERVER_PORT = 25884
## API 地址 - 结尾不要加 /
VITE_API_URL = /api/netease

View File

@@ -2,3 +2,5 @@ node_modules
dist
out
.gitignore
auto-imports.d.ts
components.d.ts

297
.eslintrc-auto-import.json Normal file
View File

@@ -0,0 +1,297 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"pausableWatch": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useAnimate": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDialog": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLoadingBar": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useMessage": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNotification": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

View File

@@ -1,53 +1,51 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"@electron-toolkit",
"@vue/eslint-config-prettier",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
],
rules: {
"vue/v-on-event-hyphenation": "off",
"vue/require-default-prop": "off",
"vue/multi-word-component-names": "off",
"vue/attribute-hyphenation": "off",
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parserOptions: {
ecmaVersion: "latest",
parser: "@typescript-eslint/parser",
sourceType: "module",
},
ignorePatterns: [
"node_modules/",
"build/",
"dist/",
"out/",
"components.d.ts",
"auto-imports.d.ts",
],
globals: {
defineProps: true,
defineEmits: true,
withDefaults: true,
h: true,
vue: true,
ref: true,
reactive: true,
computed: true,
watch: true,
provide: true,
inject: true,
defineComponent: true,
onBeforeMount: true,
onBeforeUnmount: true,
onUnmounted: true,
onMounted: true,
nextTick: true,
watchEffect: true,
electron: true,
$message: true,
$dialog: true,
$loadingBar: true,
$changeLogin: true,
$notification: true,
$changeThemeColor: true,
$canNotConnect: true,
plugins: ["@typescript-eslint", "vue"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
},
global: {
h: "readonly",
ref: "readonly",
computed: "readonly",
watch: "readonly",
watchEffect: "readonly",
onBeforeMount: "readonly",
onBeforeUnmount: "readonly",
onBeforeUpdate: "readonly",
reactive: "readonly",
onMounted: "readonly",
onUnmounted: "readonly",
onActivated: "readonly",
onDeactivated: "readonly",
onRenderTracked: "readonly",
onRenderTriggered: "readonly",
onServerPrefetch: "readonly",
},
};

View File

@@ -1,17 +0,0 @@
name: 添加功能
description: 请填写希望添加的功能的具体信息
title: "添加功能"
labels: [add]
body:
- type: input
id: name
validations:
required: true
attributes:
label: "希望添加什么功能?"
placeholder: "请填写功能名称"
- type: textarea
id: other
attributes:
label: "具体信息"
description: "请详细描述希望添加的功能的具体信息"

View File

@@ -1,5 +1,6 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
title: 请填写标题
labels: [bug]
body:
- type: input
@@ -30,4 +31,5 @@ body:
id: other
attributes:
label: "具体信息"
description: "有需要补充的信息吗?比如控制台的报错什么的"
description: "请填写完整的复现步骤和遇到的问题,包括但不限于报错信息、控制台输出、网络请求等"
placeholder: "请填写具体的复现步骤和遇到的问题"

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 添加功能
url: https://github.com/imsyy/SPlayer/discussions/new?category=%E6%83%B3%E6%B3%95-ideas
about: 新的功能建议和提问答疑请到讨论区发起
- name: 转到讨论区
url: https://github.com/imsyy/SPlayer/discussions
about: Issues 用于反馈 Bug, 新的功能建议和提问答疑请到讨论区发起
- name: 提问的艺术
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
about: 默认所有 Issues 发起者均已了解此处的内容

View File

@@ -15,12 +15,20 @@ jobs:
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4.0.0
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
# 复制环境变量文件
- name: Copy .env.example
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: npm install
@@ -35,7 +43,20 @@ jobs:
npx rimraf "dist/!(*.exe)"
# 上传构建产物
- name: Upload artifacts
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v4
with:
name: SPlayer-dev
path: dist
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
tag_name: ${{ github.ref }}
name: ${{ github.ref }}-rc
body: This version is still under development, currently only provides windows version, non-developers please do not use!
draft: false
prerelease: true
files: dist/*.exe
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

56
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Publish Docker image
on:
release:
types: [published]
workflow_dispatch:
jobs:
push_to_registry:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
imsyy/splayer
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -12,15 +12,24 @@ jobs:
build-windows:
name: Build for Windows
runs-on: windows-latest
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4.0.0
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
# 复制环境变量文件
- name: Copy .env.example
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: npm install
@@ -32,38 +41,43 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 上传构建产物
- name: Upload Windows artifact
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v4
with:
name: SPlarer-Win
if-no-files-found: ignore
path: |
dist/*.exe
dist/*.msi
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.exe
dist/*.msi
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Mac
build-macos:
name: Build for macOS
runs-on: macos-latest
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4.0.0
uses: actions/setup-node@v4
with:
node-version: "18.x"
# 安装项目依赖
node-version: "20.x"
# 复制环境变量文件
- name: Copy .env.example
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
# 构建 Electron App
@@ -74,40 +88,45 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 上传构建产物
- name: Upload macOS artifact
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v4
with:
name: SPlarer-Macos
if-no-files-found: ignore
path: |
dist/*.dmg
dist/*.zip
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.dmg
dist/*.zip
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Linux
build-linux:
name: Build for Linux
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4.0.0
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
# 更新 Ubuntu 软件源
- name: Ubuntu Update with sudo
run: sudo apt-get update
# 复制环境变量文件
- name: Copy .env.example
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
@@ -119,23 +138,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 上传构建产物
- name: Upload Linux artifact
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v4
with:
name: SPlarer-Linux
if-no-files-found: ignore
path: |
dist/*.AppImage
dist/*.deb
dist/*.rpm
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.AppImage
dist/*.deb
dist/*.rpm
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

6
.gitignore vendored
View File

@@ -14,9 +14,9 @@ dist-ssr
coverage
*.local
out
/cypress/videos/
/cypress/screenshots/
.env
auto-imports.d.ts
components.d.ts
# Editor directories and files
.vscode/*

5
.npmrc
View File

@@ -1,2 +1,5 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
registry=https://registry.npmmirror.com
disturl=https://registry.npmmirror.com/-/binary/node
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true

View File

@@ -2,5 +2,7 @@ out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
auto-imports.d.ts
components.d.ts
# tsconfig.json
# tsconfig.*.json

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}

View File

@@ -1,4 +0,0 @@
singleQuote: false
semi: true
printWidth: 100
trailingComma: all

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# build
FROM node:20-alpine AS builder
RUN apk update && apk add --no-cache git
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# add .env.example to .env
RUN [ ! -e ".env" ] && cp .env.example .env || true
RUN npm run build
# nginx
FROM nginx:1.27-alpine-slim AS app
COPY --from=builder /app/out/renderer /usr/share/nginx/html
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache npm
RUN npm install -g NeteaseCloudMusicApi
CMD nginx && npx NeteaseCloudMusicApi

149
LICENSE
View File

@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

257
README.md
View File

@@ -1,25 +1,28 @@
> [!IMPORTANT]
> ## 🎉 当前项目正在重构中 🎉
>
> - 目前版本进入维护模式,仅在遇到重大问题时会进行修复
> - 支持客户端与网页端
> - 支持现有版本所有功能
> - 新增支持播放与管理本地歌曲
# SPlayer
<div align="center">
<img alt="logo" height="80" src="./public/images/logo/favicon.png" />
<h2>SPlayer</h2>
<p>一个简约的音乐播放器</p>
<img alt="main" src="./screenshots/main.png" />
</div>
<br />
> 一个简约的音乐播放器
![main](/screenshots/SPlayer.jpg)
## 说明
- 本项目采用 [Vue 3](https://cn.vuejs.org/) 全家桶和 [Naïve UI](https://www.naiveui.com/) 组件库及 [Electron](https://www.electronjs.org/zh/docs/latest/) 开发
- 支持网页端与客户端,由于设备有限,目前仅适配 `Win`,其他平台可自行构建
- ~~仅对移动端做了基础适配,**不保证功能全部可用**~~
- 欢迎各位大佬指点和 `Star` 哦 😍
> [!IMPORTANT]
>
> ### 严肃警告
>
> - 请务必遵守 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 许可协议
> - 在您的修改、演绎、分发或派生项目中,必须同样采用 **AGPL-3.0** 许可协议,**并在适当的位置包含本项目的许可和版权信息**
> - **禁止用于售卖或其他盈利用途**,如若发现,作者保留追究法律责任的权利
> - 禁止在二开项目中修改程序原版权信息( 您可以添加二开作者信息
> - 感谢您的尊重与理解
- 本项目采用 [Vue 3](https://cn.vuejs.org/) + [TypeScript](https://www.typescriptlang.org/) + [Naïve UI](https://www.naiveui.com/) + [Electron](https://www.electronjs.org/zh/docs/latest/) 开发
- 支持网页端与客户端,由于设备有限,目前仅适配 `Win`,其他平台可自行解决兼容性后进行构建
- 仅对移动端做了基础适配,**不保证功能全部可用**
> 请注意,本程序不打算开发移动端,也不会对移动端进行完美适配,仅保证基础可用性
- 欢迎各位大佬 `Star` 😍
## 👀 Demo
@@ -27,40 +30,36 @@
## 🎉 功能
- 支持扫码登录
- 支持手机号登录
- 自动进行每日签到及云贝签到
- 封面主题色自适应
- 本地歌曲管理及分类 ~~以及音乐标签编辑~~
- **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- 下载歌曲(最高支持 Hi-Res
- 新建歌单及歌单编辑
- 收藏 / 取消收藏歌单或歌手
- 每日推荐歌曲
- 私人 FM
- 云盘音乐上传
- 云盘内歌曲播放
- 云盘内歌曲纠正
- 云盘歌曲删除
- 支持逐字歌词
- 歌词滚动以及歌词翻译
- MV 与视频播放
- 音乐频谱显示( 暂时去除,还待完善
- 音乐渐入渐出
- 支持 PWA
- 支持评论区及评论点赞
- 明暗模式自动 / 手动切换
- ~~移动端基础适配~~
- ~~`i18n` 支持~~
- 支持扫码登录
- 📱 支持手机号登录
- 📅 自动进行每日签到及云贝签到
- 💻 支持桌面歌词
- 💻 支持切换为本地播放器,此模式将不会连接网络
- 🎨 封面主题色自适应,支持全站着色
- 🌚 Light / Dark / Auto 模式自动切换
- 📁 本地歌曲管理及分类(建议先使用 [音乐标签](https://www.cnblogs.com/vinlxc/p/11347744.html) 进行匹配后再使用)
- 📁 简易的本地音乐标签编辑及封面修改
- 🎵 **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- ⬇️ 下载歌曲( 最高支持 Hi-Res需具有相应会员账号
- 新建歌单及歌单编辑
- ❤️ 收藏 / 取消收藏歌单或歌手
- 🎶 每日推荐歌曲
- 📻 私人 FM
- ☁️ 云盘音乐上传
- 📂 云盘内歌曲播放
- 🔄 云盘内歌曲纠正
- 🗑️ 云盘歌曲删除
- 📝 支持逐字歌词
- 🔄 歌词滚动以及歌词翻译
- 📹 MV 与视频播放
- 🎶 音乐频谱显示
- ⏭️ 音乐渐入渐出
- 🔄 支持 PWA
- 💬 支持评论区
- 📱 移动端基础适配
- ~~🌐 `i18n` 支持~~
#### 待办
- [ ] 完善音乐频谱
- [ ] 添加桌面歌词
- [ ] 多种布局方式
- [ ] 发表评论
## 🖼️ Screenshots
## 🖼️ screenshots
> 开发中,仅供参考
@@ -118,68 +117,107 @@
[Dev Workflow](https://github.com/imsyy/SPlayer/actions/workflows/build.yml)
## ⚙️ 部署
## ⚙️ Docker 部署
> Vercel 等托管平台可在 Fork 后一键导入并自动部署
> 安装及配置 `Docker` 将不在此处说明,请自行解决
### API 服务(客户端无需理会,如果需要网页端,则必需部署)
### 本地构建
> 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目
- 请在根目录下的 `.env` 文件中的 `RENDERER_VITE_SERVER_URL` 中填入 API 地址(必需)
```js
RENDERER_VITE_SERVER_URL = "your api url";
```
### 安装依赖
> 请尽量拉取最新分支后使用本地构建方式,在线部署的仓库可能更新不及时
```bash
pnpm install
# 或者
yarn install
# 或者
npm install
# 构建
docker build -t splayer .
# 运行
docker run -d --name SPlayer -p 25884:25884 splayer
# 或使用 Docker Compose
docker-compose up -d
```
### 开发
### 在线部署
```bash
pnpm dev
# 或者
yarn dev
# 或者
npm dev
# 从 Docker Hub 拉取
docker pull imsyy/splayer:latest
# 从 GitHub ghcr 拉取
docker pull ghcr.io/imsyy/splayer:latest
# 运行
docker run -d --name SPlayer -p 25884:25884 imsyy/splayer:latest
```
### 构建网页端
以上步骤成功后,将会在本地 [localhost:25884](http://localhost:25884/) 启动,如需更换端口,请自行修改命令行中的端口号
```bash
pnpm build
# 或者
yarn build
# 或者
npm build
```
## ⚙️ Vercel 部署
构建完成后可将生成的 `out/renderer` 文件夹内的文件上传至服务器
> 其他部署平台大致相同,在此不做说明
若使用的为第三方部署平台,比如 `Vercel`,请将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
1. 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目,并成功取得在线访问地址
2. 点击本仓库右上角的 `Fork`,复制本仓库到你的 `GitHub` 账号
3. 复制 `/.env.example` 文件并重命名为 `/.env`
4.`.env` 文件中的 `VITE_API_URL` 改为第一步得到的 API 地址
![build](/screenshots/build.png)
```js
VITE_API_URL = "https://example.com";
```
### 构建客户端
5. 将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
```bash
# win
pnpm build:win
# linux
pnpm build:linux
# mac
pnpm build:mac
```
![build](/screenshots/build.jpg)
构建完成后可在 `dist` 文件夹中打开可执行文件来完成安装操作
6. 点击 `Deploy`,即可成功部署
## ⚙️ 服务器部署
1. 重复 `⚙️ Vercel 部署` 中的 1 - 4 步骤
2. 克隆仓库
```bash
git clone https://github.com/imsyy/SPlayer.git
```
3. 安装依赖
```bash
pnpm install
# 或
yarn install
# 或
npm install
```
4. 编译打包
```bash
pnpm build
# 或
yarn build
# 或
npm build
```
5. 将站点运行目录设置为 `out/renderer` 目录
## ⚙️ 本地部署
1. 本地部署需要用到 `Node.js`。可前往 [Node.js 官网](https://nodejs.org/zh-cn/) 下载安装包,请下载最新稳定版
2. 安装 pnpm
```bash
npm install pnpm -g
```
3. 克隆仓库并拉取至本地,此处不再赘述
4. 使用 `pnpm install` 安装项目依赖(若安装过程中遇到网络错误,请使用国内镜像源替代,此处不再赘述)
5. 复制 `/.env.example` 文件并重命名为 `/.env` 并修改配置
6. 打包客户端,请依据你的系统类型来选择,打包成功后,会输出安装包或可执行文件在 `/dist` 目录中,可自行安装
| 命令 | 系统类型 |
| ------------------ | -------- |
| `pnpm build:win` | Windows |
| `pnpm build:linux` | Linux |
| `pnpm build:mac` | MacOS |
## 😘 鸣谢
@@ -188,18 +226,10 @@ pnpm build:mac
- [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
- [YesPlayMusic](https://github.com/qier222/YesPlayMusic)
- [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server)
- [BlurLyric](https://github.com/Project-And-Factory/BlurLyric)
- [applemusic-like-lyrics](https://github.com/Steve-xmh/applemusic-like-lyrics)
- [Vue-mmPlayer](https://github.com/maomao1996/Vue-mmPlayer)
## 📜 开源许可
- **本项目仅供个人学习研究使用,禁止用于商业及非法用途**
- 本项目基于 [GNU General Public License version 3](https://opensource.org/license/gpl-3-0/) 许可进行开源
1. **修改和分发:** 任何对本项目的修改和分发都必须基于 GPL Version 3 进行,源代码必须一并提供
2. **派生作品:** 任何派生作品必须同样采用 GPL Version 3并在适当的地方注明原始项目的许可证
3. **免责声明:** 根据 GPL Version 3本项目不提供任何明示或暗示的担保。请详细阅读 GPL Version 3以了解完整的免责声明内容
4. **社区参与:** 欢迎社区的参与和贡献,我们鼓励开发者一同改进和维护本项目
5. **许可证链接:** 请阅读 [GNU General Public License version 3](https://opensource.org/license/gpl-3-0/) 了解更多详情
- [refined-now-playing-netease](https://github.com/solstice23/refined-now-playing-netease)
- [material-color-utilities](https://github.com/material-foundation/material-color-utilities)
## 📢 免责声明
@@ -210,3 +240,18 @@ pnpm build:mac
请使用者在使用本项目时遵守相关法律法规,**不要将本项目用于任何商业及非法用途。如有违反,一切后果由使用者自负。** 同时,使用者应该自行承担因使用本项目而带来的风险和责任。本项目开发者不对本项目所提供的服务和内容做出任何保证
感谢您的理解
## 📜 开源许可
- **本项目仅供个人学习研究使用,禁止用于商业及非法用途**
- 本项目基于 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 许可进行开源
1. **修改和分发:** 任何对本项目的修改和分发都必须基于 AGPL-3.0 进行,源代码必须一并提供
2. **派生作品:** 任何派生作品必须同样采用 AGPL-3.0,并在适当的地方注明原始项目的许可证
3. **注明原作者:** 在任何修改、派生作品或其他分发中,必须在适当的位置明确注明原作者及其贡献
4. **免责声明:** 根据 AGPL-3.0,本项目不提供任何明示或暗示的担保。请详细阅读 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 以了解完整的免责声明内容
5. **社区参与:** 欢迎社区的参与和贡献,我们鼓励开发者一同改进和维护本项目
6. **许可证链接:** 请阅读 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 了解更多详情
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=imsyy/SPlayer&type=Date)](https://star-history.com/#imsyy/SPlayer&Date)

229
auto-imports.d.ts vendored
View File

@@ -6,64 +6,291 @@
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDialog: typeof import('naive-ui')['useDialog']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMessage: typeof import('naive-ui')['useMessage']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNotification: typeof import('naive-ui')['useNotification']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}

89
components.d.ts vendored
View File

@@ -1,32 +1,50 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AddPlaylist: typeof import('./src/components/Modal/AddPlaylist.vue')['default']
AboutSetting: typeof import('./src/components/Setting/AboutSetting.vue')['default']
ArtistList: typeof import('./src/components/List/ArtistList.vue')['default']
BatchList: typeof import('./src/components/Modal/batchList.vue')['default']
CloudMatch: typeof import('./src/components/Modal/CloudMatch.vue')['default']
CommentList: typeof import('./src/components/List/CommentList.vue')['default']
CountDown: typeof import('./src/components/Player/CountDown.vue')['default']
CoverDropdown: typeof import('./src/components/Cover/CoverDropdown.vue')['default']
CoverList: typeof import('./src/components/List/CoverList.vue')['default']
CoverMenu: typeof import('./src/components/Menu/CoverMenu.vue')['default']
CreatePlaylist: typeof import('./src/components/Modal/CreatePlaylist.vue')['default']
DownloadSong: typeof import('./src/components/Modal/DownloadSong.vue')['default']
FullPlayer: typeof import('./src/components/Player/FullPlayer.vue')['default']
GeneralSetting: typeof import('./src/components/Setting/GeneralSetting.vue')['default']
JumpArtist: typeof import('./src/components/Modal/JumpArtist.vue')['default']
KeyboardSetting: typeof import('./src/components/Setting/KeyboardSetting.vue')['default']
LocalSetting: typeof import('./src/components/Setting/LocalSetting.vue')['default']
Login: typeof import('./src/components/Modal/Login.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/LoginQRCode.vue')['default']
Lyric: typeof import('./src/components/Player/Lyric.vue')['default']
MainControl: typeof import('./src/components/Player/MainControl.vue')['default']
MainCover: typeof import('./src/components/Cover/MainCover.vue')['default']
MainNav: typeof import('./src/components/Nav/MainNav.vue')['default']
Menu: typeof import('./src/components/Global/Menu.vue')['default']
LoginPhone: typeof import('./src/components/Modal/loginPhone.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/loginQRCode.vue')['default']
LyricsSetting: typeof import('./src/components/Setting/LyricsSetting.vue')['default']
MainAMLyric: typeof import('./src/components/Player/MainAMLyric.vue')['default']
MainLyric: typeof import('./src/components/Player/MainLyric.vue')['default']
MainPlayer: typeof import('./src/components/Player/MainPlayer.vue')['default']
MainPlayList: typeof import('./src/components/Player/MainPlayList.vue')['default']
MainSetting: typeof import('./src/components/Setting/MainSetting.vue')['default']
Menu: typeof import('./src/components/Layout/Menu.vue')['default']
NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert']
Nav: typeof import('./src/components/Layout/Nav.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -36,31 +54,41 @@ declare module 'vue' {
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFloatButton: typeof import('naive-ui')['NFloatButton']
NFloatButtonGroup: typeof import('naive-ui')['NFloatButtonGroup']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NH4: typeof import('naive-ui')['NH4']
NH6: typeof import('naive-ui')['NH6']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NLi: typeof import('naive-ui')['NLi']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination']
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
NOl: typeof import('naive-ui')['NOl']
NP: typeof import('naive-ui')['NP']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
@@ -68,7 +96,6 @@ declare module 'vue' {
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
@@ -77,24 +104,36 @@ declare module 'vue' {
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
Pagination: typeof import('./src/components/Global/Pagination.vue')['default']
NVirtualList: typeof import('naive-ui')['NVirtualList']
OtherSetting: typeof import('./src/components/Setting/OtherSetting.vue')['default']
PersonalFM: typeof import('./src/components/Player/PersonalFM.vue')['default']
PlayerBackground: typeof import('./src/components/Player/PlayerBackground.vue')['default']
PlayerComment: typeof import('./src/components/Player/PlayerComment.vue')['default']
PlayerControl: typeof import('./src/components/Player/PlayerControl.vue')['default']
Playlist: typeof import('./src/components/Global/Playlist.vue')['default']
PlaylistUpdate: typeof import('./src/components/Modal/PlaylistUpdate.vue')['default']
PrivateFm: typeof import('./src/components/Player/PrivateFm.vue')['default']
PlayerCover: typeof import('./src/components/Player/PlayerCover.vue')['default']
PlayerData: typeof import('./src/components/Player/PlayerData.vue')['default']
PlayerMenu: typeof import('./src/components/Player/PlayerMenu.vue')['default']
PlayerSpectrum: typeof import('./src/components/Player/PlayerSpectrum.vue')['default']
PlaylistAdd: typeof import('./src/components/Modal/PlaylistAdd.vue')['default']
PlaySetting: typeof import('./src/components/Setting/PlaySetting.vue')['default']
Provider: typeof import('./src/components/Global/Provider.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchHot: typeof import('./src/components/Search/SearchHot.vue')['default']
SearchDefault: typeof import('./src/components/Search/SearchDefault.vue')['default']
SearchInp: typeof import('./src/components/Search/SearchInp.vue')['default']
SearchSuggestions: typeof import('./src/components/Search/SearchSuggestions.vue')['default']
SearchInpMenu: typeof import('./src/components/Menu/SearchInpMenu.vue')['default']
SearchSuggest: typeof import('./src/components/Search/SearchSuggest.vue')['default']
Sider: typeof import('./src/components/Layout/Sider.vue')['default']
SongDataCard: typeof import('./src/components/Card/SongDataCard.vue')['default']
SongInfoEditor: typeof import('./src/components/Modal/SongInfoEditor.vue')['default']
SongList: typeof import('./src/components/List/SongList.vue')['default']
SongListDropdown: typeof import('./src/components/List/SongListDropdown.vue')['default']
SpecialCover: typeof import('./src/components/Cover/SpecialCover.vue')['default']
SpecialCoverCard: typeof import('./src/components/Cover/SpecialCoverCard.vue')['default']
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
TitleBar: typeof import('./src/components/WinDom/TitleBar.vue')['default']
UpCloudSong: typeof import('./src/components/Modal/UpCloudSong.vue')['default']
UserData: typeof import('./src/components/Nav/UserData.vue')['default']
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']
UpdatePlaylist: typeof import('./src/components/Modal/UpdatePlaylist.vue')['default']
User: typeof import('./src/components/Layout/User.vue')['default']
UserAgreement: typeof import('./src/components/Modal/UserAgreement.vue')['default']
}
}

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: github
owner: "imsyy"
repo: "SPlayer"

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
SPlayer:
build:
context: .
image: splayer
container_name: SPlayer
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
ports:
- 25884:25884
restart: always

View File

@@ -5,9 +5,12 @@ productName: SPlayer
copyright: Copyright © imsyy 2023
# 构建资源所在的目录
directories:
buildResources: build
# 包含在最终应用程序构建中的文件列表,这里使用通配符 ! 表示排除不需要的文件
buildResources: public
# 包含在最终应用程序构建中的文件列表
# 使用通配符 ! 表示排除不需要的文件
files:
- "public/**"
- "out/**"
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
@@ -16,21 +19,20 @@ files:
# 哪些文件将不会被压缩,而是解压到构建目录
asarUnpack:
- public/**
# Windows 平台配置
win:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
icon: public/icons/favicon-512x512.png
# 构建类型
target:
# 安装版
- nsis
# 打包版
- portable
# 管理员权限
requestedExecutionLevel: highestAvailable
# NSIS 安装器配置
nsis:
# 一键式安装程序还是辅助安装程序
# 是否一键式安装
oneClick: false
# 安装程序的生成名称
artifactName: ${productName}-${version}-setup.${ext}
@@ -44,12 +46,16 @@ nsis:
allowElevation: true
# 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true
# 安装包图标
installerIcon: public/icons/favicon.ico
# 卸载命令图标
uninstallerIcon: public/icons/favicon.ico
# macOS 平台配置
mac:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_512.png
icon: public/icons/favicon-512x512.png
# 权限继承的文件路径
entitlementsInherit: build/entitlements.mac.plist
# 扩展信息,如权限描述
@@ -62,20 +68,29 @@ mac:
notarize: false
darkModeSupport: true
category: public.app-category.music
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
# macOS 平台的 DMG 配置
dmg:
# DMG 文件的生成名称
artifactName: ${productName}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
# Linux 平台配置
linux:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
icon: public/icons/favicon-512x512.png
# 构建类型
target:
- pacman
- AppImage
- snap
- deb
- rpm
- tar.gz
@@ -86,7 +101,7 @@ linux:
# AppImage 配置
appImage:
# AppImage 文件的生成名称
artifactName: ${productName}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
# 是否在构建之前重新编译原生模块
npmRebuild: false
# 自动更新的配置

View File

@@ -1,91 +1,93 @@
import { resolve } from "path";
import {
defineConfig,
externalizeDepsPlugin,
loadEnv,
splitVendorChunkPlugin,
} from "electron-vite";
import { MainEnv } from "./env";
import { defineConfig, externalizeDepsPlugin, loadEnv } from "electron-vite";
import { NaiveUiResolver } from "unplugin-vue-components/resolvers";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import viteCompression from "vite-plugin-compression";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ mode }) => {
export default defineConfig(({ command, mode }) => {
// 读取环境变量
const getEnv = (name) => {
const getEnv = (name: keyof MainEnv): string => {
return loadEnv(mode, process.cwd())[name];
};
console.log(command);
// 获取端口
const webPort: number = Number(getEnv("VITE_WEB_PORT") || 14558);
const servePort: number = Number(getEnv("VITE_SERVER_PORT") || 25884);
// 返回配置
return {
// 主进程
main: {
resolve: {
alias: {
"@main": resolve(__dirname, "electron/main"),
},
},
plugins: [externalizeDepsPlugin()],
build: {
publicDir: resolve(__dirname, "public"),
rollupOptions: {
input: {
index: resolve(__dirname, "electron/main/index.js"),
index: resolve(__dirname, "electron/main/index.ts"),
lyric: resolve(__dirname, "web/lyric.html"),
loading: resolve(__dirname, "web/loading.html"),
},
},
},
},
// 预渲染
// 预加载
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.js"),
index: resolve(__dirname, "electron/preload/index.ts"),
},
},
},
},
// 渲染进程
renderer: {
resolve: {
extensions: [".js", ".vue", ".json"],
alias: {
"@": resolve(__dirname, "src"),
},
},
root: ".",
plugins: [
vue(),
AutoImport({
imports: [
"vue",
"vue-router",
"@vueuse/core",
{
"naive-ui": ["useDialog", "useMessage", "useNotification", "useLoadingBar"],
},
],
eslintrc: {
enabled: true,
filepath: "./.eslintrc-auto-import.json",
},
}),
Components({
resolvers: [NaiveUiResolver()],
}),
// viteCompression
viteCompression(),
// splitVendorChunkPlugin
splitVendorChunkPlugin(),
wasm(),
],
// 服务器配置
resolve: {
alias: {
"@": resolve(__dirname, "src/"),
},
},
server: {
port: getEnv("MAIN_VITE_DEV_PORT"),
port: webPort,
// 代理
proxy: {
"/api": {
target: `http://${getEnv("MAIN_VITE_SERVER_HOST")}:${getEnv("MAIN_VITE_SERVER_PORT")}`,
target: `http://127.0.0.1:${servePort}`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
rewrite: (path) => path.replace(/^\/api/, "/api/"),
},
},
},
// 构建
root: ".",
preview: {
port: webPort,
},
build: {
minify: "terser",
publicDir: resolve(__dirname, "public"),
@@ -93,6 +95,11 @@ export default defineConfig(({ mode }) => {
input: {
index: resolve(__dirname, "index.html"),
},
output: {
manualChunks: {
stores: ["src/stores/data.ts", "src/stores/index.ts"],
},
},
},
terserOptions: {
compress: {
@@ -100,9 +107,6 @@ export default defineConfig(({ mode }) => {
},
},
sourcemap: false,
win: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
},
},
};

1
electron/main/index.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="electron-vite/node" />

View File

@@ -1,186 +0,0 @@
import { join } from "path";
import { app, protocol, shell, BrowserWindow, globalShortcut } from "electron";
import { optimizer, is } from "@electron-toolkit/utils";
import { startNcmServer } from "@main/startNcmServer";
import { startMainServer } from "@main/startMainServer";
import { configureAutoUpdater } from "@main/utils/checkUpdates";
import createSystemInfo from "@main/utils/createSystemInfo";
import createGlobalShortcut from "@main/utils/createGlobalShortcut";
import mainIpcMain from "@main/mainIpcMain";
import log from "electron-log";
// 屏蔽报错
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 配置 log
log.transports.file.resolvePathFn = () =>
join(app.getPath("documents"), "/SPlayer/splayer-log.txt");
// 设置日志文件的最大大小为 2 MB
log.transports.file.maxSize = 2 * 1024 * 1024;
// 绑定 console.log
console.log = log.log.bind(log);
// 主进程
class MainProcess {
constructor() {
// 主窗口
this.mainWindow = null;
// 主代理
this.mainServer = null;
// 网易云 API
this.ncmServer = null;
// 初始化
this.init();
}
// 初始化程序
async init() {
log.info("主进程初始化");
// 单例锁
if (!app.requestSingleInstanceLock()) {
app.quit();
log.error("已有一个程序正在运行,本次启动阻止");
}
// 启动网易云 API
this.ncmServer = await startNcmServer({
port: import.meta.env.MAIN_VITE_SERVER_PORT,
host: import.meta.env.MAIN_VITE_SERVER_HOST,
});
// 非开发环境启动代理
if (!is.dev) {
this.mainServer = await startMainServer();
}
// 注册应用协议
app.setAsDefaultProtocolClient("splayer");
// 应用程序准备好之前注册
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } },
]);
// 主应用程序事件
this.mainAppEvents();
}
// 创建主窗口
createWindow() {
// 创建浏览器窗口
this.mainWindow = new BrowserWindow({
width: 1280, // 窗口宽度
height: 720, // 窗口高度
minHeight: 700, // 最小高度
minWidth: 1200, // 最小宽度
center: true, // 是否出现在屏幕居中的位置
show: false, // 初始时不显示窗口
frame: false, // 无边框
titleBarStyle: "customButtonsOnHover", // Macos 隐藏菜单栏
autoHideMenuBar: true, // 失去焦点后自动隐藏菜单栏
// 图标配置
icon: join(__dirname, "../../public/images/logo/favicon.png"),
// 预加载
webPreferences: {
// devTools: is.dev, //是否开启 DevTools
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
hardwareAcceleration: true,
},
});
// 窗口准备就绪时显示窗口
this.mainWindow.on("ready-to-show", () => {
this.mainWindow.show();
// mainWindow.maximize();
});
// 主窗口事件
this.mainWindowEvents();
// 设置窗口打开处理程序
this.mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
// 渲染路径
// 在开发模式
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
this.mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
}
// 生产模式
else {
this.mainWindow.loadURL(`http://127.0.0.1:${import.meta.env.MAIN_VITE_MAIN_PORT ?? 7899}`);
}
// 监听关闭
this.mainWindow.on("close", (event) => {
if (!app.isQuiting) {
event.preventDefault();
this.mainWindow.hide();
}
return false;
});
}
// 主应用程序事件
mainAppEvents() {
app.on("ready", async () => {
// 创建主窗口
this.createWindow();
// 检测更新
configureAutoUpdater(process.platform);
// 创建系统信息
createSystemInfo(this.mainWindow);
// 引入主 Ipc
mainIpcMain(this.mainWindow);
// 注册快捷键
createGlobalShortcut(this.mainWindow);
});
// 在开发模式下默认通过 F12 打开或关闭 DevTools
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// 在 macOS 上,当单击 Dock 图标且没有其他窗口时,通常会重新创建窗口
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) this.createWindow();
});
// 自定义协议
app.on("open-url", (_, url) => {
console.log("Received custom protocol URL:", url);
});
// 将要退出
app.on("will-quit", () => {
// 注销全部快捷键
globalShortcut.unregisterAll();
});
// 当所有窗口都关闭时退出应用macOS 除外
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
}
// 主窗口事件
mainWindowEvents() {
this.mainWindow.on("show", () => {
console.info("窗口展示");
this.mainWindow.webContents.send("lyricsScroll");
});
// this.mainWindow.on("hide", () => {
// console.info("窗口隐藏");
// });
this.mainWindow.on("focus", () => {
console.info("窗口获得焦点");
this.mainWindow.webContents.send("lyricsScroll");
});
// this.mainWindow.on("blur", () => {
// console.info("窗口失去焦点");
// });
}
}
new MainProcess();

290
electron/main/index.ts Normal file
View File

@@ -0,0 +1,290 @@
import { app, shell, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { join } from "path";
import { release } from "os";
import { isDev, isMac, appName } from "./utils";
import { registerAllShortcuts, unregisterShortcuts } from "./shortcut";
import { initTray, MainTray } from "./tray";
import { initThumbar, Thumbar } from "./thumbar";
import initAppServer from "../server";
import initIpcMain from "./ipcMain";
import log from "./logger";
import store from "./store";
// icon
import icon from "../../public/icons/favicon.png?asset";
// 屏蔽报错
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 模拟打包
Object.defineProperty(app, "isPackaged", {
get() {
return true;
},
});
// 主进程
class MainProcess {
// 窗口
mainWindow: BrowserWindow | null = null;
lyricWindow: BrowserWindow | null = null;
loadingWindow: BrowserWindow | null = null;
// 托盘
mainTray: MainTray | null = null;
// 工具栏
thumbar: Thumbar | null = null;
// 是否退出
isQuit: boolean = false;
constructor() {
log.info("🚀 Main process startup");
// 禁用 Windows 7 的 GPU 加速功能
if (release().startsWith("6.1")) app.disableHardwareAcceleration();
// 单例锁
if (!app.requestSingleInstanceLock()) {
log.error("❌ There is already a program running and this process is terminated");
app.quit();
process.exit(0);
} else this.showWindow();
// 准备就绪
app.whenReady().then(async () => {
log.info("🚀 Application Process Startup");
// 设置应用程序名称
electronApp.setAppUserModelId(app.getName());
// 启动主服务进程
await initAppServer();
// 启动进程
this.createLoadingWindow();
this.createMainWindow();
this.createLyricsWindow();
this.handleAppEvents();
this.handleWindowEvents();
// 注册其他服务
this.mainTray = initTray(this.mainWindow!, this.lyricWindow!);
this.thumbar = initThumbar(this.mainWindow!);
// 注册主进程事件
initIpcMain(
this.mainWindow,
this.lyricWindow,
this.loadingWindow,
this.mainTray,
this.thumbar,
store,
);
// 注册快捷键
registerAllShortcuts(this.mainWindow!);
});
}
// 创建窗口
createWindow(options: BrowserWindowConstructorOptions = {}): BrowserWindow {
const defaultOptions: BrowserWindowConstructorOptions = {
title: appName,
width: 1280,
height: 720,
frame: false,
center: true,
// 图标
icon,
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
// 禁用渲染器沙盒
sandbox: false,
// 禁用同源策略
webSecurity: false,
// 允许 HTTP
allowRunningInsecureContent: true,
// 禁用拼写检查
spellcheck: false,
// 启用 Node.js
nodeIntegration: true,
nodeIntegrationInWorker: true,
// 启用上下文隔离
contextIsolation: false,
},
};
// 合并参数
options = Object.assign(defaultOptions, options);
// 创建窗口
const win = new BrowserWindow(options);
return win;
}
// 创建主窗口
createMainWindow() {
// 窗口配置项
const options: BrowserWindowConstructorOptions = {
width: store.get("window").width,
height: store.get("window").height,
minHeight: 800,
minWidth: 1280,
// 菜单栏
titleBarStyle: "customButtonsOnHover",
// 立即显示窗口
show: false,
};
// 初始化窗口
this.mainWindow = this.createWindow(options);
// 渲染路径
if (isDev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
const port = Number(import.meta.env["VITE_SERVER_PORT"] || 25884);
this.mainWindow.loadURL(`http://127.0.0.1:${port}`);
}
// 配置网络代理
if (store.get("proxy")) {
this.mainWindow.webContents.session.setProxy({ proxyRules: store.get("proxy") });
}
// 窗口打开处理程序
this.mainWindow.webContents.setWindowOpenHandler((details) => {
const { url } = details;
if (url.startsWith("https://") || url.startsWith("http://")) {
shell.openExternal(url);
}
return { action: "deny" };
});
}
// 创建加载窗口
createLoadingWindow() {
// 初始化窗口
this.loadingWindow = this.createWindow({
width: 800,
height: 560,
maxWidth: 800,
maxHeight: 560,
resizable: false,
});
// 渲染路径
this.loadingWindow.loadFile(join(__dirname, "../main/web/loading.html"));
}
// 创建桌面歌词窗口
createLyricsWindow() {
// 初始化窗口
this.lyricWindow = this.createWindow({
width: store.get("lyric").width || 800,
height: store.get("lyric").height || 180,
minWidth: 440,
minHeight: 120,
maxWidth: 1600,
maxHeight: 300,
// 窗口位置
x: store.get("lyric").x,
y: store.get("lyric").y,
transparent: true,
backgroundColor: "rgba(0, 0, 0, 0)",
alwaysOnTop: true,
resizable: true,
movable: true,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false,
show: false,
});
// 渲染路径
this.lyricWindow.loadFile(join(__dirname, "../main/web/lyric.html"));
}
// 应用程序事件
handleAppEvents() {
// 窗口被关闭时
app.on("window-all-closed", () => {
if (!isMac) app.quit();
this.mainWindow = null;
this.loadingWindow = null;
});
// 应用被激活
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
} else {
this.createMainWindow();
}
});
// 新增 session
app.on("second-instance", () => {
this.showWindow();
});
// 开发环境控制台
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// 自定义协议
app.on("open-url", (_, url) => {
console.log("Received custom protocol URL:", url);
});
// 将要退出
app.on("will-quit", () => {
// 注销全部快捷键
unregisterShortcuts();
});
// 退出前
app.on("before-quit", () => {
this.isQuit = true;
});
}
// 窗口事件
handleWindowEvents() {
this.mainWindow?.on("show", () => {
// this.mainWindow?.webContents.send("lyricsScroll");
});
this.mainWindow?.on("focus", () => {
this.saveBounds();
});
// 移动或缩放
this.mainWindow?.on("resized", () => {
// 若处于全屏则不保存
if (this.mainWindow?.isFullScreen()) return;
this.saveBounds();
});
this.mainWindow?.on("moved", () => {
this.saveBounds();
});
// 歌词窗口缩放
this.lyricWindow?.on("resized", () => {
const bounds = this.lyricWindow?.getBounds();
if (bounds) {
const { width, height } = bounds;
store.set("lyric", { ...store.get("lyric"), width, height });
}
});
// 窗口关闭
this.mainWindow?.on("close", (event) => {
event.preventDefault();
if (this.isQuit) {
app.exit();
} else {
this.mainWindow?.hide();
}
});
}
// 更新窗口大小
saveBounds() {
if (this.mainWindow?.isFullScreen()) return;
const bounds = this.mainWindow?.getBounds();
if (bounds) store.set("window", bounds);
}
// 显示窗口
showWindow() {
if (this.mainWindow) {
this.mainWindow.show();
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
}
}
}
export default new MainProcess();

701
electron/main/ipcMain.ts Normal file
View File

@@ -0,0 +1,701 @@
import {
app,
ipcMain,
BrowserWindow,
powerSaveBlocker,
screen,
shell,
dialog,
net,
session,
} from "electron";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { parseFile } from "music-metadata";
import { getFonts } from "font-list";
import { MainTray } from "./tray";
import { Thumbar } from "./thumbar";
import { StoreType } from "./store";
import { isDev, getFileID, getFileMD5 } from "./utils";
import { isShortcutRegistered, registerShortcut, unregisterShortcuts } from "./shortcut";
import { join, basename, resolve } from "path";
import { download } from "electron-dl";
import { checkUpdate, startDownloadUpdate } from "./update";
import fs from "fs/promises";
import log from "../main/logger";
import Store from "electron-store";
import fg from "fast-glob";
// 注册 ipcMain
const initIpcMain = (
win: BrowserWindow | null,
lyricWin: BrowserWindow | null,
loadingWin: BrowserWindow | null,
tray: MainTray | null,
thumbar: Thumbar | null,
store: Store<StoreType>,
) => {
initWinIpcMain(win, loadingWin, lyricWin, store);
initLyricIpcMain(lyricWin, win, store);
initTrayIpcMain(tray, win, lyricWin);
initThumbarIpcMain(thumbar);
initStoreIpcMain(store);
initOtherIpcMain(win);
};
// win
const initWinIpcMain = (
win: BrowserWindow | null,
loadingWin: BrowserWindow | null,
lyricWin: BrowserWindow | null,
store: Store<StoreType>,
) => {
let preventId: number | null = null;
// 当前窗口状态
ipcMain.on("win-state", (ev) => {
ev.returnValue = win?.isMaximized();
});
// 加载完成
ipcMain.on("win-loaded", () => {
if (loadingWin && !loadingWin.isDestroyed()) loadingWin.close();
win?.show();
win?.focus();
});
// 最小化
ipcMain.on("win-min", (ev) => {
ev.preventDefault();
win?.minimize();
});
// 最大化
ipcMain.on("win-max", () => {
win?.maximize();
});
// 还原
ipcMain.on("win-restore", () => {
win?.restore();
});
// 关闭
ipcMain.on("win-close", (ev) => {
ev.preventDefault();
win?.close();
app.quit();
});
// 隐藏
ipcMain.on("win-hide", () => {
win?.hide();
});
// 显示
ipcMain.on("win-show", () => {
win?.show();
});
// 重启
ipcMain.on("win-reload", () => {
app.quit();
app.relaunch();
});
// 显示进度
ipcMain.on("set-bar", (_, val: number | "none" | "indeterminate" | "error" | "paused") => {
switch (val) {
case "none":
win?.setProgressBar(-1);
break;
case "indeterminate":
win?.setProgressBar(2, { mode: "indeterminate" });
break;
case "error":
win?.setProgressBar(1, { mode: "error" });
break;
case "paused":
win?.setProgressBar(1, { mode: "paused" });
break;
default:
if (typeof val === "number") {
win?.setProgressBar(val / 100);
} else {
win?.setProgressBar(-1);
}
break;
}
});
// 开启控制台
ipcMain.on("open-dev-tools", () => {
win?.webContents.openDevTools({
title: "SPlayer DevTools",
mode: isDev ? "right" : "detach",
});
});
// 获取系统全部字体
ipcMain.handle("get-all-fonts", async () => {
try {
const fonts = await getFonts();
return fonts;
} catch (error) {
log.error(`❌ Failed to get all system fonts: ${error}`);
return [];
}
});
// 切换桌面歌词
ipcMain.on("change-desktop-lyric", (_, val: boolean) => {
val ? lyricWin?.show() : lyricWin?.hide();
if (val) lyricWin?.setAlwaysOnTop(true, "screen-saver");
});
// 是否阻止系统息屏
ipcMain.on("prevent-sleep", (_, val: boolean) => {
if (val) {
preventId = powerSaveBlocker.start("prevent-display-sleep");
log.info("⏾ System sleep prevention started");
} else {
if (preventId !== null) {
powerSaveBlocker.stop(preventId);
log.info("✅ System sleep prevention stopped");
}
}
});
// 默认文件夹
ipcMain.handle(
"get-default-dir",
(_, type: "documents" | "downloads" | "pictures" | "music" | "videos"): string => {
return app.getPath(type);
},
);
// 遍历音乐文件
ipcMain.handle("get-music-files", async (_, dirPath: string) => {
try {
// 查找指定目录下的所有音乐文件
const musicFiles = await fg("**/*.{mp3,wav,flac}", { cwd: dirPath });
// 解析元信息
const metadataPromises = musicFiles.map(async (file) => {
const filePath = join(dirPath, file);
// 处理元信息
const { common, format } = await parseFile(filePath);
// 获取文件大小
const { size } = await fs.stat(filePath);
// 判断音质等级
let quality: string;
if ((format.sampleRate || 0) >= 96000 || (format.bitsPerSample || 0) > 16) {
quality = "Hi-Res";
} else if ((format.sampleRate || 0) >= 44100) {
quality = "HQ";
} else {
quality = "SQ";
}
return {
id: getFileID(filePath),
name: common.title || basename(filePath),
artists: common.artists?.[0] || common.artist,
album: common.album || "",
alia: common.comment?.[0],
duration: (format?.duration ?? 0) * 1000,
size: (size / (1024 * 1024)).toFixed(2),
path: filePath,
quality,
};
});
const metadataArray = await Promise.all(metadataPromises);
return metadataArray;
} catch (error) {
log.error("❌ Error fetching music metadata:", error);
throw error;
}
});
// 获取音乐元信息
ipcMain.handle("get-music-metadata", async (_, path: string) => {
try {
const { common, format } = await parseFile(path);
return {
// 文件名称
fileName: basename(path),
// 文件大小
fileSize: (await fs.stat(path)).size / (1024 * 1024),
// 元信息
common,
// 音质信息
format,
// md5
md5: await getFileMD5(path),
};
} catch (error) {
log.error("❌ Error fetching music metadata:", error);
throw error;
}
});
// 获取音乐歌词
ipcMain.handle("get-music-lyric", async (_, path: string): Promise<string> => {
try {
const { common, native } = await parseFile(path);
const lyric = common?.lyrics;
if (lyric && lyric.length > 0) return String(lyric[0]);
else {
// 尝试读取 UNSYNCEDLYRICS
const nativeTags = native["ID3v2.3"] || native["ID3v2.4"];
const usltTag = nativeTags?.find((tag) => tag.id === "USLT");
if (usltTag) return String(usltTag.value.text);
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
else {
const lrcFilePath = path.replace(/\.[^.]+$/, ".lrc");
try {
await fs.access(lrcFilePath);
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
return lrcData || "";
} catch {
return "";
}
}
}
} catch (error) {
log.error("❌ Error fetching music lyric:", error);
throw error;
}
});
// 获取音乐封面
ipcMain.handle(
"get-music-cover",
async (_, path: string): Promise<{ data: Buffer; format: string } | null> => {
try {
const { common } = await parseFile(path);
// 获取封面数据
const picture = common.picture?.[0];
if (picture) {
return { data: Buffer.from(picture.data), format: picture.format };
} else {
const coverFilePath = path.replace(/\.[^.]+$/, ".jpg");
try {
await fs.access(coverFilePath);
const coverData = await fs.readFile(coverFilePath);
return { data: coverData, format: "image/jpeg" };
} catch {
return null;
}
}
} catch (error) {
console.error("❌ Error fetching music cover:", error);
throw error;
}
},
);
// 删除文件
ipcMain.handle("delete-file", async (_, path: string) => {
try {
// 规范化路径
const resolvedPath = resolve(path);
// 检查文件是否存在
try {
await fs.access(resolvedPath);
} catch {
throw new Error("❌ File not found");
}
// 删除文件
await fs.unlink(resolvedPath);
return true;
} catch (error) {
log.error("❌ File delete error", error);
return false;
}
});
// 打开文件夹
ipcMain.on("open-folder", async (_, path: string) => {
try {
// 规范化路径
const resolvedPath = resolve(path);
// 检查文件夹是否存在
try {
await fs.access(resolvedPath);
} catch {
throw new Error("❌ Folder not found");
}
// 打开文件夹
shell.showItemInFolder(resolvedPath);
} catch (error) {
log.error("❌ Folder open error", error);
throw error;
}
});
// 图片选择窗口
ipcMain.handle("choose-image", async () => {
try {
const { filePaths } = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png"] }],
});
if (!filePaths || filePaths.length === 0) return null;
return filePaths[0];
} catch (error) {
log.error("❌ Image choose error", error);
return null;
}
});
// 路径选择窗口
ipcMain.handle("choose-path", async () => {
try {
const { filePaths } = await dialog.showOpenDialog({
title: "选择文件夹",
defaultPath: app.getPath("downloads"),
properties: ["openDirectory", "createDirectory"],
buttonLabel: "选择文件夹",
});
if (!filePaths || filePaths.length === 0) return null;
return filePaths[0];
} catch (error) {
log.error("❌ Path choose error", error);
return null;
}
});
// 修改音乐元信息
ipcMain.handle("set-music-metadata", async (_, path: string, metadata: any) => {
try {
const { name, artist, album, alia, lyric, cover } = metadata;
// 规范化路径
const songPath = resolve(path);
const coverPath = cover ? resolve(cover) : null;
// 读取歌曲文件
const songFile = File.createFromPath(songPath);
// 读取封面文件
const songCover = coverPath ? Picture.fromPath(coverPath) : null;
// 保存元数据
Id3v2Settings.forceDefaultVersion = true;
Id3v2Settings.defaultVersion = 3;
songFile.tag.title = name || "未知曲目";
songFile.tag.performers = [artist || "未知艺术家"];
songFile.tag.album = album || "未知专辑";
songFile.tag.albumArtists = [artist || "未知艺术家"];
songFile.tag.lyrics = lyric || "";
songFile.tag.description = alia || "";
songFile.tag.comment = alia || "";
if (songCover) songFile.tag.pictures = [songCover];
// 保存元信息
songFile.save();
songFile.dispose();
return true;
} catch (error) {
log.error("❌ Error setting music metadata:", error);
throw error;
}
});
// 下载文件
ipcMain.handle(
"download-file",
async (
_,
url: string,
options: {
fileName: string;
fileType: string;
path: string;
downloadMeta?: boolean;
downloadCover?: boolean;
downloadLyric?: boolean;
saveMetaFile?: boolean;
lyric?: string;
songData?: any;
} = {
fileName: "未知文件名",
fileType: "mp3",
path: app.getPath("downloads"),
},
): Promise<boolean> => {
try {
if (!win) return false;
// 获取配置
const {
fileName,
fileType,
path,
lyric,
downloadMeta,
downloadCover,
downloadLyric,
saveMetaFile,
songData,
} = options;
// 规范化路径
const downloadPath = resolve(path);
// 检查文件夹是否存在
try {
await fs.access(downloadPath);
} catch {
throw new Error("❌ Folder not found");
}
// 下载文件
const songDownload = await download(win, url, {
directory: downloadPath,
filename: `${fileName}.${fileType}`,
});
if (!downloadMeta || !songData?.cover) return true;
// 下载封面
const coverUrl = songData?.coverSize?.l || songData.cover;
const coverDownload = await download(win, coverUrl, {
directory: downloadPath,
filename: `${fileName}.jpg`,
});
// 读取歌曲文件
const songFile = File.createFromPath(songDownload.getSavePath());
// 生成图片信息
const songCover = Picture.fromPath(coverDownload.getSavePath());
// 保存修改后的元数据
Id3v2Settings.forceDefaultVersion = true;
Id3v2Settings.defaultVersion = 3;
songFile.tag.title = songData?.name || "未知曲目";
songFile.tag.album = songData?.album?.name || "未知专辑";
songFile.tag.performers = songData?.artists?.map((ar: any) => ar.name) || ["未知艺术家"];
songFile.tag.albumArtists = songData?.artists?.map((ar: any) => ar.name) || ["未知艺术家"];
if (lyric && downloadLyric) songFile.tag.lyrics = lyric;
if (songCover && downloadCover) songFile.tag.pictures = [songCover];
// 保存元信息
songFile.save();
songFile.dispose();
// 创建同名歌词文件
if (lyric && saveMetaFile && downloadLyric) {
const lrcPath = join(downloadPath, `${fileName}.lrc`);
await fs.writeFile(lrcPath, lyric, "utf-8");
}
// 是否删除封面
if (!saveMetaFile || !downloadCover) await fs.unlink(coverDownload.getSavePath());
return true;
} catch (error) {
log.error("❌ Error downloading file:", error);
return false;
}
},
);
// 取消代理
ipcMain.on("remove-proxy", () => {
store.set("proxy", "");
win?.webContents.session.setProxy({ proxyRules: "" });
log.info("✅ Remove proxy successfully");
});
// 配置网络代理
ipcMain.on("set-proxy", (_, config) => {
const proxyRules = `${config.protocol}://${config.server}:${config.port}`;
store.set("proxy", proxyRules);
win?.webContents.session.setProxy({ proxyRules });
log.info("✅ Set proxy successfully:", proxyRules);
});
// 代理测试
ipcMain.handle("test-proxy", async (_, config) => {
const proxyRules = `${config.protocol}://${config.server}:${config.port}`;
try {
// 设置代理
const ses = session.defaultSession;
await ses.setProxy({ proxyRules });
// 测试请求
const request = net.request({ url: "https://www.baidu.com" });
return new Promise((resolve) => {
request.on("response", (response) => {
if (response.statusCode === 200) {
log.info("✅ Proxy test successful");
resolve(true);
} else {
log.error(`❌ Proxy test failed with status code: ${response.statusCode}`);
resolve(false);
}
});
request.on("error", (error) => {
log.error("❌ Error testing proxy:", error);
resolve(false);
});
request.end();
});
} catch (error) {
log.error("❌ Error testing proxy:", error);
return false;
}
});
// 重置全部设置
ipcMain.on("reset-setting", () => {
store.reset();
log.info("✅ Reset setting successfully");
});
// 检查更新
ipcMain.on("check-update", (_, showTip) => checkUpdate(win!, showTip));
// 开始下载更新
ipcMain.on("start-download-update", () => startDownloadUpdate());
};
// lyric
const initLyricIpcMain = (
lyricWin: BrowserWindow | null,
mainWin: BrowserWindow | null,
store: Store<StoreType>,
): void => {
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title) return;
lyricWin?.webContents.send("play-song-change", title);
});
// 音乐歌词更改
ipcMain.on("play-lyric-change", (_, lyricData) => {
if (!lyricData) return;
lyricWin?.webContents.send("play-lyric-change", lyricData);
});
// 获取窗口位置
ipcMain.handle("get-window-bounds", () => {
return lyricWin?.getBounds();
});
// 获取屏幕尺寸
ipcMain.handle("get-screen-size", () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
return { width, height };
});
// 移动窗口
ipcMain.on("move-window", (_, x, y, width, height) => {
lyricWin?.setBounds({ x, y, width, height });
// 保存配置
store.set("lyric", { ...store.get("lyric"), x, y, width, height });
// 保持置顶
lyricWin?.setAlwaysOnTop(true, "screen-saver");
});
// 更新高度
ipcMain.on("update-window-height", (_, height) => {
if (!lyricWin) return;
const { width } = lyricWin.getBounds();
// 更新窗口高度
lyricWin.setBounds({ width, height });
});
// 获取配置
ipcMain.handle("get-desktop-lyric-option", () => {
return store.get("lyric");
});
// 保存配置
ipcMain.on("set-desktop-lyric-option", (_, option, callback: boolean = false) => {
store.set("lyric", option);
// 触发窗口更新
if (callback && lyricWin) {
lyricWin.webContents.send("desktop-lyric-option-change", option);
}
mainWin?.webContents.send("desktop-lyric-option-change", option);
});
// 发送主程序事件
ipcMain.on("send-main-event", (_, name, val) => {
mainWin?.webContents.send(name, val);
});
// 关闭桌面歌词
ipcMain.on("closeDesktopLyric", () => {
lyricWin?.hide();
mainWin?.webContents.send("closeDesktopLyric");
});
// 锁定/解锁桌面歌词
ipcMain.on("toogleDesktopLyricLock", (_, isLock: boolean) => {
if (!lyricWin) return;
// 是否穿透
if (isLock) {
lyricWin.setIgnoreMouseEvents(true, { forward: true });
} else {
lyricWin.setIgnoreMouseEvents(false);
}
});
};
// tray
const initTrayIpcMain = (
tray: MainTray | null,
win: BrowserWindow | null,
lyricWin: BrowserWindow | null,
): void => {
// 音乐播放状态更改
ipcMain.on("play-status-change", (_, playStatus: boolean) => {
tray?.setPlayState(playStatus ? "play" : "pause");
lyricWin?.webContents.send("play-status-change", playStatus);
});
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title) return;
// 更改标题
win?.setTitle(title);
tray?.setTitle(title);
tray?.setPlayName(title);
});
// 播放模式切换
ipcMain.on("play-mode-change", (_, mode) => {
tray?.setPlayMode(mode);
});
// 喜欢状态切换
ipcMain.on("like-status-change", (_, likeStatus: boolean) => {
tray?.setLikeState(likeStatus);
});
// 桌面歌词开关
ipcMain.on("change-desktop-lyric", (_, val: boolean) => {
tray?.setDesktopLyricShow(val);
});
// 锁定/解锁桌面歌词
ipcMain.on("toogleDesktopLyricLock", (_, isLock: boolean) => {
tray?.setDesktopLyricLock(isLock);
});
};
// thumbar
const initThumbarIpcMain = (thumbar: Thumbar | null): void => {
if (!thumbar) return;
};
// store
const initStoreIpcMain = (store: Store<StoreType>): void => {
if (!store) return;
};
// other
const initOtherIpcMain = (mainWin: BrowserWindow | null): void => {
// 快捷键是否被注册
ipcMain.handle("is-shortcut-registered", (_, shortcut: string) => isShortcutRegistered(shortcut));
// 注册快捷键
ipcMain.handle("register-all-shortcut", (_, allShortcuts: any): string[] | false => {
if (!mainWin || !allShortcuts) return false;
// 卸载所有快捷键
unregisterShortcuts();
// 注册快捷键
const failedShortcuts: string[] = [];
for (const key in allShortcuts) {
const shortcut = allShortcuts[key].globalShortcut;
if (!shortcut) continue;
// 快捷键回调
const callback = () => mainWin.webContents.send(key);
const isSuccess = registerShortcut(shortcut, callback);
if (!isSuccess) failedShortcuts.push(shortcut);
}
return failedShortcuts;
});
// 卸载所有快捷键
ipcMain.on("unregister-all-shortcut", () => unregisterShortcuts());
};
export default initIpcMain;

31
electron/main/logger.ts Normal file
View File

@@ -0,0 +1,31 @@
// 日志输出
import { join } from "path";
import { app } from "electron";
import { isDev } from "./utils";
import log from "electron-log";
// 绑定事件
Object.assign(console, log.functions);
// 日志配置
log.transports.file.level = "info";
log.transports.file.maxSize = 2 * 1024 * 1024;
if (log.transports.ipc) log.transports.ipc.level = false;
// 控制台输出
log.transports.console.useStyles = true;
// 文件输出
log.transports.file.format = "{h}:{i}:{s}:{ms} {text}";
// 本地输出
if (!isDev) {
log.transports.file.resolvePathFn = () =>
join(app.getPath("documents"), "/SPlayer/SPlayer-log.txt");
} else {
log.transports.file.level = false;
}
log.info("📃 logger initialized");
export default log;

View File

@@ -1,278 +0,0 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { write } from "node-id3";
import { download } from "electron-dl";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import axios from "axios";
import fs from "fs/promises";
/**
* 监听主进程的 IPC 事件
* @param {BrowserWindow} win - 要监听 IPC 事件的程序窗口
*/
const mainIpcMain = (win) => {
// 窗口操作部分
ipcMain.on("window-min", (ev) => {
// 阻止最小化
ev.preventDefault();
// 最小化
win.minimize();
});
ipcMain.on("window-maxOrRestore", (ev) => {
const winSizeState = win.isMaximized();
winSizeState ? win.restore() : win.maximize();
ev.reply("window-state", win.isMaximized());
});
ipcMain.on("window-restore", () => {
win.restore();
});
ipcMain.on("window-hide", () => {
win.hide();
});
ipcMain.on("window-close", () => {
win.close();
app.isQuiting = true;
app.quit();
});
ipcMain.on("window-relaunch", () => {
app.isQuiting = true;
app.relaunch();
app.quit();
});
// 显示进度
ipcMain.on("setProgressBar", (_, val) => {
if (val === "close") {
win.setProgressBar(-1);
return false;
}
win.setProgressBar(val / 100);
});
// 解灰
ipcMain.handle("getMusicNumUrl", async (_, data) => {
// 解析传入数据
const songData = JSON.parse(data);
const songName = `${songData?.name}-${songData?.artists?.[0].name}`;
console.log("开始解灰:", songName);
const url = await getNeteaseMusicUrl(songName);
console.log("解灰地址:", url);
return url;
});
// bili 链接解析
ipcMain.handle("getBiliUrlData", async (_, url) => {
const data = await getBiliUrlBase64(url);
return data;
});
// 默认音乐文件夹
ipcMain.handle("getdefaultMusicPath", async () => {
const path = app.getPath("music");
return path;
});
// 选择文件夹
ipcMain.handle("selectDir", async (_, isChooseDl = false) => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
title: isChooseDl ? "选择下载目录" : "选择添加目录",
defaultPath: isChooseDl ? app.getPath("downloads") : app.getPath("music"),
properties: ["openDirectory", "createDirectory"],
buttonLabel: "选择文件夹",
});
if (!canceled) {
const selectedDirectory = filePaths[0];
return selectedDirectory;
}
} catch (err) {
console.error("选择文件夹时发生错误:", err);
throw err;
}
});
// 读取文件夹内容
ipcMain.handle("getDirContents", async (_, selectedDir) => {
try {
// 使用 readDirAsync 函数递归地读取文件夹内容
const directoryContents = await readDirAsync(selectedDir);
return directoryContents;
} catch (err) {
console.error("读取文件夹内容时发生错误:", err);
throw err;
}
});
// 读取音乐歌词
ipcMain.handle("getMusicLyric", async (_, path) => {
try {
const data = await parseFile(path);
const lyric = data.common.lyrics;
if (lyric && lyric.length > 0) {
return lyric[0];
}
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
else {
const lrcFilePath = path.replace(/\.[^.]+$/, ".lrc");
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
// 返回读取的 lrc 数据,如果没有则返回 null
return lrcData || null;
}
} catch (error) {
console.error("读取音乐歌词出错:", error);
return null;
}
});
// 读取音乐封面
ipcMain.handle("getMusicCover", async (_, path) => {
try {
const data = await parseFile(path);
const picture = data.common.picture;
if (picture && picture.length > 0) {
const coverData = picture[0].data;
const coverFormat = picture[0].format;
return { coverData, coverFormat };
}
// 如果封面数据不存在,尝试读取同名的封面图片文件
else {
const coverFilePath = path.replace(/\.[^.]+$/, ".jpg");
const coverData = await fs.readFile(coverFilePath);
// 返回读取的封面图片数据,如果没有则返回 null
return coverData ? { coverData, coverFormat: "jpg" } : null;
}
} catch (error) {
console.error("读取音乐封面出错:", error);
return null;
}
});
// 执行复制操作
ipcMain.handle("copyData", async (_, data) => {
try {
clipboard.writeText(data);
return true;
} catch (error) {
console.error("复制操作出错:", error);
return false;
}
});
// 本地磁盘文件删除
ipcMain.handle("deleteFile", async (_, path) => {
try {
// 检查文件是否存在
if (fs.access(path)) {
// 尝试删除文件
fs.unlink(path);
console.log(`文件已删除:${path}`);
return true;
} else {
console.log(`文件不存在:${path}`);
return false;
}
} catch (err) {
console.error(`文件删除操作出错:${path}`, err);
return false;
}
});
// 打开歌曲目录
ipcMain.on("openSongLocal", (_, path) => {
try {
if (fs.access(path)) {
shell.showItemInFolder(path);
} else {
console.log(`文件不存在:${path}`);
}
} catch (error) {
console.error("打开歌曲目录时出错:", error);
}
});
// 下载文件至指定目录
ipcMain.handle("downloadFile", async (_, data, song, songName, songType, path) => {
try {
if (fs.access(path)) {
const songData = JSON.parse(song);
console.info("开始下载:", songData, data);
// 下载歌曲
const songDownload = await download(win, data.url, {
directory: path,
filename: `${songName}.${songType}`,
});
// 下载封面
const coverDownload = await download(win, songData.cover, {
directory: path,
filename: `${songName}.jpg`,
});
// 生成歌曲文件的元数据
const songTag = {
title: songData.name,
artist: Array.isArray(songData.artists)
? songData.artists.map((ar) => ar.name).join(" / ")
: songData.artists || "未知歌手",
album: songData.album?.name || songData.album,
image: coverDownload.getSavePath(),
};
// 保存修改后的元数据
write(songTag, songDownload.getSavePath());
// 删除封面
await fs.unlink(coverDownload.getSavePath());
return true;
} else {
console.log(`目录不存在:${path}`);
return false;
}
} catch (error) {
console.error("下载文件时出错:", error);
return false;
}
});
};
/**
* 从 Bilibili 视频中获取文件的 Base64 数据
*
* @param {string} url - 要获取的文件的 URL
* @returns {Promise<string>} - 文件的 Base64 数据
*/
const getBiliUrlBase64 = async (url) => {
try {
const response = await axios.get(url, {
headers: {
Referer: "https://www.bilibili.com/",
"User-Agent": "okhttp/3.4.1",
},
responseType: "arraybuffer",
withCredentials: false,
});
// 将二进制数据转换为缓冲区
const buffer = toBuffer(response.data);
// 将缓冲区中的数据转换为 Base64 编码的字符串
const encodedData = buffer.toString("base64");
// 返回 Base64 编码的文件数据
return encodedData;
} catch (error) {
console.error("获取文件数据时发生错误:" + error);
return null;
}
};
/**
* 将数据转换为缓冲区( Buffer
*
* @param {ArrayBuffer|Buffer|Uint8Array} data - 要转换的数据
* @returns {Buffer} - 转换后的缓冲区
*/
const toBuffer = (data) => {
if (data instanceof Buffer) {
return data;
} else {
return Buffer.from(data);
}
};
export default mainIpcMain;

43
electron/main/shortcut.ts Normal file
View File

@@ -0,0 +1,43 @@
import { BrowserWindow, globalShortcut } from "electron";
import { isDev } from "./utils";
import log from "../main/logger";
// 注册快捷键并检查
export const registerShortcut = (shortcut: string, callback: () => void): boolean => {
try {
const success = globalShortcut.register(shortcut, callback);
if (!success) {
log.error(`❌ Failed to register shortcut: ${shortcut}`);
return false;
} else {
log.info(`✅ Shortcut registered: ${shortcut}`);
return true;
}
} catch (error) {
log.error(` Error registering shortcut ${shortcut}:`, error);
return false;
}
};
// 检查快捷键是否被注册
export const isShortcutRegistered = (shortcut: string): boolean => {
return globalShortcut.isRegistered(shortcut);
};
// 卸载所有快捷键
export const unregisterShortcuts = () => {
globalShortcut.unregisterAll();
log.info("🚫 All shortcuts unregistered.");
};
// 注册所有快捷键
export const registerAllShortcuts = (win: BrowserWindow) => {
// 开启控制台
registerShortcut("CmdOrCtrl+Shift+I", () => {
win.webContents.openDevTools({
title: "SPlayer DevTools",
// 客户端分离
mode: isDev ? "right" : "detach",
});
});
};

View File

@@ -1,19 +0,0 @@
import { join } from "path";
import express from "express";
import expressProxy from "express-http-proxy";
/**
* 启动主服务器
* @returns {import('http').Server} HTTP 服务器实例
*/
export const startMainServer = async () => {
const { MAIN_VITE_MAIN_PORT, MAIN_VITE_SERVER_HOST, MAIN_VITE_SERVER_PORT } = import.meta.env;
const port = MAIN_VITE_MAIN_PORT ?? 7899;
const apiHost = `http://${MAIN_VITE_SERVER_HOST}:${MAIN_VITE_SERVER_PORT}`;
const expressApp = express();
// 代理
expressApp.use("/", express.static(join(__dirname, "../renderer/")));
expressApp.use("/api", expressProxy(apiHost));
// 启动 Express 应用服务器,并监听指定端口
return expressApp.listen(port, "127.0.0.1");
};

View File

@@ -1,20 +0,0 @@
const netEaseApi = require("NeteaseCloudMusicApi");
/**
* 启动网易云音乐 API 服务器
*
* @async
* @param {Object} options - 服务器配置
* @param {number} [options.port=12141] - 服务器端口
* @param {string} [options.host="127.0.0.1"] - 服务器主机地址
* @returns {Promise<void>} 返回一个 Promise在 API 服务器成功启动后 resolve
*/
export const startNcmServer = async (
options = {
port: 11451,
host: "127.0.0.1",
},
) => {
console.log(options);
return await netEaseApi.serveNcmApi(options);
};

46
electron/main/store.ts Normal file
View File

@@ -0,0 +1,46 @@
import Store from "electron-store";
import log from "./logger";
log.info("🌱 Store init");
export interface StoreType {
window: {
width: number;
height: number;
x?: number;
y?: number;
};
lyric: {
fontSize: number;
mainColor: string;
shadowColor: string;
// 窗口位置
x?: number;
y?: number;
width?: number;
height?: number;
};
proxy: string;
}
// 初始化仓库
const store = new Store<StoreType>({
defaults: {
window: {
width: 1280,
height: 800,
},
lyric: {
fontSize: 30,
mainColor: "#fff",
shadowColor: "rgba(0, 0, 0, 0.5)",
x: 0,
y: 0,
width: 800,
height: 180,
},
proxy: "",
},
});
export default store;

98
electron/main/thumbar.ts Normal file
View File

@@ -0,0 +1,98 @@
import { BrowserWindow, nativeImage, nativeTheme, ThumbarButton } from "electron";
import { join } from "path";
import { isWin } from "./utils";
import log from "./logger";
enum ThumbarKeys {
Play = "play",
Pause = "pause",
Prev = "prev",
Next = "next",
}
type ThumbarMap = Map<ThumbarKeys, ThumbarButton>;
export interface Thumbar {
clearThumbar(): void;
}
// 工具栏图标
const thumbarIcon = (filename: string) => {
// 是否为暗色
const isDark = nativeTheme.shouldUseDarkColors;
// 返回图标
return nativeImage.createFromPath(
join(__dirname, `../../public/icons/thumbar/${filename}-${isDark ? "dark" : "light"}.png`),
);
};
// 缩略图工具栏
const createThumbarButtons = (win: BrowserWindow): ThumbarMap => {
return new Map<ThumbarKeys, ThumbarButton>()
.set(ThumbarKeys.Prev, {
tooltip: "上一曲",
icon: thumbarIcon("prev"),
click: () => win.webContents.send("play-prev"),
})
.set(ThumbarKeys.Next, {
tooltip: "下一曲",
icon: thumbarIcon("next"),
click: () => win.webContents.send("play-next"),
})
.set(ThumbarKeys.Play, {
tooltip: "播放",
icon: thumbarIcon("play"),
click: () => win.webContents.send("play"),
})
.set(ThumbarKeys.Pause, {
tooltip: "暂停",
icon: thumbarIcon("pause"),
click: () => win.webContents.send("play-pause"),
});
};
// 创建缩略图工具栏
class createThumbar implements Thumbar {
// 窗口
private _win: BrowserWindow;
// 工具栏
private _thumbar: ThumbarMap;
// 工具栏按钮
private _prev: ThumbarButton;
private _next: ThumbarButton;
private _play: ThumbarButton;
private _pause: ThumbarButton;
constructor(win: BrowserWindow) {
// 初始化数据
this._win = win;
this._thumbar = createThumbarButtons(win);
// 工具栏按钮
this._play = this._thumbar.get(ThumbarKeys.Play)!;
this._pause = this._thumbar.get(ThumbarKeys.Pause)!;
this._prev = this._thumbar.get(ThumbarKeys.Prev)!;
this._next = this._thumbar.get(ThumbarKeys.Next)!;
// 初始化工具栏
this.updateThumbar();
}
// 更新工具栏
private updateThumbar(playing: boolean = false, clean: boolean = false) {
if (clean) return this.clearThumbar();
this._win.setThumbarButtons([this._prev, playing ? this._pause : this._play, this._next]);
}
// 清除工具栏
clearThumbar() {
this._win.setThumbarButtons([]);
}
}
export const initThumbar = (win: BrowserWindow) => {
try {
// 若非 Win
if (!isWin) return null;
log.info("🚀 ThumbarButtons Startup");
return new createThumbar(win);
} catch (error) {
log.error("❌ ThumbarButtons Error", error);
throw error;
}
};

282
electron/main/tray.ts Normal file
View File

@@ -0,0 +1,282 @@
import {
app,
Tray,
Menu,
MenuItemConstructorOptions,
BrowserWindow,
nativeImage,
nativeTheme,
} from "electron";
import { isWin, isLinux, isDev, appName } from "./utils";
import { join } from "path";
import log from "./logger";
// 播放模式
type PlayMode = "repeat" | "repeat-once" | "shuffle";
type PlayState = "play" | "pause" | "loading";
// 全局数据
let playMode: PlayMode = "repeat";
let playState: PlayState = "pause";
let playName: string = "未播放歌曲";
let likeSong: boolean = false;
let desktopLyricShow: boolean = false;
let desktopLyricLock: boolean = false;
export interface MainTray {
setTitle(title: string): void;
setPlayMode(mode: PlayMode): void;
setLikeState(like: boolean): void;
setPlayState(state: PlayState): void;
setPlayName(name: string): void;
setDesktopLyricShow(show: boolean): void;
setDesktopLyricLock(lock: boolean): void;
destroyTray(): void;
}
// 托盘图标
const trayIcon = (filename: string) => {
// const rootPath = isDev
// ? join(__dirname, "../../public/icons/tray")
// : join(app.getAppPath(), "../../public/icons/tray");
// return nativeImage.createFromPath(join(rootPath, filename));
return nativeImage.createFromPath(join(__dirname, `../../public/icons/tray/${filename}`));
};
// 托盘菜单
const createTrayMenu = (
win: BrowserWindow,
lyricWin: BrowserWindow,
): MenuItemConstructorOptions[] => {
// 区分明暗图标
const showIcon = (iconName: string) => {
const isDark = nativeTheme.shouldUseDarkColors;
return trayIcon(`${iconName}${isDark ? "-dark" : "-light"}.png`).resize({
width: 16,
height: 16,
});
};
// 菜单
const menu: MenuItemConstructorOptions[] = [
{
id: "name",
label: playName,
icon: showIcon("music"),
click: () => {
win.show();
win.focus();
},
},
{
type: "separator",
},
{
id: "toogleLikeSong",
label: likeSong ? "从我喜欢中移除" : "添加到我喜欢",
icon: showIcon(likeSong ? "like" : "unlike"),
click: () => win.webContents.send("toogleLikeSong"),
},
{
id: "changeMode",
label:
playMode === "repeat" ? "列表循环" : playMode === "repeat-once" ? "单曲循环" : "随机播放",
icon: showIcon(playMode),
submenu: [
{
id: "repeat",
label: "列表循环",
icon: showIcon("repeat"),
checked: playMode === "repeat",
type: "radio",
click: () => win.webContents.send("changeMode", "repeat"),
},
{
id: "repeat-once",
label: "单曲循环",
icon: showIcon("repeat-once"),
checked: playMode === "repeat-once",
type: "radio",
click: () => win.webContents.send("changeMode", "repeat-once"),
},
{
id: "shuffle",
label: "随机播放",
icon: showIcon("shuffle"),
checked: playMode === "shuffle",
type: "radio",
click: () => win.webContents.send("changeMode", "shuffle"),
},
],
},
{
type: "separator",
},
{
id: "playNext",
label: "上一曲",
icon: showIcon("prev"),
click: () => win.webContents.send("playPrev"),
},
{
id: "playOrPause",
label: playState === "pause" ? "播放" : "暂停",
icon: showIcon(playState === "pause" ? "play" : "pause"),
click: () => win.webContents.send(playState === "pause" ? "play" : "pause"),
},
{
id: "playNext",
label: "下一曲",
icon: showIcon("next"),
click: () => win.webContents.send("playNext"),
},
{
type: "separator",
},
{
id: "toogleDesktopLyric",
label: `${desktopLyricShow ? "关闭" : "开启"}桌面歌词`,
icon: showIcon("lyric"),
click: () => win.webContents.send("toogleDesktopLyric"),
},
{
id: "toogleDesktopLyricLock",
label: `${desktopLyricLock ? "解锁" : "锁定"}桌面歌词`,
icon: showIcon(desktopLyricLock ? "lock" : "unlock"),
visible: desktopLyricShow,
click: () => lyricWin.webContents.send("toogleDesktopLyricLock", !desktopLyricLock),
},
{
type: "separator",
},
{
id: "setting",
label: "全局设置",
icon: showIcon("setting"),
click: () => {
win.show();
win.focus();
win.webContents.send("openSetting");
},
},
{
type: "separator",
},
{
id: "exit",
label: "退出",
icon: showIcon("power"),
click: () => {
win.close();
// app.exit(0);
app.quit();
},
},
];
return menu;
};
// 创建托盘
class CreateTray implements MainTray {
// 窗口
private _win: BrowserWindow;
private _lyricWin: BrowserWindow;
// 托盘
private _tray: Tray;
// 菜单
private _menu: MenuItemConstructorOptions[];
private _contextMenu: Menu;
constructor(win: BrowserWindow, lyricWin: BrowserWindow) {
// 托盘图标
const icon = trayIcon(isWin ? "tray.ico" : "tray@32.png").resize({
height: 32,
width: 32,
});
// 初始化数据
this._win = win;
this._lyricWin = lyricWin;
this._tray = new Tray(icon);
this._menu = createTrayMenu(this._win, this._lyricWin);
this._contextMenu = Menu.buildFromTemplate(this._menu);
// 初始化事件
this.initTrayMenu();
this.initEvents();
this.setTitle(appName);
}
// 托盘菜单
private initTrayMenu() {
this._menu = createTrayMenu(this._win, this._lyricWin);
this._contextMenu = Menu.buildFromTemplate(this._menu);
this._tray.setContextMenu(this._contextMenu);
}
// 托盘事件
private initEvents() {
// 点击
this._tray.on("click", () => this._win.show());
// 明暗变化
nativeTheme.on("updated", () => {
this.initTrayMenu();
});
}
// 设置标题
setTitle(title: string) {
this._tray.setTitle(title);
this._tray.setToolTip(title);
}
// 设置播放名称
setPlayName(name: string) {
// 超长处理
if (name.length > 20) name = name.slice(0, 20) + "...";
playName = name;
// 更新菜单
this.initTrayMenu();
}
// 设置播放状态
setPlayState(state: PlayState) {
playState = state;
// 更新菜单
this.initTrayMenu();
}
// 设置播放模式
setPlayMode(mode: PlayMode) {
playMode = mode;
// 更新菜单
this.initTrayMenu();
}
// 设置喜欢状态
setLikeState(like: boolean) {
likeSong = like;
// 更新菜单
this.initTrayMenu();
}
// 桌面歌词开关
setDesktopLyricShow(show: boolean) {
desktopLyricShow = show;
// 更新菜单
this.initTrayMenu();
}
// 锁定桌面歌词
setDesktopLyricLock(lock: boolean) {
desktopLyricLock = lock;
// 更新菜单
this.initTrayMenu();
}
// 销毁托盘
destroyTray() {
this._tray.destroy();
}
}
export const initTray = (win: BrowserWindow, lyricWin: BrowserWindow) => {
try {
// 若为 MacOS
if (isWin || isLinux || isDev) {
log.info("🚀 Tray Process Startup");
return new CreateTray(win, lyricWin);
}
return null;
} catch (error) {
log.error("❌ Tray Process Error", error);
return null;
}
};

76
electron/main/update.ts Normal file
View File

@@ -0,0 +1,76 @@
import { type BrowserWindow } from "electron";
import electronUpdater from "electron-updater";
import log from "./logger";
// import
const { autoUpdater } = electronUpdater;
// 更新源
autoUpdater.setFeedURL({
provider: "github",
owner: "imsyy",
repo: "SPlayer",
});
// 禁用自动下载
autoUpdater.autoDownload = false;
// 是否初始化
let isInit: boolean = false;
// 是否提示
let isShowTip: boolean = false;
// 事件监听
const initUpdaterListeners = (win: BrowserWindow) => {
if (isInit) return;
// 当有新版本可用时
autoUpdater.on("update-available", (info) => {
win.webContents.send("update-available", info);
log.info(`🚀 New version available: ${info.version}`);
});
// 更新下载进度
autoUpdater.on("download-progress", (progress) => {
win.webContents.send("download-progress", progress);
log.info(`🚀 Downloading: ${progress.percent}%`);
});
// 当下载完成时
autoUpdater.on("update-downloaded", (info) => {
win.webContents.send("update-downloaded", info);
log.info(`🚀 Update downloaded: ${info.version}`);
// 安装更新
autoUpdater.quitAndInstall();
});
// 当没有新版本时
autoUpdater.on("update-not-available", (info) => {
if (isShowTip) win.webContents.send("update-not-available", info);
log.info(`✅ No new version available: ${info.version}`);
});
// 更新错误
autoUpdater.on("error", (err) => {
win.webContents.send("update-error", err);
log.error(`❌ Update error: ${err.message}`);
});
isInit = true;
};
// 检查更新
export const checkUpdate = (win: BrowserWindow, showTip: boolean = false) => {
// 初始化事件监听器
initUpdaterListeners(win);
// 更改提示
isShowTip = showTip;
// 检查更新
autoUpdater.checkForUpdates();
};
// 开始下载
export const startDownloadUpdate = () => {
autoUpdater.downloadUpdate();
};

32
electron/main/utils.ts Normal file
View File

@@ -0,0 +1,32 @@
import { app } from "electron";
import { is } from "@electron-toolkit/utils";
import fs from "fs/promises";
import crypto from "crypto";
// 系统判断
export const isDev = is.dev;
export const isWin = process.platform === "win32";
export const isMac = process.platform === "darwin";
export const isLinux = process.platform === "linux";
// 程序名称
export const appName = app.getName() || "SPlayer";
// 生成唯一ID
export const getFileID = (filePath: string): number => {
// SHA-256
const hash = crypto.createHash("sha256");
hash.update(filePath);
const digest = hash.digest("hex");
// 将哈希值的前 16 位转换为十进制数字
const uniqueId = parseInt(digest.substring(0, 16), 16);
return Number(uniqueId.toString().padStart(16, "0"));
};
// 生成文件 MD5
export const getFileMD5 = async (path: string): Promise<string> => {
const data = await fs.readFile(path);
const hash = crypto.createHash("md5");
hash.update(data);
return hash.digest("hex");
};

View File

@@ -1,30 +0,0 @@
import { dialog, shell } from "electron";
import { is } from "@electron-toolkit/utils";
import { autoUpdater } from "electron-updater";
// 更新弹窗
const hasNewVersion = (info) => {
dialog
.showMessageBox({
title: "发现新版本 v" + info.version,
message: "发现新版本 v" + info.version,
detail: "是否前往 GitHub 下载新版本安装包?",
buttons: ["前往", "取消"],
type: "question",
noLink: true,
})
.then((result) => {
if (result.response === 0) {
shell.openExternal("https://github.com/imsyy/SPlayer/releases");
}
});
};
export const configureAutoUpdater = () => {
if (is.dev) return false;
autoUpdater.checkForUpdatesAndNotify();
// 若有更新
autoUpdater.on("update-available", (info) => {
hasNewVersion(info);
});
};

View File

@@ -1,14 +0,0 @@
import { globalShortcut } from "electron";
/**
* 注册全局快捷键
* @param {BrowserWindow} win - 程序窗口
*/
const createGlobalShortcut = (win) => {
// 刷新程序
globalShortcut.register("CommandOrControl+R", () => {
win?.reload();
});
};
export default createGlobalShortcut;

View File

@@ -1,115 +0,0 @@
import { join } from "path";
import { Tray, Menu, app, ipcMain, nativeImage, nativeTheme } from "electron";
// 当前播放歌曲数据
let playSongName = "当前暂无播放歌曲";
let playSongState = false;
/**
* 创建系统自定义信息
* @param {BrowserWindow} win - 程序窗口
*/
const createSystemInfo = (win) => {
// 弹出列表
app.setUserTasks([]);
// 系统托盘
const mainTray = new Tray(join(__dirname, "../../public/images/logo/favicon.png"));
// 给托盘图标设置气球提示
mainTray.setToolTip(app.getName());
// 歌曲数据改变时
ipcMain.on("songNameChange", (_, val) => {
playSongName = val;
// 托盘图标标题
mainTray.setToolTip(val);
// 更改应用标题
win.setTitle(val);
});
ipcMain.on("songStateChange", (_, val) => {
playSongState = val;
});
// 左键事件
mainTray.on("click", () => {
// 显示窗口
win.show();
});
// 右键事件
mainTray.on("right-click", () => {
mainTray.popUpContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
});
};
// 生成右键菜单
const createTrayMenu = (win) => {
// 系统是否为暗色
const isDarkMode = nativeTheme.shouldUseDarkColors;
// 生成图标
const createIcon = (name) => {
return nativeImage
.createFromPath(
isDarkMode
? join(__dirname, `../../public/images/icon/${name}-dark.png`)
: join(__dirname, `../../public/images/icon/${name}-light.png`),
)
.resize({ width: 16, height: 16 });
};
// 返回菜单
return [
{
label: playSongName,
icon: createIcon("open"),
click() {
win.show();
win.focus();
win.webContents.send("showPlayer");
},
},
{
type: "separator",
},
{
label: "上一曲",
icon: createIcon("prev"),
click: () => {
win.webContents.send("playNextOrPrev", "prev");
},
},
{
label: playSongState ? "暂停" : "播放",
icon: createIcon(playSongState ? "pause" : "play"),
click: () => {
win.webContents.send("playOrPause");
},
},
{
label: "下一曲",
icon: createIcon("next"),
click: () => {
win.webContents.send("playNextOrPrev", "next");
},
},
{
type: "separator",
},
{
label: "全局设置",
icon: createIcon("setting"),
click: () => {
win.webContents.send("setting");
},
},
{
type: "separator",
},
{
label: "退出",
icon: createIcon("power"),
click: () => {
win.close();
app.isQuiting = true;
app.quit();
},
},
];
};
export default createSystemInfo;

View File

@@ -1,167 +0,0 @@
import { encryptQuery } from "@main/utils/kwDES";
import axios from "axios";
/**
* 网易云音乐解灰
*/
// 咪咕音乐请求头
const requestHeader = {
Origin: "http://music.migu.cn/",
Referer: "http://m.music.migu.cn/v3/",
aversionid: import.meta.env.MAIN_VITE_MIGU_COOKIE || null,
channel: "0146921",
};
/**
* 获取咪咕音乐歌曲 ID
* @param {string} keyword - 搜索关键字
* @returns {Promise<any>}
*/
const getMiguSongId = async (keyword) => {
try {
const url =
"https://m.music.migu.cn/migu/remoting/scr_search_tag?keyword=" +
keyword.toString() +
"&type=2&rows=20&pgc=1";
const result = await axios.get(url, {
headers: requestHeader,
});
if (result.data?.musics?.length) {
// 是否与原曲吻合
const originalName = keyword.split("-");
const songName = result.data.musics[0]?.songName;
if (songName && !songName?.includes(originalName[0])) {
return null;
}
return result.data.musics[0].id;
}
return null;
} catch (error) {
console.error("获取咪咕音乐歌曲 ID 失败:", error);
return null;
}
};
/**
* 获取咪咕音乐歌曲 URL
* @param {string} keyword - 搜索关键字
* @returns {Promise<any>}
*/
const getMiguSongUrl = async (keyword) => {
try {
const songId = await getMiguSongId(keyword);
if (!songId) return null;
console.info("咪咕解灰歌曲 ID", songId);
const soundQuality = "PQ";
const url =
"https://app.c.nf.migu.cn/MIGUM2.0/strategy/listen-url/v2.4?netType=01&resourceType=2&songId=" +
songId.toString() +
"&toneFlag=" +
soundQuality;
const result = await axios.get(url, {
headers: requestHeader,
});
if (result.data?.data?.url) {
const songUrl = result.data.data.url;
console.info("咪咕解灰歌曲 URL", songUrl);
return songUrl;
}
return null;
} catch (error) {
console.error("获取咪咕音乐歌曲 URL 失败:", error);
return null;
}
};
/**
* 获取酷我音乐歌曲 ID
* @param {string} keyword - 搜索关键字
* @returns {Promise<any>}
*/
const getKuwoSongId = async (keyword) => {
try {
const url =
"http://search.kuwo.cn/r.s?&correct=1&stype=comprehensive&encoding=utf8" +
"&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all=" +
keyword.toString();
const result = await axios.get(url);
if (
!result.data ||
result.data.content.length < 2 ||
!result.data.content[1].musicpage ||
result.data.content[1].musicpage.abslist.length < 1
) {
return null;
}
// 是否与原曲吻合
const originalName = keyword.split("-");
const songName = result.data.content[1].musicpage.abslist[0]?.SONGNAME;
if (songName && !songName?.includes(originalName[0])) {
return null;
}
const songId = result.data.content[1].musicpage.abslist[0].MUSICRID;
return songId.slice("MUSIC_".length);
} catch (error) {
console.error("获取酷我音乐歌曲 ID 失败:", error);
return null;
}
};
/**
* 获取酷我音乐歌曲 URL
* @param {string} keyword - 搜索关键字
* @returns {Promise<any>}
*/
const getKuwoSongUrl = async (keyword) => {
try {
const songId = await getKuwoSongId(keyword);
if (!songId) return null;
console.info("酷我解灰歌曲 ID", songId);
const url = encryptQuery
? "http://mobi.kuwo.cn/mobi.s?f=kuwo&q=" +
encryptQuery(
"corp=kuwo&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&p2p=1&type=convert_url2&sig=0&format=mp3" +
"&rid=" +
songId,
)
: "http://antiserver.kuwo.cn/anti.s?type=convert_url&format=mp3&response=url&rid=MUSIC_" +
songId;
const result = await axios.get(url, { "user-agent": "okhttp/3.10.0" });
if (result.data) {
const urlMatch = result.data.match(/http[^\s$"]+/)[0];
console.info("酷我解灰歌曲 URL", urlMatch);
return urlMatch;
}
return null;
} catch (error) {
console.error("获取酷我音乐歌曲 URL 失败:", error);
return null;
}
};
/**
* 获取给定关键字的音乐 URL
* @param {string} keyword - 关键字
* @returns {Promise<?string>} 音乐 URL
*/
const getNeteaseMusicUrl = async (keyword) => {
try {
const [kuwoSongUrl, miguSongUrl] = await Promise.all([
getKuwoSongUrl(keyword),
getMiguSongUrl(keyword),
]);
if (kuwoSongUrl) {
return kuwoSongUrl;
}
if (miguSongUrl) {
return miguSongUrl;
}
return null;
} catch (error) {
console.error("获取解灰 URL 全部失败:", error);
return null;
}
};
export default getNeteaseMusicUrl;

View File

@@ -1,151 +0,0 @@
import { parseFile } from "music-metadata";
import fs from "fs/promises";
import path from "path";
/**
* 从指定文件夹中递归读取音乐文件,并将它们以数组形式返回
* @param {string} directoryPath - 要读取的文件夹路径
* @param {number} fileLimit - 返回的音乐文件数量限制
* @returns {Array} - 包含音乐文件信息的数组
*/
export const readDirAsync = async (directoryPath, fileLimit = 5000) => {
const result = [];
// 递归读取文件夹中的项目
const readItem = async (item) => {
const itemPath = path.join(directoryPath, item);
const stats = await fs.stat(itemPath);
// 若为音频文件
if (stats.isFile() && isAudioFile(itemPath)) {
try {
const { common, format } = await parseFile(itemPath);
// 音乐文件信息
const fileInfo = {
id: generateId(itemPath),
name: common.title,
path: itemPath,
size: (stats.size / (1024 * 1024)).toFixed(2),
time: stats.mtime?.getTime(),
artists: common.artists?.[0],
album: common.album,
alia: common.comment?.[0],
duration: formatDuration(format.duration),
};
result.push(fileInfo);
} catch (error) {
console.error("解析音乐文件元数据时出错:", error);
}
}
// 若为文件夹
if (stats.isDirectory()) {
// 读取子文件夹中的项目
const subItems = await fs.readdir(itemPath);
for (const subItem of subItems) {
await readItem(path.join(item, subItem));
}
}
};
// 从根目录开始读取
await readItem("");
// 返回不超过上限的音乐文件列表
return result.slice(0, fileLimit);
};
/**
* 递归地读取文件夹内容,包括文件和子文件夹的信息
* @param {string} directoryPath - 要读取的文件夹路径
* @param {number} depth - 递归深度(默认为 -1无限递归
* @param {number} fileLimit - 文件总数
* @returns {Promise<Array>} 包含文件和子文件夹信息的树形数组
*/
export const readDirTreeAsync = async (directoryPath, depth = -1, fileLimit = 5000) => {
const result = [];
const readItem = async (item) => {
const itemPath = path.join(directoryPath, item);
const stats = await fs.stat(itemPath);
const fileInfo = {
id: generateId(item),
name: item,
path: itemPath,
type: stats.isFile() ? "song" : "dir",
size: (stats.size / (1024 * 1024)).toFixed(2), // 文件大小
modified: stats.mtime, // 修改日期
};
if (stats.isFile() && isAudioFile(itemPath)) {
try {
const { common, format } = await parseFile(itemPath);
fileInfo.metadata = {
name: common.title,
artists: common.artists,
album: common.album,
date: common.date,
alia: common.comment?.[0],
year: common.year,
duration: formatDuration(format.duration),
};
} catch (error) {
console.error("解析音乐文件元数据时出错:", error);
}
}
if (stats.isDirectory() && (depth === -1 || depth > 0)) {
// 如果是文件夹且未达到递归深度限制,且文件数量未达到上限,则递归读取文件夹内容
if (fileInfo.type === "dir" && result.length < fileLimit) {
fileInfo.children = await readDirAsync(
itemPath,
depth === -1 ? -1 : depth - 1,
fileLimit - result.length,
);
}
}
result.push(fileInfo);
};
const items = await fs.readdir(directoryPath);
await Promise.all(items.map(readItem));
return result.slice(0, fileLimit); // 返回不超过上限的文件列表
};
/**
* 歌曲时长时间戳转换
* @param {number} mss 毫秒数
* @returns {string} 格式为 "mm:ss" 的字符串
*/
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
const formattedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`;
const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`;
return `${formattedMinutes}:${formattedSeconds}`;
};
/**
* 判断文件是否为音频文件
* @param {string} filePath - 文件路径
* @returns {boolean} - 是否为音频文件
*/
const isAudioFile = (filePath) => {
const audioExtensions = [".flac", ".mp3"];
const extension = path.extname(filePath).toLowerCase();
return audioExtensions.includes(extension);
};
/**
* 从文件名生成数字 ID
* @param {string} fileName - 文件名
* @returns {number} - 生成的数字ID
*/
const generateId = (fileName) => {
// 将文件名转换为哈希值
let hash = 0;
for (let i = 0; i < fileName.length; i++) {
hash = (hash << 5) - hash + fileName.charCodeAt(i);
}
// 将哈希值转换为正整数
const numericId = Math.abs(hash % 10000000000);
return numericId;
};

8
electron/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import { ElectronAPI } from "@electron-toolkit/preload";
declare global {
interface Window {
electron: ElectronAPI;
api: unknown;
}
}

View File

@@ -1,15 +1,16 @@
import { contextBridge } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
// 如果启用了上下文隔离,使用 `contextBridge` Electron API 暴露给渲染进程
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
// 使用 contextBridge 暴露 electronAPI 到渲染进程的全局对象中
contextBridge.exposeInMainWorld("electron", electronAPI);
} catch (error) {
console.error(error);
}
} else {
// 如果上下文隔离未启用,将 electronAPI 添加到 DOM 全局对象
// @ts-expect-error (define in dts)
window.electron = electronAPI;
}

59
electron/server/index.ts Normal file
View File

@@ -0,0 +1,59 @@
import { join } from "path";
import { isDev } from "../main/utils";
import initNcmAPI from "./netease";
import initUnblockAPI from "./unblock";
import fastifyCookie from "@fastify/cookie";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import fastify from "fastify";
import log from "../main/logger";
const initAppServer = async () => {
try {
const server = fastify({
// 忽略尾随斜杠
ignoreTrailingSlash: true,
});
// 注册插件
server.register(fastifyCookie);
server.register(fastifyMultipart);
// 生产环境启用静态文件
if (!isDev) {
log.info("📂 Serving static files from /renderer");
server.register(fastifyStatic, {
root: join(__dirname, "../renderer"),
});
}
// 声明
server.get("/api", (_, reply) => {
reply.send({
name: "SPlayer API",
description: "SPlayer API service",
author: "@imsyy",
list: [
{
name: "NeteaseCloudMusicApi",
url: "/api/netease",
},
{
name: "UnblockAPI",
url: "/api/unblock",
},
],
});
});
// 注册接口
server.register(initNcmAPI, { prefix: "/api" });
server.register(initUnblockAPI, { prefix: "/api" });
// 启动端口
const port = Number(import.meta.env["VITE_SERVER_PORT"] || 25884);
await server.listen({ port });
log.info(`🌐 Starting AppServer on port ${port}`);
return server;
} catch (error) {
log.error("🚫 AppServer failed to start");
throw error;
}
};
export default initAppServer;

View File

@@ -0,0 +1,66 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { pathCase } from "change-case";
import NeteaseCloudMusicApi from "NeteaseCloudMusicApi";
import log from "../../main/logger";
// 获取数据
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
return async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
log.info("🌐 Request NcmAPI:", name);
// 获取 NcmAPI 数据
try {
const result = await neteaseApi({
...req.query,
...(req.body as Record<string, any>),
cookie: req.cookies,
});
return reply.send(result.body);
} catch (error: any) {
log.error("❌ NcmAPI Error:", error);
if ([400, 301].includes(error.status)) {
return reply.status(error.status).send(error.body);
}
return reply.status(500);
}
};
};
// 初始化 NcmAPI
const initNcmAPI = async (fastify: FastifyInstance) => {
// 主信息
fastify.get("/netease", (_, reply) => {
reply.send({
name: "NeteaseCloudMusicApi",
version: "4.20.0",
description: "网易云音乐 Node.js API service",
author: "@binaryify",
license: "MIT",
url: "https://gitlab.com/Binaryify/neteasecloudmusicapi",
});
});
// 注册 NeteaseCloudMusicApi 所有接口
Object.entries(NeteaseCloudMusicApi).forEach(([routerName, neteaseApi]: [string, any]) => {
// 例外
if (["serveNcmApi", "getModulesDefinitions"].includes(routerName)) return;
// 路由名称
const pathName = pathCase(routerName);
// 获取数据
const handler = getHandler(pathName, neteaseApi);
// 注册路由
fastify.get(`/netease/${pathName}`, handler);
fastify.post(`/netease/${pathName}`, handler);
// 兼容路由 - 中间具有 _ 的路由
if (routerName.includes("_")) {
fastify.get(`/netease/${routerName}`, handler);
fastify.post(`/netease/${routerName}`, handler);
}
});
log.info("🌐 Register NcmAPI successfully");
};
export default initNcmAPI;

View File

@@ -0,0 +1,68 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { SongUrlResult } from "./unblock";
import getKuwoSongUrl from "./kuwo";
import log from "../../main/logger";
import axios from "axios";
/**
* 直接获取 网易云云盘 链接
* Thank @939163156
* Power by GD音乐台(music.gdstudio.xyz)
*/
const getNeteaseSongUrl = async (id: number | string): Promise<SongUrlResult> => {
try {
if (!id) return { code: 404, url: null };
const baseUrl = "https://music-api.gdstudio.xyz/api.php";
const result = await axios.get(baseUrl, {
params: { types: "url", id },
});
const songUrl = result.data.url;
log.info("🔗 NeteaseSongUrl URL:", songUrl);
return { code: 200, url: songUrl };
} catch (error) {
log.error("❌ Get NeteaseSongUrl Error:", error);
return { code: 404, url: null };
}
};
// 初始化 UnblockAPI
const UnblockAPI = async (fastify: FastifyInstance) => {
// 主信息
fastify.get("/unblock", (_, reply) => {
reply.send({
name: "UnblockAPI",
description: "SPlayer UnblockAPI service",
author: "@imsyy",
content:
"部分接口采用 @939163156 by GD音乐台(music.gdstudio.xyz),仅供本人学习使用,不可传播下载内容,不可用于商业用途。",
});
});
// netease
fastify.get(
"/unblock/netease",
async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
const { id } = req.query;
const result = await getNeteaseSongUrl(id);
return reply.send(result);
},
);
// kuwo
fastify.get(
"/unblock/kuwo",
async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
const { keyword } = req.query;
const result = await getKuwoSongUrl(keyword);
return reply.send(result);
},
);
log.info("🌐 Register UnblockAPI successfully");
};
export default UnblockAPI;

View File

@@ -0,0 +1,66 @@
import { encryptQuery } from "./kwDES";
import { SongUrlResult } from "./unblock";
import log from "../../main/logger";
import axios from "axios";
// 获取酷我音乐歌曲 ID
const getKuwoSongId = async (keyword: string): Promise<string | null> => {
try {
const url =
"http://search.kuwo.cn/r.s?&correct=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all=" +
keyword;
const result = await axios.get(url);
if (
!result.data ||
result.data.content.length < 2 ||
!result.data.content[1].musicpage ||
result.data.content[1].musicpage.abslist.length < 1
) {
return null;
}
// 获取歌曲信息
const songId = result.data.content[1].musicpage.abslist[0].MUSICRID;
const songName = result.data.content[1].musicpage.abslist[0]?.SONGNAME;
// 是否与原曲吻合
const originalName = keyword?.split("-") ?? keyword;
if (songName && !songName?.includes(originalName[0])) return null;
return songId.slice("MUSIC_".length);
} catch (error) {
log.error("❌ Get KuwoSongId Error:", error);
return null;
}
};
// 获取酷我音乐歌曲 URL
const getKuwoSongUrl = async (keyword: string): Promise<SongUrlResult> => {
try {
if (!keyword) return { code: 404, url: null };
const songId = await getKuwoSongId(keyword);
if (!songId) return { code: 404, url: null };
// 请求地址
const PackageName = "kwplayer_ar_5.1.0.0_B_jiakong_vh.apk";
const url =
"http://mobi.kuwo.cn/mobi.s?f=kuwo&q=" +
encryptQuery(
`corp=kuwo&source=${PackageName}&p2p=1&type=convert_url2&sig=0&format=mp3` +
"&rid=" +
songId,
);
const result = await axios.get(url, {
headers: {
"User-Agent": "okhttp/3.10.0",
},
});
if (result.data) {
const urlMatch = result.data.match(/http[^\s$"]+/)[0];
log.info("🔗 KuwoSong URL:", urlMatch);
return { code: 200, url: urlMatch };
}
return { code: 404, url: null };
} catch (error) {
log.error("❌ Get KuwoSong URL Error:", error);
return { code: 404, url: null };
}
};
export default getKuwoSongUrl;

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-undef */
/*
Thanks to
https://github.com/XuShaohua/kwplayer/blob/master/kuwo/DES.py

4
electron/server/unblock/unblock.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export type SongUrlResult = {
code: number;
url: string | null;
};

5
env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export interface MainEnv {
readonly VITE_WEB_PORT: string;
readonly VITE_SERVER_PORT: string;
readonly VITE_API_URL: string;
}

View File

@@ -3,22 +3,19 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="%RENDERER_VITE_SITE_LOGO%" />
<link rel="apple-touch-icon" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="bookmark" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="apple-touch-icon-precomposed" sizes="200x200" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="icon" type="image/icon" href="/icons/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%RENDERER_VITE_SITE_TITLE%</title>
<meta name="apple-mobile-web-app-title" content="%RENDERER_VITE_SITE_TITLE%" />
<meta name="author" content="%RENDERER_VITE_SITE_ANTHOR%" />
<meta name="keywords" content="%RENDERER_VITE_SITE_KEYWORDS%" />
<meta name="description" content="%RENDERER_VITE_SITE_DES%" />
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>SPlayer</title>
<!-- font -->
<link rel="stylesheet" href="/fonts/font.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
gzip on;
listen 25884;
listen [::]:25884;
server_name localhost;
client_max_body_size 100M;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location @rewrites {
rewrite ^(.*)$ /index.html last;
}
location /api/netease/ {
proxy_buffers 16 64k;
proxy_buffer_size 128k;
proxy_busy_buffers_size 256k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $remote_addr;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://localhost:3000/;
}
}

View File

@@ -1,68 +1,114 @@
{
"name": "splayer",
"version": "2.0.0-beta.3",
"productName": "SPlayer",
"version": "3.0.0-alpha.2",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
"home": "https://imsyy.top",
"github": "https://github.com/imsyy/SPlayer",
"blog": "https://blog.imsyy.top",
"repository": "github:imsyy/SPlayer",
"license": "AGPL-3.0",
"license-file": "LICENSE",
"engines": {
"node": ">=16.16.0"
"node": ">=20",
"npm": ">=10"
},
"type": "module",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"lint": "npx eslint . --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "chcp 65001 && electron-vite dev --watch",
"build": "electron-vite build",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
"build:mac": "npm run build && electron-builder --mac --config",
"build:linux": "npm run build && electron-builder --linux --config"
"build:web": "npm run build",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^2.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.13.5",
"axios": "^1.4.0",
"colorthief": "^2.4.0",
"electron-dl": "^3.5.1",
"electron-updater": "^6.1.7",
"express": "^4.18.2",
"express-http-proxy": "^1.6.3",
"howler": "^2.2.3",
"@applemusic-like-lyrics/core": "^0.1.3",
"@applemusic-like-lyrics/lyric": "^0.2.2",
"@applemusic-like-lyrics/vue": "^0.1.5",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@pixi/app": "^7.4.2",
"@pixi/core": "^7.4.2",
"@pixi/display": "^7.4.2",
"@pixi/filter-blur": "^7.4.2",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
"@pixi/sprite": "^7.4.2",
"@vueuse/core": "^10.11.1",
"NeteaseCloudMusicApi": "^4.22.0",
"axios": "^1.7.7",
"change-case": "^5.4.4",
"dayjs": "^1.11.13",
"electron-dl": "^3.5.2",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.4",
"file-saver": "^2.0.5",
"font-list": "^1.5.1",
"github-markdown-css": "^5.7.0",
"howler": "^2.2.4",
"js-cookie": "^3.0.5",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"music-metadata": "7.13.4",
"node-id3": "^0.2.6",
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"lodash-es": "^4.17.21",
"marked": "^14.1.2",
"music-metadata": "7.14.0",
"pinia": "^2.2.2",
"pinia-plugin-persistedstate": "^3.2.3",
"plyr": "^3.7.8",
"qrcode.vue": "^3.4.1",
"screenfull": "^6.0.2",
"vue-router": "^4.2.4",
"vue-slider-component": "4.1.0-beta.7"
"vue-virtual-scroller": "2.0.0-beta.8"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.1",
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"electron": "^25.6.0",
"electron-builder": "^24.6.4",
"electron-log": "^5.0.1",
"electron-vite": "^1.0.27",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"naive-ui": "^2.34.4",
"prettier": "^3.0.2",
"sass": "^1.66.1",
"terser": "^5.19.2",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@fastify/cookie": "^9.4.0",
"@fastify/http-proxy": "^9.5.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@types/file-saver": "^2.0.7",
"@types/howler": "^2.2.11",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.5.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-vue": "^5.1.3",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
"electron": "^28.3.3",
"electron-builder": "^24.13.3",
"electron-log": "^5.2.0",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.28.0",
"fast-glob": "^3.3.2",
"fastify": "^4.28.1",
"naive-ui": "^2.39.0",
"node-taglib-sharp": "^5.2.3",
"prettier": "^3.3.3",
"sass": "^1.78.0",
"terser": "^5.33.0",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.3",
"vite-plugin-compression": "^0.5.1",
"vue": "^3.3.4"
"vite-plugin-wasm": "^3.3.0",
"vue": "3.4.38",
"vue-router": "^4.4.3",
"vue-tsc": "^2.1.6"
}
}

9506
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

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