Compare commits

...

422 Commits

Author SHA1 Message Date
imsyy
1b6ebd9c7c feat: 支持解锁配置 2025-11-21 17:41:34 +08:00
imsyy
7721251a98 feat: 优化播放流程 2025-11-21 11:57:53 +08:00
底层用户
320047ca9c Merge pull request #586 from SUBearH/SUBearH-patch-2
为歌单页面添加歌单创建时间的显示
2025-11-21 11:54:55 +08:00
底层用户
73be8d8657 Merge pull request #585 from SUBearH/SUBearH-patch-1
Increase max badge value from 999 to 9999
2025-11-21 11:53:29 +08:00
imsyy
d527b076dc 🐞 fix: 优化播放处理 2025-11-21 11:03:36 +08:00
SUBear
7bf9c6d7bc Merge pull request #1 from SUBearH/SUBearH-patch-2-1
Fix conditional rendering for createTime in playlist
2025-11-21 02:51:05 +08:00
SUBear
4dfd897401 Fix conditional rendering for createTime in playlist 2025-11-21 02:48:29 +08:00
SUBear
f0a6526fd1 Fix conditional rendering for createTime display 2025-11-21 02:47:02 +08:00
SUBear
6d4f78413b Increase max badge value from 999 to 9999 2025-11-21 02:36:59 +08:00
imsyy
8866996e5b 🐞 fix: 再次修复过慢的歌词请求 2025-11-20 23:23:33 +08:00
imsyy
5d21709c58 🌈 style: 简介改为弹窗 & 优化简介换行 2025-11-20 21:58:12 +08:00
底层用户
54d77d08eb Merge pull request #583 from MoYingJi/fix
fix(playlist): 歌单描述有换行时并没有正确换行
2025-11-20 20:37:10 +08:00
MoYingJi
bd25a8fe2e fix(playlist): 歌单描述有换行时并没有正确换行 2025-11-20 20:30:14 +08:00
imsyy
f0270a2fb0 feat: 添加交流群 2025-11-20 16:50:35 +08:00
imsyy
3ebfccdfcc feat: 新增音源-gequbao 2025-11-20 14:47:51 +08:00
imsyy
c29f2ed0a0 🌈 style: 优化部分样式 2025-11-20 14:23:17 +08:00
imsyy
6caf99da09 feat(player): 添加播放状态信息显示功能
- 在设置中新增开关控制是否显示播放状态信息
- 在播放器界面添加当前歌曲及歌词状态信息显示
- 优化歌词管理器逻辑,改进TTML歌词处理
- 调整播放器数据组件布局,支持精简显示模式
2025-11-20 00:14:05 +08:00
底层用户
ad27d1eaea Merge pull request #578 from MoYingJi/feat
fix(local-lyric): 现在会匹配带前缀的歌词文件名
2025-11-19 22:13:30 +08:00
MoYingJi
8846d7f669 fix(local-lyric): 现在会匹配带前缀的歌词文件名
也添加了注释解释 `{,*.}` 的作用,避免被误删
2025-11-19 21:36:48 +08:00
imsyy
807b72ed9e 🌈 style: 优化样式 2025-11-19 18:32:39 +08:00
imsyy
d89be488e2 修复列表问题 #569 #568 2025-11-19 14:17:22 +08:00
imsyy
4bf986b763 feat: 桌面歌词支持 TTML 2025-11-19 10:56:55 +08:00
imsyy
ffb1fcc1ea 🐞 fix: 修正无歌词时条件 2025-11-18 18:19:38 +08:00
imsyy
ea822f91e8 🐞 fix: 修复失败后无法尝试下一曲 2025-11-18 16:12:23 +08:00
imsyy
5e260ffc0d 🐞 fix: 修复进度调节单位错误 2025-11-18 15:29:37 +08:00
imsyy
e3dcde71e8 Merge branch 'dev-lyric' into dev 2025-11-18 15:22:25 +08:00
imsyy
39c35e8a31 🦄 refactor: 重构为毫秒单位 2025-11-18 15:08:15 +08:00
imsyy
772f6552e7 🦄 refactor: 基础适配新格式 2025-11-18 00:16:59 +08:00
imsyy
c9f3553806 🐞 fix: 修复歌词管理引用错误 2025-11-17 18:15:28 +08:00
底层用户
7de1355f18 Merge pull request #573 from MoYingJi/docs-it
docs: 小改 Issue Template
2025-11-17 14:19:59 +08:00
imsyy
392c64f06b feat: 完善歌词模块 2025-11-16 23:35:52 +08:00
MoYingJi
0d66ced637 docs: 小改 Issue Template 2025-11-16 00:29:09 +08:00
底层用户
ae1ee71e75 Merge pull request #567 from MoYingJi/fix-ldc
fix(player): 修复极端状态下播放异常
2025-11-14 09:29:01 +08:00
底层用户
84d9c999eb Merge pull request #566 from MoYingJi/docs-it
docs: 更新 Issue Template
2025-11-14 09:28:14 +08:00
MoYingJi
1a36fbf1d5 fix(player): 修复极端状态下播放异常
由于 `jumpSeek` 现在会减去 `offset`,可能会导致当 `offset` 超出预期时引发一些奇怪的行为
2025-11-14 03:06:29 +08:00
MoYingJi
e0e62cd906 docs: 更新 Issue Template 2025-11-14 01:14:46 +08:00
imsyy
6d367f1fd5 🧪 test: lyric 重构准备 2025-11-14 00:39:19 +08:00
底层用户
2ed6acf934 Merge pull request #563 from MoYingJi/fix-ldc
fix(Lyric): 点击歌词跳转进度未应用偏移
2025-11-14 00:37:56 +08:00
MoYingJi
236ee0a345 style: 补全行尾分号 统一代码风格 2025-11-14 00:06:19 +08:00
MoYingJi
fe0f7a0f25 fix(Lyric): 点击歌词跳转进度未应用偏移 2025-11-13 20:16:55 +08:00
底层用户
8529663ea5 Merge pull request #559 from MoYingJi/feat-lr
feat(lyric): 支持解析本地 LRC 歌词中的音译
2025-11-12 22:29:16 +08:00
MoYingJi
100bae7488 feat(lyric): 支持解析本地 LRC 歌词中的音译
根据已解析歌词中是否有时间相同来判断,因此最先遍历的歌词行会被作为主歌词

若具有翻译或音译,则判断主歌词中是否有翻译,若没有则将此句作为翻译,音译同理

如果出现时间相同的歌词行,第一行会被作为主歌词,第二行翻译,第三行音译,其余舍去
2025-11-12 21:56:04 +08:00
imsyy
91927b8d76 🎈 perf: 优化桌面歌词 hover 效果,避免长时间不消失 2025-11-12 10:47:32 +08:00
imsyy
8be209837d 🐞 fix: 修复窗口关闭问题 #547 2025-11-12 10:28:07 +08:00
底层用户
9ae09b5711 Merge pull request #556 from MoYingJi/fix
fix(lyric): 关闭在线 TTML 歌词后不获取本地 TTML 歌词了
2025-11-12 10:23:57 +08:00
MoYingJi
3509d73ecc chore(lyric): 修正注释 2025-11-12 10:18:04 +08:00
MoYingJi
0bdb9c07b7 chore(lyric): 添加注释说明 TTML 在线获取逻辑
因为好像确实不太好看懂哈,我的代码有点抽象?
2025-11-12 10:16:38 +08:00
MoYingJi
3218ab05f1 fix(lyric): 关闭在线 TTML 歌词后不获取本地 TTML 歌词了 2025-11-12 10:05:10 +08:00
底层用户
e40343f91f Merge pull request #554 from MoYingJi/feat-ll
feat(LocalLyric): 使本地覆盖在线歌词的文件更好管理
2025-11-12 09:47:54 +08:00
MoYingJi
fcf1c235c3 fix: 现在更改本地歌词目录时也会检测是否为子目录 2025-11-12 05:42:51 +08:00
MoYingJi
ef0433c645 feat(LocalLyric): 使本地覆盖在线歌词的文件更好管理
使「本地歌词覆盖在线歌词」功能中的用户存放的文件更好管理

## 改动
 - 支持将歌词命名为 `任意前缀.歌曲ID.后缀名` 的格式
 - 支持将歌词文件放在子文件夹

## 代码改动
 - 将 `ipc-file` 中的 `read-local-lyric` 里写死的路径改成了 FastGlob 查找,因此支持了上述的两项功能
 - 更改 `LocalSettings.vue` 中的信息
2025-11-12 02:47:10 +08:00
底层用户
207f84fcd7 Merge pull request #550 from MoYingJi/feat-settings
refactor(LyricsSetting): 增加歌词内容分组
2025-11-11 22:40:36 +08:00
底层用户
2985aa5977 Merge pull request #549 from MoYingJi/feat-unlock
refactor(unlock): 重写部分 getUnlockSongUrl
2025-11-11 22:39:24 +08:00
底层用户
ffd7aeff49 Merge pull request #548 from MoYingJi/fix-ttml
fix(ttml): 行结束时间过早
2025-11-11 22:37:57 +08:00
MoYingJi
7f4a88daa1 refactor(LyricsSetting): 增加歌词内容分组 2025-11-11 19:26:33 +08:00
MoYingJi
91171465b2 refactor(unlock): 重写部分 getUnlockSongUrl
只是重写,没有改变逻辑,没有改变源和优先级,但使源更容易更改
2025-11-11 19:11:27 +08:00
MoYingJi
1744a5a678 fix(ttml): 行结束时间过早
TTML 有时会为了触发一些效果(比如和下一行连起来)而故意延后行的结束时间(同时词的结束时间不变),`parseTTMLToAMLL` 直接忽略了行的开始结束时间而采用词的开始结束时间
2025-11-11 19:04:30 +08:00
imsyy
ce3d469547 🐞 fix: 桌面歌词无法拖动 #545 2025-11-11 18:16:46 +08:00
imsyy
7cc5b85fc5 🐞 fix: 托盘右键无法正常关闭软件 #546 2025-11-11 14:46:53 +08:00
imsyy
3b3f321a3d 🐞 fix: 修复窗口 IPC 报错 & 歌单去重 2025-11-10 23:43:13 +08:00
imsyy
58e3c6e21c 🐞 fix: 修复本地歌词覆盖失效 2025-11-10 17:07:43 +08:00
imsyy
68756f2502 🐞 fix: 修复进度条点击及拖拽问题 #535 2025-11-10 14:37:52 +08:00
imsyy
cea9f7b025 🐞 fix: 修复TTML到LRC的回退逻辑 2025-11-10 02:00:33 +08:00
imsyy
6b653bc5e8 🌈 style: 优化歌词与桌面歌词样式问题 2025-11-10 00:36:18 +08:00
imsyy
1b2985892b 🐞 fix: 修复退出应用时进程异常阻断 2025-11-09 01:57:48 +08:00
imsyy
eb0094c189 feat: 优化列表播放方式 & 添加一些桌面歌词配置 #536 2025-11-09 00:39:55 +08:00
imsyy
5edbd66398 feat: 完善本地歌词逐字效果 & 修复锁定后无法点击 2025-11-08 21:52:22 +08:00
底层用户
4e731e976c Merge pull request #539 from MoYingJi/fix
fix: 本地音乐内嵌歌词加载状态未恢复
2025-11-08 21:40:51 +08:00
MoYingJi
bd6e23435e fix: 本地音乐内嵌歌词加载状态未恢复 2025-11-08 19:29:25 +08:00
imsyy
8ec91e1392 feat: 添加桌面歌词锁定 2025-11-07 17:38:49 +08:00
imsyy
8f69b56378 🐞 fix: 尝试修复缩放问题 2025-11-07 12:34:32 +08:00
imsyy
ccd0c6bdeb 🐞 fix: 修复系统开启缩放时,拖拽桌面歌词导致宽高异常 2025-11-07 01:06:36 +08:00
imsyy
1c9109af73 🐞 fix: 修复桌面歌词字体调节 2025-11-06 15:04:40 +08:00
imsyy
a556a2e102 🌈 style: 优化桌面歌词样式 2025-11-06 01:07:04 +08:00
imsyy
9fcd5b0e98 feat: 完善歌词窗口 IPC 2025-11-05 18:21:17 +08:00
imsyy
a1be1e16b2 feat: 更换解灰音源 2025-11-04 17:49:38 +08:00
imsyy
fc7fc08a6e 🌈 style: 优化评论展示样式 2025-11-04 16:30:52 +08:00
imsyy
d74515142d 🎈 perf: 优化歌词并发限制 2025-11-04 14:28:43 +08:00
imsyy
0aae10e8a0 🐞 fix: 修复歌词加载过慢仍旧展示上一首 #532 2025-11-04 12:01:27 +08:00
imsyy
242c6f2ca7 🎈 perf: 优化相关引用 2025-11-03 23:25:22 +08:00
imsyy
413b74bf9a 🎈 perf: 优化播放器 2025-11-03 17:14:52 +08:00
imsyy
aad5c9461f Merge branch 'dev' of https://github.com/imsyy/SPlayer into dev 2025-11-03 09:24:10 +08:00
imsyy
23cccbb660 🐞 fix: 修复单曲循环模式加载状态异常 #531 2025-11-03 09:22:37 +08:00
底层用户
ba7666526c Merge pull request #525 from MoYingJi/feat
fix: dev.mjs linux/macOS 设置 UTF-8
2025-11-01 20:10:08 +08:00
底层用户
82ea11b6fa Merge pull request #526 from MoYingJi/fix
fix(AMLyric): 有时处理歌词语言不正常工作
2025-11-01 20:09:45 +08:00
MoYingJi
9771cea25f optimize(AMLyric): 组件被挂载时重复处理歌词
因为已经侦听了 `lyricPlayerRef`,所以 `onMounted` 中处理歌词是不必要的
2025-11-01 17:56:29 +08:00
MoYingJi
6f4afd85d7 fix(AMLyric): 有时处理歌词语言不正常工作 2025-11-01 01:54:05 +08:00
MoYingJi
fe82fa623b fix: dev.mjs linux/macOS 设置 UTF-8 2025-10-31 21:03:33 +08:00
imsyy
28b521a192 🎈 perf: 完善部分桌面歌词 2025-10-31 00:55:47 +08:00
imsyy
bdfbb4d2b1 🐞 fix: 修复网页端获取 ttml 2025-10-30 14:04:08 +08:00
imsyy
6937a93d17 feat: 完善 store 通信 2025-10-29 23:59:13 +08:00
imsyy
2bc4fca4dd 🐞 fix: 修复 ttml 返回 404 2025-10-29 22:35:51 +08:00
imsyy
92428593e2 🐞 fix: 修复参数错误 2025-10-29 20:42:20 +08:00
imsyy
95e3f74301 🔧 build: 添加所需文件 2025-10-29 18:34:48 +08:00
imsyy
9145eb034b 🌈 style: 优化部分样式 2025-10-29 18:29:58 +08:00
底层用户
829dc3b591 🐞 fix: 修复路由重定向 2025-10-28 17:23:15 +08:00
底层用户
c4c6178b9e 🌈 style: 优化评论区效果 2025-10-28 14:51:58 +08:00
imsyy
9967bcb102 🌈 style: 优化播放样式 2025-10-28 00:33:40 +08:00
底层用户
feb186db65 🌈 style: 优化播放器切歌效果 2025-10-27 18:21:05 +08:00
底层用户
84999338e6 🎈 perf: 优化歌词组件样式性能 2025-10-27 15:15:54 +08:00
imsyy
7943b56dbb 🐞 fix: 修复工具栏图标 & 添加调试工具 2025-10-27 00:46:02 +08:00
imsyy
6a1657cf20 🦄 refactor: 主进程重构
修复导航栏不及时响应窗口状态
修复 thumb 展示异常
2025-10-25 23:43:54 +08:00
底层用户
0eaf32b8a7 Merge pull request #517 from MoYingJi/feat
feat: 支持收藏专辑
2025-10-25 22:17:04 +08:00
MoYingJi
424c1e76f0 优化格式 去掉一层嵌套 2025-10-25 03:36:08 +08:00
MoYingJi
e854dbb816 fix: 未使用的 import 和注释错误 2025-10-25 03:33:12 +08:00
MoYingJi
5e37678348 refactor(auth): 抽离收藏/订阅逻辑到通用函数
`toLikeSomething` 的 `request` 参数必须套一层 getter 才行,不然报错在定义前使用,不是很懂 TypeScript 🤔
2025-10-25 03:28:37 +08:00
MoYingJi
9fcb5ca633 feat: 支持收藏专辑 2025-10-25 03:06:42 +08:00
底层用户
55aedc33c3 Merge pull request #516 from MoeFurina/dev
ci(vercel): 添加vercel配置文件, 方便下次部署就不需要在outputdirectory输入"out/renderer"了
2025-10-24 23:09:59 +08:00
底层用户
1d4aa43185 Merge pull request #514 from MoYingJi/feat
feat(lyric): 优化歌词排除
2025-10-24 23:08:40 +08:00
ElyPrism
8014eedd72 ci(vercel): 添加vercel配置文件, 方便下次部署就不需要在outputdirectory输入"out/renderer"了 2025-10-24 22:43:15 +08:00
MoYingJi
4f018775ba fix(lyric): 一些错误 2025-10-24 22:16:05 +08:00
MoYingJi
4e7bd95366 feat(lyric): 本地歌词排除设置项 2025-10-24 22:06:57 +08:00
MoYingJi
3035056ded 优化代码 2025-10-24 21:13:55 +08:00
MoYingJi
df42292f0a fix: 不排除 TTML 歌词 设置不生效 2025-10-24 20:34:52 +08:00
MoYingJi
ca2ff2a890 feat(lyric): 设置项「TTML 歌词排除」 2025-10-24 20:01:36 +08:00
MoYingJi
bd06c3f8f4 fix(storage): 原来不能直接存正则 2025-10-24 17:43:59 +08:00
MoYingJi
a44cd904dc feat(lyric): 改个名 ExcludeKeywords -> ExcludeLyrics 2025-10-24 17:27:32 +08:00
MoYingJi
09838e632d feat(lyric): 修改歌词排除配置页面 2025-10-24 17:11:15 +08:00
MoYingJi
7f77ccb18a feat(lyric): 换用歌词排除函数 2025-10-24 16:40:01 +08:00
MoYingJi
f3f05aa72b feat(lyric): 歌词行正则排除 基本设置 2025-10-24 16:34:49 +08:00
底层用户
6beb9c78e1 feat: 新增 每日推荐 - 不感兴趣 2025-10-24 15:23:51 +08:00
底层用户
2efc0a5228 🐞 fix: 歌词排除内容不生效 #509 2025-10-23 16:23:35 +08:00
底层用户
bb0cae2be1 🐞 fix: 修复 单曲循环索引问题 #510 2025-10-23 15:50:41 +08:00
底层用户
22a6fdd0c4 Merge pull request #507 from MoYingJi/feat
feat: 优化启用 TTML 选项的逻辑
2025-10-23 09:12:23 +08:00
MoYingJi
d257a441c2 feat: 优化启用 TTML 选项的逻辑
原先的逻辑不太统一,一边受选项控制,一边不受选项控制

现在将所有的本地 TTML 加载都改为不受选项控制,只要本地文件存在则始终启用,且将设置项「启用 TTML 歌词」更名为「启用在线 TTML 歌词」
2025-10-22 23:10:36 +08:00
底层用户
49c81b6afd feat: 调整路由结构
- 修复网页端报错 #505
2025-10-22 17:57:48 +08:00
底层用户
045497a7c8 🌈 style: 优化一些样式 2025-10-22 15:15:19 +08:00
底层用户
521970b8a1 Merge pull request #503 from MoYingJi/feat
fix: 更改函数名后构建失败
2025-10-22 14:56:01 +08:00
MoYingJi
a1e9dd7746 fix: 更改函数名后构建失败
千万不要随便改函数名,因为 IDE 可能根本搜不到调用的地方...
2025-10-22 14:47:50 +08:00
底层用户
61d2467916 Merge pull request #502 from MoYingJi/feat
feat: 对本地歌词的修改
2025-10-22 14:33:05 +08:00
底层用户
f3adf773a7 Merge pull request #501 from MoYingJi/fix
fix: 恢复窗口最大化状态时过早显示窗口
2025-10-22 14:17:20 +08:00
MoYingJi
ad807cf478 chore: 规范代码 添加分号 2025-10-22 13:59:56 +08:00
MoYingJi
a1194232af log 怎么又忘删了 2025-10-22 13:48:25 +08:00
MoYingJi
9fc1746495 feat: 支持从本地读取歌词覆盖线上歌词 2025-10-22 13:33:33 +08:00
MoYingJi
103bf7948d 本地音乐歌词的修改
- 优先使用外部歌词
- 支持本地 TMLL 歌词
2025-10-22 11:58:01 +08:00
MoYingJi
12c2dea226 fix: 恢复窗口最大化状态时过早显示窗口
在恢复窗口的最大化状态时,窗口还未加载完成就调用了 `maximize` 导致窗口过早显示
2025-10-22 11:16:56 +08:00
底层用户
7fbe8ac6a5 Merge pull request #500 from MoYingJi/fix
fix: dev.mjs linux/mac 无法启动开发环境
2025-10-22 09:40:48 +08:00
MoYingJi
ef6868fd40 fix: dev.mjs linux/mac 无法启动开发环境
在 isWindows 分支中,startElectronVite 函数正常启动
在 else 分支中,startElectronVite 函数在定义之前就被调用了
2025-10-22 02:41:33 +08:00
imsyy
f238845f9b 🐞 fix: 调整均衡器 2025-10-22 00:49:31 +08:00
底层用户
d4d16b71ae feat: 新增 均衡器 2025-10-21 18:15:51 +08:00
底层用户
9baf571478 🐞 fix: 修复文件导入 2025-10-21 16:42:15 +08:00
底层用户
6370ac77e6 feat: 优化歌词偏移管理 2025-10-21 16:30:34 +08:00
底层用户
2bf3f2a5c6 🗑️ refactor: 移除不再使用的组件声明文件,更新类型配置 2025-10-21 14:08:24 +08:00
底层用户
e8bf42891e 🐞 fix: 修复大小写 #498 2025-10-21 13:50:56 +08:00
imsyy
c21f970b86 feat: 新增 定时关闭 & 播放速度 2025-10-21 00:12:48 +08:00
底层用户
56045cd338 完善功能 2025-10-20 18:22:40 +08:00
imsyy
dbf6121098 feat: 优化与新增播放器控制组件 2025-10-20 03:44:17 +08:00
imsyy
103b2fe923 feat: 优化播放器布局,修复缺失组件 2025-10-19 02:56:31 +08:00
imsyy
c70a266b22 🐞 fix: 修复本地歌词及元信息错误 2025-10-19 01:36:13 +08:00
底层用户
a7b34ca4b0 Merge pull request #497 from xiaoQQya/unblock-source
修改默认 unblock source 为 kugou bodian pyncmd
2025-10-18 23:33:07 +08:00
底层用户
caa6fa838f Merge pull request #496 from xiaoQQya/dev
修复 UnblockNeteaseMusic 返回非 https 协议链接时反代失败的问题
2025-10-18 23:32:29 +08:00
底层用户
e98140d0ac Merge pull request #495 from MoYingJi/feat-window
feat: 记忆窗口最大化状态
2025-10-18 23:32:11 +08:00
底层用户
ac47b38773 Merge pull request #494 from MoYingJi/feat
fix: AM 歌词翻译错位
2025-10-18 23:30:14 +08:00
xiaoQQya
fef6e4f6fa chore: 修改默认 unblock source 为 kugou bodian pyncmd 2025-10-18 19:56:20 +08:00
xiaoQQya
0215e5944d fix: 修复 UnblockNeteaseMusic 返回非 https 协议链接时反代失败的问题 2025-10-18 19:52:45 +08:00
MoYingJi
1b6ad163e3 log 忘删了 2025-10-18 19:35:26 +08:00
MoYingJi
080de35545 fix(window): Linux 兼容性 2025-10-18 19:22:17 +08:00
MoYingJi
163423222d 移动根据记忆将窗口最大化的逻辑 2025-10-18 19:06:52 +08:00
imsyy
fc49b7ad00 🐳 chore: 优化构建流程 2025-10-18 18:30:15 +08:00
MoYingJi
b83cd99d8c feat: 记录窗口最大化状态 2025-10-18 18:11:34 +08:00
MoYingJi
74a5de96c0 fix: AM 歌词翻译错位
解析 lrc 或 yrc 时,当歌词中有部分歌词行没有翻译时,原先 `parseAMData` 通过 `index` 获取翻译的方法会使得歌词翻译错位

我仿照 `alignLyrics` 写了 `alignAMLyrics`,并修改 `parseAMData` 使其调用此方法对齐
2025-10-18 17:15:18 +08:00
底层用户
fcf78cdd08 🦄 refactor: electron-builder to ts 2025-10-17 17:26:48 +08:00
底层用户
98fbc81d2f ci(workflow): 更新发布工作流配置
移除冗余的文件上传配置并设置矩阵策略不快速失败
2025-10-17 13:55:44 +08:00
底层用户
5ad562ab1c 🐳 chore: 更新构建配置和工作流 2025-10-17 12:38:14 +08:00
底层用户
10dc011f9a 🐳 chore: 更新构建配置和工作流 2025-10-17 12:03:38 +08:00
底层用户
3df6e91f95 🐳 chore: 更新构建配置和工作流 2025-10-17 11:50:18 +08:00
底层用户
31ae15d242 🐳 chore: 移除不必要的环境变量 2025-10-17 11:29:34 +08:00
底层用户
a2cfbb5e52 🐞 fix: 修复构建 pnpm 失败 2025-10-17 11:18:33 +08:00
底层用户
c4bd94daae 🐳 chore: 测试构建 2025-10-17 10:25:49 +08:00
imsyy
e18828cd08 🔧 build: 更新构建内容 2025-10-17 02:47:02 +08:00
imsyy
82f6b4607b 🐳 chore: 修复工作流格式 2025-10-17 01:13:55 +08:00
imsyy
3874ab3483 🐳 chore: 更新工作流 2025-10-17 01:09:01 +08:00
imsyy
fc297cb198 🐳 chore: 测试自动发版 2025-10-17 00:33:46 +08:00
imsyy
aec06c5a55 🐳 chore: 修复构建 2025-10-17 00:11:01 +08:00
imsyy
87ce076f26 🐳 chore: 修复构建打包 2025-10-16 23:09:23 +08:00
底层用户
45b1bf130d feat: 去除报错弹窗 2025-10-16 18:29:27 +08:00
底层用户
3966578015 🐞 fix: 修复 TTML 缺失翻译及音译 2025-10-16 17:50:14 +08:00
底层用户
fed7b3678b feat: 默认歌词支持 TTML 2025-10-16 16:35:37 +08:00
底层用户
ac6ce257b8 Merge pull request #491 from awsl1414/feat/ci-multi-platform
🔧 build: 优化 CI/CD 构建流程,增加多架构支持
2025-10-16 10:30:59 +08:00
awsl1414
b8b8f747d3 🔧 build: 全平台采用原生架构 runner 替代交叉编译
- feat: Windows ARM64 使用 windows-11-arm 原生 runner
- feat: macOS 拆分为独立的 x64 和 ARM64 构建任务
- feat: macOS x64 使用 macos-13 原生 Intel runner
- feat: macOS ARM64 使用 macos-15 原生 Apple Silicon runner
- feat: Linux ARM64 使用 ubuntu-22.04-arm 原生 runner
- fix: 修复 Windows ARM64 交叉编译生成错误 I386 指令集问题
- improve: 移除 QEMU 和交叉编译工具链依赖
- improve: 简化构建配置,提升构建可靠性和性能
- improve: 确保所有平台生成正确架构的可执行文件
2025-10-15 20:16:56 +08:00
awsl1414
53468b2e3a 🔧 build: 优化 CI/CD 构建流程,增加多架构支持
- feat: 为 Windows 构建添加 x64 和 ARM64 分离任务
- feat: 为 Linux 构建添加 ARM64 架构支持
- feat: 优化构建产物命名,包含架构标识
- chore: 更新构建相关依赖版本
- improve: Linux ARM64 使用 QEMU 交叉编译
- improve: 优化工作流任务命名和注释
2025-10-15 04:24:09 +08:00
底层用户
ecadf6ade7 🔧 build: 更新依赖 2025-10-13 09:43:34 +08:00
底层用户
8187b09fcb Merge pull request #484 from MoYingJi/feat
feat: 支持为日语单独设置字体
2025-10-13 09:06:02 +08:00
MoYingJi
89117d4198 规范代码
- 添加了一些注释
- 补全行尾分号 以符合总体代码风格
2025-10-11 01:21:19 +08:00
MoYingJi
6158dd2750 Merge branch 'imsyy:dev' into feat 2025-10-10 00:16:46 +08:00
MoYingJi
86f33d054a 日语单独字体 AM 支持 2025-10-10 00:10:21 +08:00
MoYingJi
52e8458590 fix 2025-10-09 23:13:39 +08:00
MoYingJi
554cf45500 fix 2025-10-09 23:11:50 +08:00
MoYingJi
60f751713a 日文字形设置和 MainLyric 的基础支持 2025-10-09 23:07:57 +08:00
底层用户
edd9b38cfc 🐞 fix: 修复构建失败 2025-10-09 11:43:05 +08:00
底层用户
9a87d73289 Merge pull request #479 from q1zhen/dev
fix: adjust minimum window size 调整最小窗口大小
2025-10-09 10:51:06 +08:00
底层用户
317763e2c3 Merge pull request #481 from MoeFurina/dev
feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词
2025-10-09 10:50:08 +08:00
ImFurina
aa3f9d2ca8 feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词 2025-10-02 12:25:04 +08:00
Yang Chyi-Jen
e64a5ba1bc fix: adjust minimum window size 2025-10-02 11:00:05 +08:00
底层用户
00e6f7bb60 Merge pull request #472 from MoeFurina/dev
fix(getOnlineUrl): 修复网页端登录后获取url的CORS问题
2025-09-25 13:35:15 +08:00
ImFurina
883ef05ab4 fix(getOnlineUrl): 修复网页端登录后获取url的CORS问题 2025-09-24 15:32:30 +08:00
底层用户
d0b5eb3371 Merge pull request #452 from serious-snow/dev
fix: 修复 macos 和linux 本地歌曲路径问题
2025-09-23 18:13:26 +08:00
底层用户
590ef96aa7 Merge pull request #469 from Pissofdvpe/dev-fix
fix:修复歌词显示相关
2025-09-23 18:10:16 +08:00
Pissofdvpe
172ca5a2f3 Update lyric.html
feat:优化调整桌面歌词字体大小设置
2025-09-21 19:26:22 +08:00
Pissofdvpe
6e1e56c1bd Update lyric.html
feat:优化桌面歌词字体调整
2025-09-21 19:18:46 +08:00
Pissofdvpe
105fed4bd0 Update LyricsSetting.vue
feat:优化调整歌词大小设置
2025-09-21 00:38:10 +08:00
Pissofdvpe
8bd3dc56d8 Update MainLyric.vue
fix:修复播放页面歌词设置在居中和居右状态下不能自动换行
2025-09-21 00:32:02 +08:00
底层用户
8e88bf64b1 🐞 fix: 修复类型错误 2025-09-15 12:06:50 +08:00
底层用户
244a832c52 🔧 build: 更新依赖 2025-09-15 11:45:02 +08:00
底层用户
146af3aeba 切换上游 api 库 #459 2025-09-15 11:22:55 +08:00
wangjian
143e8e29d7 fix: 修复 编辑歌曲信息弹窗无法复制(路径和md5) 2025-08-21 13:54:30 +08:00
wangjian
c702e6e01a fix: 修复 macos 和linux 本地歌曲路径问题
- 在 macos 和 linux 读取本地文件需要添加 file:// 前缀
- windows 也支持 file:// 前缀
- 在创建播放器时,为本地歌曲路径添加 file:// 前缀
- 在修改歌曲封面时,为本地路径添加 file:// 前缀
- 在保存元数据时,移除 file:// 前缀以保持兼容性
2025-08-21 09:37:25 +08:00
底层用户
b2ddb9f4e2 feat: 优化随机播放问题 & 播放页字体抖动效果优化 2025-08-20 11:35:07 +08:00
底层用户
201186bab2 🐞 fix: 修复同时请求问题 #448 2025-08-20 10:35:43 +08:00
底层用户
bfcd59daca Merge pull request #417 from serious-snow/dev
feat(Setting): 更新音质选项并添加新选择
2025-05-23 09:12:17 +08:00
wangjian
d5c3843c3f feat(Setting): 更新音质选项并添加新选择
- 参考手机端,更新音质说明文案
- 增加 jyeffect、vivid、dolby 音质
2025-05-22 10:55:17 +08:00
imsyy
d3f307eac5 🐞 fix: 修复登录错误 2025-04-19 22:56:33 +08:00
imsyy
675a52b8d1 🎈 perf: 优化 cookie 登录体验 2025-04-19 00:31:41 +08:00
imsyy
aee90e9c4e 🔧 build: 更新依赖 2025-04-18 23:34:22 +08:00
底层用户
eb39b81d8d Merge pull request #375 from xiaoQQya/dev
Docker 镜像内置 UnblockNeteaseMusic,  支持播放部分无版权歌曲
2025-03-18 14:56:37 +08:00
xiaoQQya
436df47104 feat: Docker 镜像内置 UnblockNeteaseMusic, 支持播放部分无版权歌曲 2025-03-15 11:28:12 +08:00
imsyy
e04e5e34c6 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2025-01-17 17:03:33 +08:00
imsyy
b57d685c03 🔧 build: 更新依赖 2025-01-17 17:03:16 +08:00
底层用户
6684172592 Merge pull request #333 from serious-snow/dev
fix: 更新日志中的超链接跳转由新窗口打开
2025-01-08 11:33:21 +08:00
wangjian
0257e74ff0 fix: 更新日志中的超链接跳转由新窗口打开
- 在 AboutSetting 组件中添加了 jumpLink 函数,实现更新日志中链接的点击跳转
- 修复 CoverList 组件移入移出会出现黑角的bug
2025-01-08 10:59:10 +08:00
imsyy
16c8865651 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-12-27 10:25:39 +08:00
imsyy
1a61aa2458 🐞 fix: 修复网页端跨域 #317 2024-12-27 10:25:01 +08:00
底层用户
e543f07d8e Merge pull request #307 from 239144498/patch-1
Update idMeta.json
2024-12-24 18:22:30 +08:00
imsyy
96a0495a88 🐞 fix: 修复本地目录无法读取 #315 2024-12-23 11:21:22 +08:00
imsyy
02befcd8a4 🐳 chore: 修复工作流 2024-12-12 14:04:30 +08:00
imsyy
15b500806a feat: 完善自动登录 2024-12-12 11:37:29 +08:00
imsyy
9accf5d27d 🐞 fix: 修复音量调节 2024-12-11 17:57:56 +08:00
imsyy
191ab29a44 feat: 完善部分配置及页面 2024-12-11 17:12:59 +08:00
imsyy
a4d4cd5f70 feat: 支持软件内登录 2024-12-11 10:38:15 +08:00
imsyy
3b07f7346f feat: 新增副歌时间展示 2024-12-10 17:45:12 +08:00
Naihe
1edceeebdd Update idMeta.json
增加会员雷达,里面的每日推荐歌曲质量还不错
https://music.163.com/#/playlist?id=8402996200
2024-12-09 13:23:10 +08:00
imsyy
fbf261f80b Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-12-02 16:02:13 +08:00
imsyy
432fa18299 🌈 style: 修改部分播放样式 #290 2024-12-02 16:02:02 +08:00
底层用户
b2dcec840b Merge pull request #301 from serious-snow/dev
refactor(music): 优化音乐播放逻辑和数据处理
2024-12-02 16:00:11 +08:00
imsyy
6f93d65f02 🔧 build: change version 2024-12-02 11:48:18 +08:00
imsyy
852b901353 🐞 fix: 修复歌单歌曲数量异常 #303 2024-12-02 11:38:21 +08:00
imsyy
71a282157d 🐞 fix: 修复下载路径分隔符 #302 2024-12-02 10:04:06 +08:00
wangjian
49f462cdf5 refactor(music): 优化音乐播放逻辑和数据处理
-改进 setNextPlaySong 方法,处理空播放列表的情况
-优化重复歌曲移除逻辑,确保插入位置正确
- 调整音乐数据重置方式,使用对象展开运算符
-增加播放器加载状态检查,避免未准备好时操作
-改进歌曲播放逻辑,处理添加到播放列表后立即播放的情况- 优化播放索引切换,考虑当前播放状态
-调整播放列表移除歌曲逻辑,优化用户体验
- 将初始 playIndex 设置为 -1,表示未播放状态
2024-11-29 14:28:22 +08:00
imsyy
94c0ca70e1 🐞 fix: 修复 snap 无法正常启动 #299 2024-11-27 14:30:24 +08:00
imsyy
813637762c 🔧 build: update dependency 2024-11-05 10:33:09 +08:00
imsyy
eb39b85a8b 🐞 fix: 修复启动失败 2024-11-05 09:49:39 +08:00
底层用户
201fd8d687 Merge pull request #288 from jcfun/dev
fix🐛: 修复了歌词翻译和音译开关不生效的bug
2024-11-04 18:17:53 +08:00
底层用户
02116d8f0f Merge branch 'dev' into dev 2024-11-04 18:16:49 +08:00
jcfun
551a190edf fix🐛: 修复了歌词翻译和音译开关不生效的bug 2024-11-04 12:29:27 +08:00
底层用户
f5015a4028 Merge pull request #279 from FrzMtrsprt/lyric-pos
🦄 refactor: 初始化桌面歌词至屏幕下方
2024-10-21 09:09:33 +08:00
FrzMtrsprt
ea55616bd6 🦄 refactor: 初始化桌面歌词至屏幕下方 2024-10-20 17:17:31 -04:00
imsyy
2b8eb93404 🐞 fix: fix some bugs #275 2024-10-18 15:50:24 +08:00
imsyy
18113d94e9 🐞 fix: 修复 UID 登录模式部分功能异常 2024-10-16 16:19:33 +08:00
imsyy
a8f01d5728 🐞 fix: UID 模式歌单歌曲获取数量过少 2024-10-15 10:51:07 +08:00
imsyy
3a6f8e7462 🐞 fix: 修复 Cookie 登录状态过期过快 2024-10-15 09:53:17 +08:00
imsyy
57325bfc46 feat: 支持 Cookie 登录 2024-10-14 16:18:22 +08:00
imsyy
a3e8931460 feat: 新增 UID 登录模式
- 修复未登录状态下动态封面报错 #274
- 修复海外手机号无法登录 #273
2024-10-14 15:05:14 +08:00
imsyy
2e0f448f66 🐞 fix: 修复音频播放问题 #240 2024-10-11 18:02:16 +08:00
imsyy
44cfbbf4e0 feat: 支持动态封面 2024-10-11 14:39:31 +08:00
imsyy
963343c39e 🔧 build: 修复构建 2024-10-10 17:55:28 +08:00
imsyy
a697799f48 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-10-09 17:54:10 +08:00
imsyy
7c59cc2ccb feat: 切换虚拟列表组件 2024-10-09 17:53:44 +08:00
底层用户
11acbcf7eb Merge pull request #269 from jcfun/dev
fix🐛: 修复了linux6.1内核下硬件加速被错误关闭的bug
2024-10-06 23:48:35 +08:00
imsyy
c35ede2158 🔧 build: 更新依赖 2024-10-05 11:29:33 +08:00
imsyy
dc480459eb 🐞 fix: 同步上游接口以解决登录风控问题 #270 2024-10-05 11:13:25 +08:00
jcfun
c100bfed8d fix🐛: 修复了linux6.1内核下硬件加速被错误关闭的bug 2024-10-03 12:28:23 +08:00
imsyy
2b48713565 feat: 搜索栏跳转完善 #262 2024-09-29 11:52:24 +08:00
imsyy
528f9b0aa6 🐞 fix: 修复快捷键错误 #264 2024-09-29 10:55:21 +08:00
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
imsyy
d471e686b5 fix: 完善更新流程 2023-11-30 17:59:13 +08:00
imsyy
41c4342f76 fix: 修复主进程执行顺序 #93 2023-11-30 15:43:05 +08:00
imsyy
16802aaac7 feat: 解灰支持酷我音源 #92 2023-11-30 15:02:51 +08:00
imsyy
740ffa32d6 fix: 修复构建配置 2023-11-29 17:34:19 +08:00
imsyy
dc119654a2 fix: 修复工作流 & builder 配置 2023-11-29 17:03:51 +08:00
imsyy
e82ffe8683 fix: 修复搜索框歌手页面跳转 2023-11-28 16:25:21 +08:00
imsyy
2e31578fb6 fix: workflows 2023-11-28 12:05:48 +08:00
imsyy
1fc8cd73c8 fix: 去除其他平台构建 2023-11-28 11:53:47 +08:00
imsyy
4c34d02666 fix: 尝试修复 workflows 2023-11-28 11:49:08 +08:00
imsyy
7eb63e888f fix: 尝试修复 workflows 2023-11-28 11:41:52 +08:00
imsyy
41837fdba7 fix: 尝试修复 workflows 2023-11-28 11:11:08 +08:00
imsyy
8d2aea1efc fix: Restore the original workflows 2023-11-28 10:34:44 +08:00
imsyy
571640bf7d feat: test new workflows 2023-11-28 10:30:31 +08:00
imsyy
4b3aeea5f4 feat: test new workflows 2023-11-28 10:16:20 +08:00
imsyy
02641ec4f1 feat: test new workflows 2023-11-28 10:12:21 +08:00
imsyy
7da55779b4 fix: improve build process 2023-11-28 09:38:11 +08:00
imsyy
5a98cc64a9 fix: improve build process 2023-11-28 09:18:40 +08:00
imsyy
e4e0af1d9d feat: 新增歌手页 & fix: 修复一些小问题 2023-11-27 18:23:01 +08:00
imsyy
b435b2152e feat: 新增歌手页 & fix: 修复一些小问题 2023-11-27 18:22:33 +08:00
imsyy
57763066a0 fix: router path 2023-11-24 11:11:59 +08:00
imsyy
dc8cd56f89 fix: router path 2023-11-24 11:08:13 +08:00
imsyy
9c024c18ea fix: Folder name 2023-11-24 11:05:12 +08:00
imsyy
74f503b6ec fix: Folder name 2023-11-24 10:50:02 +08:00
imsyy
3841a48119 fix: Folder name 2023-11-24 10:48:54 +08:00
imsyy
477de06585 add: GitHub Workflow 2023-11-24 10:25:50 +08:00
imsyy
479802df07 fix: 修复文件路径 2023-11-24 09:41:43 +08:00
imsyy
f4ea51c761 refactor: completely migrate to Electron 2023-11-23 18:28:53 +08:00
imsyy
9fa5935929 feat: 更新说明 2023-09-13 16:00:25 +08:00
imsyy
ab0fb19585 feat: 支持手机号登录 #67 2023-08-21 17:27:08 +08:00
imsyy
540b0c5855 feat: 更新说明 2023-08-08 10:03:19 +08:00
imsyy
b5ec3aaee3 feat: 底栏歌词切换时动画 2023-08-01 15:53:54 +08:00
imsyy
91d66801a9 fix: 单曲循环无法暂停 2023-07-19 11:24:15 +08:00
imsyy
73470ef643 feat: 新增一项设置 #58 2023-07-19 10:57:42 +08:00
imsyy
b5f193c731 fix: 私人 FM 加载动画 2023-07-17 09:36:39 +08:00
imsyy
f13eaf838f feat: 新增自定义主题 & fix: 修复提示触发位置 #60 2023-07-15 11:20:22 +08:00
imsyy
7f8dbbaaf8 style: 样式微调 2023-07-03 11:06:07 +08:00
imsyy
846691f789 fix: 部分无信息歌曲导致异常 2023-06-14 17:57:29 +08:00
imsyy
17bb9f13b8 fix: 解决 Vercel 刷新 404 2023-06-14 11:44:55 +08:00
imsyy
261997a627 fix: 修复空元素异常 #52 2023-06-12 18:04:58 +08:00
imsyy
bf3675ce45 fix: 使消息同时只存在一个 2023-06-12 16:40:43 +08:00
imsyy
da2a924c7e feat: 播放模式弹窗 2023-06-05 18:26:33 +08:00
imsyy
ea245945fa fix: 移入移除喜欢列表提醒 2023-06-03 09:26:10 +08:00
imsyy
6ac8b948b2 fix: 滚动歌词无法居中 2023-06-01 09:41:43 +08:00
imsyy
995f087e1d fix: 兼容苹果端图标 #46 2023-05-31 15:27:12 +08:00
imsyy
021ace4a86 feat: 兼容解灰酷我音源 #45 2023-05-31 14:31:52 +08:00
imsyy
2c2301d8af feat: 兼容解灰酷我音源 2023-05-31 14:18:21 +08:00
567 changed files with 46935 additions and 24744 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

18
.editorconfig Normal file
View File

@@ -0,0 +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

31
.env
View File

@@ -1,31 +0,0 @@
# 全局 API 地址
## 需部署 API详见 https://github.com/Binaryify/NeteaseCloudMusicApi
VITE_MUSIC_API = "https://api-music.imsyy.top/"
# 网易云解灰 API 地址(可选功能)
## 需部署 API详见 https://github.com/imsyy/UNM-Server#%E8%BF%90%E8%A1%8C
VITE_UNM_API = "https://api-unm.imsyy.top/"
# 站点标题
VITE_SITE_TITLE = "SPlayer"
VITE_SITE_ANTHOR = "無名"
VITE_SITE_KEYWORDS = "SPlayer,云音乐,播放器,在线音乐,在线播放器,音乐播放器,在线音乐播放器"
VITE_SITE_DES = "一个简约的在线音乐播放器具有音乐搜索、播放、每日推荐、私人FM、歌词显示、歌曲评论、网易云登录与云盘等功能"
VITE_SITE_URL = "imsyy.top"
VITE_SITE_LOGO = "/images/logo/favicon.svg"
# 百度统计(若不需要,请设为空即可)
VITE_SITE_BAIDUTONGJI = "c6579e9a33cbc5260fc90231678556ec"
# ICP 备案号
## 若不需要,请设为空即可
VITE_ICP = "豫ICP备2022018134号-1"
# 公告配置
## 若无需公告,请将任意一项设为空即可
## 公告标题
VITE_ANN_TITLE = ""
## 公告内容
VITE_ANN_CONTENT = ""
## 公告时长(毫秒)不可超过 999999
VITE_ANN_DURATION = 3000

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

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

@@ -0,0 +1,304 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": 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,
"onWatcherCleanup": 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,
"useId": 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,
"useModel": 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,
"useTemplateRef": 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
}
}

75
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
labels: [bug]
body:
- type: markdown
attributes:
value: |
---
在此之前,我们默认你已经知道该如何提问。简而言之,**你要精确地描述问题并提供充足的信息**
此外,如果需要,你可以使用 <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> 打开开发者工具为我们提供信息
- type: checkboxes
validations:
required: false
attributes:
label: "检查清单"
description: |
我们需要了解一些信息,你需要检查下面的检查项 <br />
**这里并不是每一个检查项都必要,根据你的真实情况勾选即可**
options:
- label: "我已检索仓库中所有的 Issues确保我**没有重复提交问题**;或有相似 Issue但我觉得我的情况不包含在那个相似 Issue 之内"
- label: "我已经找到了可以复现这个问题的方法,并且写在了下面的「具体信息」中"
- label: "此问题可以在我的设备和当前环境中**稳定复现**"
- label: "此问题可以在最新版本 (Latest Release) 中复现"
- label: "此问题是在我更新到当前版本后**才**出现的"
- type: dropdown
validations:
required: true
attributes:
label: "是网页端还是客户端"
options:
- "客户端"
- "网页端"
default: 0
- type: input
validations:
required: true
attributes:
label: "当前系统环境"
placeholder: "如Windows 11"
- type: input
validations:
required: false
attributes:
label: "当前 Node.js 及 npm 版本"
placeholder: "如v18.16.0 / v9.6.7 (选填)"
- type: input
validations:
required: true
attributes:
label: "当前版本"
description: |
填写关于软件里的或 Releases 中版本号即可 <br />
如果是自行构建或从 GitHub Actions 下载的开发版,还需要提供 Commit ID
placeholder: "如v1.0.0"
- type: textarea
id: other
attributes:
label: "具体信息"
description: |
请填写完整的复现步骤和遇到的问题,还请尽量提供所有可能的信息,提供包括但不限于:
- 分步的复现步骤,可以使用 `1. xxx` (换行) `2. xxx` 的格式
- 截图(如果结合复现步骤还是无法详细表述,甚至可以录屏)
- 开发者工具中的:控制台输出报错、网络请求等
- 出现问题的在线歌曲链接、出现问题的文件(本地歌曲、歌词等)的下载链接
等信息,以更好地帮助我们解决你的问题
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 发起者均已了解此处的内容

67
.github/workflows/dev.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
# Dev 分支推送部署预览
## 部署 Windows x64 和 ARM64 版本
name: Build Dev
on:
push:
branches:
- dev
env:
NODE_VERSION: 22.x
PNPM_VERSION: 8
jobs:
# Windows x64 架构
build-win:
name: Build Electron App for Windows
runs-on: windows-latest
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 设置 pnpm 版本
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
# 复制环境变量文件
- 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: pnpm install
# 清理旧的构建产物
- name: Clean dist folder
run: |
if (Test-Path dist) {
Remove-Item -Recurse -Force dist
}
# 构建 Electron App (x64)
- name: Build Electron App for Windows x64
# 仅 x64
run: pnpm run build:win -- --arch=x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 清理不必要的构建产物(保留 .exe 和 .blockmap 文件)
- name: Cleanup Artifacts
run: npx del-cli "dist/**/*.yaml" "dist/**/*.yml"
# 上传构建产物(仅上传 x64 架构的 .exe 文件)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: SPlayer-dev
path: |
dist/**.exe

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 }}

169
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,169 @@
name: Build & Release
on:
push:
tags:
- v* # 只在 tag v* 时触发
workflow_dispatch:
env:
NODE_VERSION: 22.x
PNPM_VERSION: 8
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
jobs:
# ===================================================================
# 并行构建所有平台和架构
# ===================================================================
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
# 矩阵策略
# 即使一个矩阵任务失败,其他任务也会继续运行
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
# 开始步骤
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 设置 pnpm 版本
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
# 安装 Node.js
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
# 清理旧的构建产物
- name: Clean workspace on Windows
if: runner.os == 'Windows'
run: |
Write-Host "🧹 Cleaning workspace, node_modules, and Electron caches..."
if (Test-Path dist) { Remove-Item -Recurse -Force dist }
if (Test-Path out) { Remove-Item -Recurse -Force out }
if (Test-Path node_modules) { Remove-Item -Recurse -Force node_modules }
if (Test-Path "$env:LOCALAPPDATA\electron-builder") {
Remove-Item "$env:LOCALAPPDATA\electron-builder" -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path "$env:LOCALAPPDATA\electron") {
Remove-Item "$env:LOCALAPPDATA\electron" -Recurse -Force -ErrorAction SilentlyContinue
}
- name: Clean workspace on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux'
run: |
echo "🧹 Cleaning workspace, node_modules, and Electron caches..."
rm -rf dist out node_modules ~/.cache/electron-builder ~/.cache/electron
# 安装项目依赖
- name: Install dependencies
run: pnpm install
# 复制环境变量文件
- name: Copy .env file on Windows
if: runner.os == 'Windows'
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
- name: Copy .env file on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux'
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 更新 Ubuntu 软件源
- name: Ubuntu Update with sudo
if: runner.os == 'Linux'
run: sudo apt-get update
# 安装依赖
- name: Install RPM & Pacman
if: runner.os == 'Linux'
run: |
sudo apt-get install --no-install-recommends -y rpm &&
sudo apt-get install --no-install-recommends -y libarchive-tools &&
sudo apt-get install --no-install-recommends -y libopenjp2-tools
# 安裝 Snapcraft
- name: Install Snapcraft
if: runner.os == 'Linux'
uses: samuelmeuli/action-snapcraft@v2
# 构建 Electron App
- name: Build Windows x64 & ARM64 App
if: runner.os == 'Windows'
run: pnpm build:win || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build macOS Universal App
if: runner.os == 'macOS'
run: pnpm build:mac || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Linux x64 & ARM64 App
if: runner.os == 'Linux'
run: pnpm build:linux || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 上传 Snap 包到 Snapcraft 商店
- name: Publish Snap to Snap Store
if: runner.os == 'Linux'
run: snapcraft upload dist/*.snap --release stable
continue-on-error: true
# 合并所有构建
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: SPlayer-${{ runner.os }}
path: dist/*.*
# ===================================================================
# 收集并发布 Release
# ===================================================================
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
# 需要写入权限
permissions:
contents: write
steps:
# 将所有产物下载到 artifacts 文件夹
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
# 创建 GitHub Release 并上传所有产物
- name: Create GitHub Release and Upload Assets
uses: softprops/action-gh-release@v2
continue-on-error: true
with:
token: ${{ secrets.GITHUB_TOKEN }}
# 自动生成 Release 说明
generate_release_notes: true
# 发布为草稿
draft: false
# 发布为预发布
prerelease: false
# 全部上传
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.rpm
artifacts/**/*.pacman
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap

10
.gitignore vendored
View File

@@ -13,12 +13,14 @@ dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
out
.env
auto-imports.d.ts
components.d.ts
# Editor directories and files
.vscode/*
.vscode
!.vscode/extensions.json
.idea
*.suo
@@ -26,3 +28,5 @@ coverage
*.njsproj
*.sln
*.sw?
.env.development
.env.production

27
.hintrc
View File

@@ -1,27 +0,0 @@
{
"extends": [
"development"
],
"hints": {
"detect-css-reflows/composite": "off",
"detect-css-reflows/layout": "off",
"detect-css-reflows/paint": "off",
"compat-api/css": [
"default",
{
"ignore": [
"backdrop-filter"
]
}
],
"compat-api/html": [
"default",
{
"ignore": [
"meta[name=theme-color]"
]
}
],
"apple-touch-icons": "off"
}
}

5
.npmrc Normal file
View File

@@ -0,0 +1,5 @@
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

8
.prettierignore Normal file
View File

@@ -0,0 +1,8 @@
out
dist
pnpm-lock.yaml
LICENSE.md
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
}

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# 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
COPY --from=builder /app/docker-entrypoint.sh /docker-entrypoint.sh
RUN apk add --no-cache npm python3 youtube-dl \
&& npm install -g @unblockneteasemusic/server NeteaseCloudMusicApi \
&& wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \
&& chmod +x /usr/local/bin/yt-dlp \
&& chmod +x /docker-entrypoint.sh
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["npx", "NeteaseCloudMusicApi"]

674
LICENSE
View File

@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2023 imsyy
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.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
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.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
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.
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.
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.
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.
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.
TERMS AND CONDITIONS
0. Definitions.
"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.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
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. 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 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 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 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 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 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 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.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
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 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 Affero General Public License for more details.
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 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 AGPL, see
<http://www.gnu.org/licenses/>.

281
README.md
View File

@@ -1,17 +1,42 @@
<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 />
# SPlayer
> A simple music player
![Stars](https://img.shields.io/github/stars/imsyy/SPlayer?style=flat)
![Version](https://img.shields.io/github/v/release/imsyy/SPlayer)
[![Build Release](https://github.com/imsyy/SPlayer/actions/workflows/release.yml/badge.svg)](https://github.com/imsyy/SPlayer/actions/workflows/release.yml)
![License](https://img.shields.io/github/license/imsyy/SPlayer)
![Issues](https://img.shields.io/github/issues/imsyy/SPlayer)
![main](/screenshots/SPlayer.jpg)
## 说明
- 本项目采用 [Vue 3](https://cn.vuejs.org/) 全家桶和 [Naïve UI](https://www.naiveui.com/) 组件库及 `SCSS` 开发
- 目前主要以 `Web` 端为主,可能暂时不会考虑使用 `Electron` 构建客户端
> [!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` 哦 😍
> 请注意,本程序不打算开发移动端,也不会对移动端进行完美适配,仅保证基础可用性
- 欢迎各位大佬 `Star` 😍
## 💬 交流群
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw" target="_blank">
![交流群](/screenshots/welcome.png)
</a>
## 👀 Demo
@@ -19,124 +44,198 @@
## 🎉 功能
- 支持扫码登录
- 支持手机号登录(上游接口暂时无法使用)
- 自动进行每日签到及云贝签到
- 支持 [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server),自动替换变灰歌曲
- 由于酷我音源不支持 `https`,故网页端替换可能不全面
- 下载歌曲(最高支持 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
> 开发中,仅供参考
<details>
<summary>主页面</summary>
![主页面](/screenshots/SPlayer%20-%20%E4%B8%BB%E9%A1%B5%E9%9D%A2.png)
![主页面](/screenshots/SPlayer%20-%20主页面.jpg)
</details>
<details>
<summary>播放页面</summary>
![播放页面](/screenshots/SPlayer%20-%20%E6%92%AD%E6%94%BE%E9%A1%B5%E9%9D%A2.png)
![播放页面](/screenshots/SPlayer%20-%20播放页面.jpg)
</details>
<details>
<summary>发现页面</summary>
![发现页面](/screenshots/SPlayer%20-%20%E5%8F%91%E7%8E%B0%E9%A1%B5%E9%9D%A2.png)
![发现页面](/screenshots/SPlayer%20-%20发现页面.jpg)
</details>
<details>
<summary>歌单页面</summary>
![歌单页面](/screenshots/SPlayer%20-%20%E6%AD%8C%E5%8D%95%E9%A1%B5%E9%9D%A2.png)
![发现页面](/screenshots/SPlayer%20-%20歌单页面.jpg)
</details>
<details>
<summary>评论页面</summary>
![评论页面](/screenshots/SPlayer%20-%20%E8%AF%84%E8%AE%BA%E9%A1%B5%E9%9D%A2.png)
![发现页面](/screenshots/SPlayer%20-%20评论页面.jpg)
</details>
## ⚙️ 部署
<details>
<summary>本地音乐</summary>
> Vercel 等托管平台可在 Fork 后一键导入并自动部署
![发现页面](/screenshots/SPlayer%20-%20本地音乐.jpg)
### API 服务(必需)
</details>
> 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目
## 📦️ 获取
- 请在根目录下的 `.env` 文件中的 `VITE_MUSIC_API` 中填入 API 地址(必需)
### 稳定版
```js
VITE_MUSIC_API = "your api url";
```
通常情况下,可以在 [Releases](https://github.com/imsyy/SPlayer/releases) 中获取稳定版
### 网易云解灰 API可选
### 开发版
如需使用网易云解灰服务,请前往 [UNM-Server](https://github.com/imsyy/UNM-Server) 部署在线 API 服务并将 `API` 地址填入 `.env` 环境变量中,该服务用于网页端替换无法播放或无版权的歌曲。如不需要该服务,请前往站点的 `全局设置` 中关闭
可以通过 `GitHub Actions` 工作流获取最新的开发版,目前开发版仅提供 `Win` 版本
### 安装依赖
[Dev Workflow](https://github.com/imsyy/SPlayer/actions/workflows/build.yml)
## Snap Store
[![Get it from the Snap Store](https://snapcraft.io/en/dark/install.svg)](https://snapcraft.io/splayer)
## ⚙️ Docker 部署
> 安装及配置 `Docker` 将不在此处说明,请自行解决
### 本地构建
> 请尽量拉取最新分支后使用本地构建方式,在线部署的仓库可能更新不及时
```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 部署
构建完成后可将生成的 `dist` 文件夹内的文件上传至服务器
> 其他部署平台大致相同,在此不做说明
1. 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目,并成功取得在线访问地址
2. 点击本仓库右上角的 `Fork`,复制本仓库到你的 `GitHub` 账号
3. 复制 `/.env.example` 文件并重命名为 `/.env`
4.`.env` 文件中的 `VITE_API_URL` 改为第一步得到的 API 地址
```js
VITE_API_URL = "https://example.com";
```
5. 将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
![build](/screenshots/build.jpg)
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 |
## 😘 鸣谢
@@ -145,18 +244,32 @@ npm build
- [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)
## 📜 开源许可
- **本项目仅供个人学习研究使用,禁止用于商业及非法用途**
- 本项目基于 [MIT license](https://opensource.org/license/mit/) 许可进行开源
- [refined-now-playing-netease](https://github.com/solstice23/refined-now-playing-netease)
- [material-color-utilities](https://github.com/material-foundation/material-color-utilities)
## 📢 免责声明
本项目使用了网易云音乐的第三方 API 服务,**仅供个人学习研究使用,禁止用于商业及非法用途** 本项目旨在提供一个前端练手的实战项目,用于帮助开发者提升技能水平和对前端技术的理解
本项目部分功能使用了网易云音乐的第三方 API 服务,**仅供个人学习研究使用,禁止用于商业及非法用途**
同时,本项目开发者承诺 **严格遵守相关法律法规和网易云音乐 API 使用协议,不会利用本项目进行任何违法活动。** 如因使用本项目而引起的任何纠纷或责任,均由使用者自行承担。**本项目开发者不承担任何因使用本项目而导致的任何直接或间接责任,并保留追究使用者违法行为的权利**
请使用者在使用本项目时遵守相关法律法规,**不要将本项目用于任何商业及非法用途。如有违反,一切后果由使用者自负。** 同时,使用者应该自行承担因使用本项目而带来的风险和责任。本项目开发者不对本项目所提供的服务和内容做出任何保证
感谢您的理解
## 📜 开源许可
- **本项目仅供个人学习研究使用,禁止用于商业及非法用途**
- 本项目基于 [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)

315
auto-eslint.mjs Normal file
View File

@@ -0,0 +1,315 @@
export default {
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ShallowRef": true,
"Slot": true,
"Slots": 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,
"createRef": 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,
"getCurrentWatcher": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"isShallow": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onElementRemoval": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": 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,
"useCountdown": 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,
"useId": 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,
"useModel": 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,
"usePreferredReducedTransparency": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useSSRWidth": 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,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeAgoIntl": 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
}
}

309
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,309 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
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 createRef: typeof import('@vueuse/core').createRef
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 getCurrentWatcher: typeof import('vue').getCurrentWatcher
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 isShallow: typeof import('vue').isShallow
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 onElementRemoval: typeof import('@vueuse/core').onElementRemoval
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 onWatcherCleanup: typeof import('vue').onWatcherCleanup
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 useCountdown: typeof import('@vueuse/core').useCountdown
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 useId: typeof import('vue').useId
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 useModel: typeof import('vue').useModel
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 usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
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 useSSRWidth: typeof import('@vueuse/core').useSSRWidth
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 useTemplateRef: typeof import('vue').useTemplateRef
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 useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
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, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

157
components.d.ts vendored Normal file
View File

@@ -0,0 +1,157 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AboutSetting: typeof import('./src/components/Setting/AboutSetting.vue')['default']
ArtistList: typeof import('./src/components/List/ArtistList.vue')['default']
AutoClose: typeof import('./src/components/Modal/AutoClose.vue')['default']
BatchList: typeof import('./src/components/Modal/BatchList.vue')['default']
ChangeRate: typeof import('./src/components/Modal/ChangeRate.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']
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']
Equalizer: typeof import('./src/components/Modal/Equalizer.vue')['default']
ExcludeLyrics: typeof import('./src/components/Modal/ExcludeLyrics.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/Login.vue')['default']
LoginCookie: typeof import('./src/components/Modal/Login/LoginCookie.vue')['default']
LoginPhone: typeof import('./src/components/Modal/Login/LoginPhone.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/Login/LoginQRCode.vue')['default']
LoginUID: typeof import('./src/components/Modal/Login/LoginUID.vue')['default']
LyricMenu: typeof import('./src/components/Player/LyricMenu.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']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
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']
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']
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
NOl: typeof import('naive-ui')['NOl']
NP: typeof import('naive-ui')['NP']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
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']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
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']
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']
PlayerRightMenu: typeof import('./src/components/Player/PlayerRightMenu.vue')['default']
PlayerSlider: typeof import('./src/components/Player/PlayerSlider.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']
SearchDefault: typeof import('./src/components/Search/SearchDefault.vue')['default']
SearchInp: typeof import('./src/components/Search/SearchInp.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']
SImage: typeof import('./src/components/UI/s-image.vue')['default']
SongCard: typeof import('./src/components/Card/SongCard.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']
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
SongUnlockManager: typeof import('./src/components/Modal/SongUnlockManager.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.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"

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
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
environment:
# 所有变量都不是必填项
# 网易云服务端 IP, 可在宿主机通过 ping music.163.com 获得
- NETEASE_SERVER_IP=220.197.30.65
# UnblockNeteaseMusic 使用的音源, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E9%9F%B3%E6%BA%90%E6%B8%85%E5%8D%95
- UNBLOCK_SOURCES=kugou kuwo bilibili
# 可添加 UnblockNeteaseMusic 支持的任何环境变量, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F
- ENABLE_FLAC=false
- ENABLE_HTTPDNS=false
- BLOCK_ADS=true
- DISABLE_UPGRADE_CHECK=false
- DEVELOPMENT=false
- FOLLOW_SOURCE_ORDER=true
- JSON_LOG=false
- NO_CACHE=false
- SELECT_MAX_BR=true
- LOG_LEVEL=info
- SEARCH_ALBUM=true

29
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
set -e
# start unblock service in the background
npx unblockneteasemusic -p 80:443 -s -f ${NETEASE_SERVER_IP:-220.197.30.65} -o ${UNBLOCK_SOURCES:-kugou bodian pyncmd} 2>&1 &
# point the neteasemusic address to the unblock service
if ! grep -q "music.163.com" /etc/hosts; then
echo "127.0.0.1 music.163.com" >> /etc/hosts
fi
if ! grep -q "interface.music.163.com" /etc/hosts; then
echo "127.0.0.1 interface.music.163.com" >> /etc/hosts
fi
if ! grep -q "interface3.music.163.com" /etc/hosts; then
echo "127.0.0.1 interface3.music.163.com" >> /etc/hosts
fi
if ! grep -q "interface.music.163.com.163jiasu.com" /etc/hosts; then
echo "127.0.0.1 interface.music.163.com.163jiasu.com" >> /etc/hosts
fi
if ! grep -q "interface3.music.163.com.163jiasu.com" /etc/hosts; then
echo "127.0.0.1 interface3.music.163.com.163jiasu.com" >> /etc/hosts
fi
# start the nginx daemon
nginx
# start the main process
exec "$@"

173
electron-builder.config.ts Normal file
View File

@@ -0,0 +1,173 @@
import type { Configuration } from "electron-builder";
const config: Configuration = {
// 应用程序的唯一标识符
appId: "com.imsyy.splayer",
// 应用程序的产品名称
productName: "SPlayer",
copyright: "Copyright © imsyy 2023",
// 构建资源所在的目录
directories: {
buildResources: "build",
},
// 包含在最终应用程序构建中的文件列表
// 使用通配符 ! 表示排除不需要的文件
files: [
"public/**",
"out/**",
"!**/.vscode/*",
"!src/*",
"!electron.vite.config.{js,ts,mjs,cjs}",
"!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}",
"!{.env,.env.*,.npmrc,pnpm-lock.yaml}",
],
// 哪些文件将不会被压缩,而是解压到构建目录
asarUnpack: ["public/**"],
win: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/logo.ico",
// Windows 平台全局文件名模板
artifactName: "${productName}-${version}-${arch}.${ext}",
// 是否对可执行文件进行签名和编辑
// signAndEditExecutable: false,
// 构建类型(架构由命令行参数 --x64 或 --arm64 指定)
target: [
// 安装版
{
target: "nsis",
arch: ["x64", "arm64"],
},
// 打包版
{
target: "portable",
arch: ["x64", "arm64"],
},
],
},
// NSIS 安装器配置
nsis: {
// 是否一键式安装
oneClick: false,
// 安装程序的生成名称
artifactName: "${productName}-${version}-${arch}-setup.${ext}",
// 创建的桌面快捷方式名称
shortcutName: "${productName}",
// 卸载时显示的名称
uninstallDisplayName: "${productName}",
// 创建桌面图标
createDesktopShortcut: "always",
// 是否允许 UAC 提升权限
allowElevation: true,
// 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true,
// 安装包图标
installerIcon: "public/icons/favicon.ico",
// 卸载命令图标
uninstallerIcon: "public/icons/favicon.ico",
},
// Portable 便携版配置
portable: {
// 便携版文件名
artifactName: "${productName}-${version}-${arch}-portable.${ext}",
},
// macOS 平台配置
mac: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/favicon-512x512.png",
// 权限继承的文件路径
entitlementsInherit: "build/entitlements.mac.plist",
// macOS 平台全局文件名模板
artifactName: "${productName}-${version}-${arch}.${ext}",
// 扩展信息,如权限描述
extendInfo: {
NSCameraUsageDescription: "Application requests access to the device's camera.",
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
NSDocumentsFolderUsageDescription:
"Application requests access to the user's Documents folder.",
NSDownloadsFolderUsageDescription:
"Application requests access to the user's Downloads folder.",
},
// 是否启用应用程序的 Notarization苹果的安全审核
notarize: false,
darkModeSupport: true,
category: "public.app-category.music",
target: [
// DMG 安装版
{
target: "dmg",
arch: ["x64", "arm64"],
},
// 压缩包安装版
{
target: "zip",
arch: ["x64", "arm64"],
},
],
},
// Linux 平台配置
linux: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/favicon-512x512.png",
// Linux 所有格式的统一文件名模板
artifactName: "${name}-${version}-${arch}.${ext}",
// 构建类型 - 支持 x64 和 ARM64 架构
target: [
// Pacman 包管理器
{
target: "pacman",
arch: ["x64", "arm64"],
},
// AppImage 格式
{
target: "AppImage",
arch: ["x64", "arm64"],
},
// Debian 包管理器
{
target: "deb",
arch: ["x64", "arm64"],
},
// RPM 包管理器
{
target: "rpm",
arch: ["x64", "arm64"],
},
// Snap 包管理器(仅支持 x64 架构)
{
target: "snap",
arch: ["x64"],
},
// 压缩包格式
{
target: "tar.gz",
arch: ["x64", "arm64"],
},
],
// 维护者信息
maintainer: "imsyy.top",
// 应用程序类别
category: "Audio;Music",
},
// AppImage 特定配置
appImage: {
// AppImage 文件的生成名称
artifactName: "${name}-${version}-${arch}.${ext}",
},
// 是否在构建之前重新编译原生模块
npmRebuild: false,
// Electron 下载镜像配置
electronDownload: {
mirror: "https://npmmirror.com/mirrors/electron/",
},
// 发布配置
// 先留空,不自动上传
publish: [],
};
export default config;

122
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,122 @@
import { resolve } from "path";
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 VueDevTools from "vite-plugin-vue-devtools";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ command, mode }) => {
// 读取环境变量
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: {
plugins: [externalizeDepsPlugin()],
build: {
publicDir: resolve(__dirname, "public"),
rollupOptions: {
input: {
index: resolve(__dirname, "electron/main/index.ts"),
lyric: resolve(__dirname, "web/lyric.html"),
},
},
},
},
// 预加载
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.ts"),
},
},
},
},
// 渲染进程
renderer: {
root: ".",
plugins: [
vue(),
// mode === "development" && VueDevTools(),
AutoImport({
imports: [
"vue",
"vue-router",
"@vueuse/core",
{
"naive-ui": ["useDialog", "useMessage", "useNotification", "useLoadingBar"],
},
],
eslintrc: {
enabled: true,
filepath: "./auto-eslint.mjs",
},
}),
Components({
resolvers: [NaiveUiResolver()],
}),
viteCompression(),
wasm(),
],
resolve: {
alias: {
"@": resolve(__dirname, "src/"),
},
},
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ["legacy-js-api"],
},
},
},
server: {
port: webPort,
// 代理
proxy: {
"/api": {
target: `http://127.0.0.1:${servePort}`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "/api"),
},
},
},
preview: {
port: webPort,
},
build: {
minify: "terser",
publicDir: resolve(__dirname, "public"),
rollupOptions: {
input: {
index: resolve(__dirname, "index.html"),
loading: resolve(__dirname, "web/loading/index.html"),
},
output: {
manualChunks: {
stores: ["src/stores/data.ts", "src/stores/index.ts"],
},
},
},
terserOptions: {
compress: {
pure_funcs: ["console.log"],
},
},
sourcemap: false,
},
},
};
});

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

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

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

@@ -0,0 +1,86 @@
import { app, BrowserWindow } from "electron";
import { electronApp } from "@electron-toolkit/utils";
import { release, type } from "os";
import { isMac } from "./utils/config";
import { unregisterShortcuts } from "./shortcut";
import { initTray, MainTray } from "./tray";
import { processLog } from "./logger";
import initAppServer from "../server";
import { initSingleLock } from "./utils/single-lock";
import loadWindow from "./windows/load-window";
import mainWindow from "./windows/main-window";
import initIpc from "./ipc";
// 屏蔽报错
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 主进程
class MainProcess {
// 窗口
mainWindow: BrowserWindow | null = null;
loadWindow: BrowserWindow | null = null;
// 托盘
mainTray: MainTray | null = null;
// 是否退出
isQuit: boolean = false;
constructor() {
processLog.info("🚀 Main process startup");
// 程序单例锁
initSingleLock();
// 禁用 Windows 7 的 GPU 加速功能
if (release().startsWith("6.1") && type() == "Windows_NT") app.disableHardwareAcceleration();
// 监听应用事件
this.handleAppEvents();
// Electron 初始化完成后
// 某些API只有在此事件发生后才能使用
app.whenReady().then(async () => {
processLog.info("🚀 Application Process Startup");
// 设置应用程序名称
electronApp.setAppUserModelId("com.imsyy.splayer");
// 启动主服务进程
await initAppServer();
// 启动窗口
this.loadWindow = loadWindow.create();
this.mainWindow = mainWindow.create();
// 注册其他服务
this.mainTray = initTray(this.mainWindow!);
// 注册 IPC 通信
initIpc();
});
}
// 应用程序事件
handleAppEvents() {
// 窗口被关闭时
app.on("window-all-closed", () => {
if (!isMac) app.quit();
this.mainWindow = null;
this.loadWindow = null;
});
// 应用被激活
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
}
});
// 自定义协议
app.on("open-url", (_, url) => {
processLog.log("Received custom protocol URL:", url);
});
// 将要退出
app.on("will-quit", () => {
// 注销全部快捷键
unregisterShortcuts();
});
// 退出前
app.on("before-quit", () => {
this.isQuit = true;
});
}
}
export default new MainProcess();

View File

@@ -0,0 +1,27 @@
import initFileIpc from "./ipc-file";
import initLyricIpc from "./ipc-lyric";
import initShortcutIpc from "./ipc-shortcut";
import initStoreIpc from "./ipc-store";
import initSystemIpc from "./ipc-system";
import initThumbarIpc from "./ipc-thumbar";
import initTrayIpc from "./ipc-tray";
import initUpdateIpc from "./ipc-update";
import initWindowsIpc from "./ipc-window";
/**
* 初始化全部 IPC 通信
* @returns void
*/
const initIpc = (): void => {
initSystemIpc();
initWindowsIpc();
initUpdateIpc();
initFileIpc();
initTrayIpc();
initLyricIpc();
initStoreIpc();
initThumbarIpc();
initShortcutIpc();
};
export default initIpc;

View File

@@ -0,0 +1,422 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import { basename, isAbsolute, join, relative, resolve } from "path";
import { access, readFile, stat, unlink, writeFile } from "fs/promises";
import { parseFile } from "music-metadata";
import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { ipcLog } from "../logger";
import FastGlob from "fast-glob";
import { download } from "electron-dl";
/**
* 文件相关 IPC
*/
const initFileIpc = (): void => {
// 默认文件夹
ipcMain.handle(
"get-default-dir",
(_event, type: "documents" | "downloads" | "pictures" | "music" | "videos"): string => {
return app.getPath(type);
},
);
// 遍历音乐文件
ipcMain.handle("get-music-files", async (_, dirPath: string) => {
try {
// 规范化路径
const filePath = resolve(dirPath).replace(/\\/g, "/");
console.info(`📂 Fetching music files from: ${filePath}`);
// 查找指定目录下的所有音乐文件
const musicFiles = await FastGlob("**/*.{mp3,wav,flac,aac,webm}", { cwd: filePath });
// 解析元信息
const metadataPromises = musicFiles.map(async (file) => {
const filePath = join(dirPath, file);
// 处理元信息
const { common, format } = await parseFile(filePath);
// 获取文件大小
const { size } = await 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]?.text || "",
duration: (format?.duration ?? 0) * 1000,
size: (size / (1024 * 1024)).toFixed(2),
path: filePath,
quality,
};
});
const metadataArray = await Promise.all(metadataPromises);
return metadataArray;
} catch (error) {
ipcLog.error("❌ Error fetching music metadata:", error);
throw error;
}
});
// 获取音乐元信息
ipcMain.handle("get-music-metadata", async (_, path: string) => {
try {
const filePath = resolve(path).replace(/\\/g, "/");
const { common, format } = await parseFile(filePath);
return {
// 文件名称
fileName: basename(filePath),
// 文件大小
fileSize: (await stat(filePath)).size / (1024 * 1024),
// 元信息
common,
// 歌词
lyric:
metaDataLyricsArrayToLrc(common?.lyrics?.[0]?.syncText || []) ||
common?.lyrics?.[0]?.text ||
"",
// 音质信息
format,
// md5
md5: await getFileMD5(filePath),
};
} catch (error) {
ipcLog.error("❌ Error fetching music metadata:", error);
throw error;
}
});
// 修改音乐元信息
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) {
ipcLog.error("❌ Error setting music metadata:", error);
throw error;
}
});
// 获取音乐歌词
ipcMain.handle(
"get-music-lyric",
async (
_,
path: string,
): Promise<{
lyric: string;
format: "lrc" | "ttml";
}> => {
try {
const filePath = resolve(path).replace(/\\/g, "/");
const { common } = await parseFile(filePath);
// 尝试获取同名的歌词文件
const filePathWithoutExt = filePath.replace(/\.[^.]+$/, "");
for (const ext of ["ttml", "lrc"] as const) {
const lyricPath = `${filePathWithoutExt}.${ext}`;
ipcLog.info("lyricPath", lyricPath);
try {
await access(lyricPath);
const lyric = await readFile(lyricPath, "utf-8");
if (lyric && lyric != "") return { lyric, format: ext };
} catch {
/* empty */
}
}
// 尝试获取元数据
const lyric = common?.lyrics?.[0]?.syncText;
if (lyric && lyric.length > 0) {
return { lyric: metaDataLyricsArrayToLrc(lyric), format: "lrc" };
} else if (common?.lyrics?.[0]?.text) {
return { lyric: common?.lyrics?.[0]?.text, format: "lrc" };
}
// 没有歌词
return { lyric: "", format: "lrc" };
} catch (error) {
ipcLog.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 access(coverFilePath);
const coverData = await readFile(coverFilePath);
return { data: coverData, format: "image/jpeg" };
} catch {
return null;
}
}
} catch (error) {
console.error("❌ Error fetching music cover:", error);
throw error;
}
},
);
// 读取本地歌词
ipcMain.handle(
"read-local-lyric",
async (_, lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> => {
const result = { lrc: "", ttml: "" };
try {
// 定义需要查找的模式
// 此处的 `{,*.}` 表示这里可以取 `` (empty) 也可以取 `*.`
// 将歌词文件命名为 `歌曲ID.后缀名` 或者 `任意前缀.歌曲ID.后缀名` 均可
const patterns = {
ttml: `**/{,*.}${id}.ttml`,
lrc: `**/{,*.}${id}.lrc`,
};
// 遍历每一个目录
for (const dir of lyricDirs) {
try {
// 查找 ttml
if (!result.ttml) {
const ttmlFiles = await FastGlob(patterns.ttml, { cwd: dir });
if (ttmlFiles.length > 0) {
const filePath = join(dir, ttmlFiles[0]);
await access(filePath);
result.ttml = await readFile(filePath, "utf-8");
}
}
// 查找 lrc
if (!result.lrc) {
const lrcFiles = await FastGlob(patterns.lrc, { cwd: dir });
if (lrcFiles.length > 0) {
const filePath = join(dir, lrcFiles[0]);
await access(filePath);
result.lrc = await readFile(filePath, "utf-8");
}
}
// 如果两种文件都找到了就提前结束搜索
if (result.ttml && result.lrc) break;
} catch {
// 某个路径异常,跳过
}
}
} catch {
/* 忽略错误 */
}
return result;
},
);
// 删除文件
ipcMain.handle("delete-file", async (_, path: string) => {
try {
// 规范化路径
const resolvedPath = resolve(path);
// 检查文件是否存在
try {
await access(resolvedPath);
} catch {
throw new Error("❌ File not found");
}
// 删除文件
await unlink(resolvedPath);
return true;
} catch (error) {
ipcLog.error("❌ File delete error", error);
return false;
}
});
// 打开文件夹
ipcMain.on("open-folder", async (_, path: string) => {
try {
// 规范化路径
const resolvedPath = resolve(path);
// 检查文件夹是否存在
try {
await access(resolvedPath);
} catch {
throw new Error("❌ Folder not found");
}
// 打开文件夹
shell.showItemInFolder(resolvedPath);
} catch (error) {
ipcLog.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) {
ipcLog.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) {
ipcLog.error("❌ Path choose error", error);
return null;
}
});
// 下载文件
ipcMain.handle(
"download-file",
async (
event,
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 {
// 获取窗口
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
// 获取配置
const {
fileName,
fileType,
path,
lyric,
downloadMeta,
downloadCover,
downloadLyric,
saveMetaFile,
songData,
} = options;
// 规范化路径
const downloadPath = resolve(path);
// 检查文件夹是否存在
try {
await 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 writeFile(lrcPath, lyric, "utf-8");
}
// 是否删除封面
if (!saveMetaFile || !downloadCover) await unlink(coverDownload.getSavePath());
return true;
} catch (error) {
ipcLog.error("❌ Error downloading file:", error);
return false;
}
},
);
// 检查是否是子文件夹
ipcMain.handle("check-if-subfolder", (_, localFilesPath: string[], selectedDir: string) => {
const resolvedSelectedDir = resolve(selectedDir);
const allPaths = localFilesPath.map((p) => resolve(p));
return allPaths.some((existingPath) => {
const relativePath = relative(existingPath, resolvedSelectedDir);
return relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath);
});
});
};
export default initFileIpc;

View File

@@ -0,0 +1,201 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import { useStore } from "../store";
import lyricWindow from "../windows/lyric-window";
import mainWindow from "../windows/main-window";
/**
* 歌词相关 IPC
*/
const initLyricIpc = (): void => {
const store = useStore();
// 歌词窗口
let lyricWin: BrowserWindow | null = null;
/**
* 窗口是否存活
* @param win 窗口实例
* @returns 是否存活
*/
const isWinAlive = (win: BrowserWindow | null): win is BrowserWindow =>
!!win && !win.isDestroyed();
// 切换桌面歌词
ipcMain.on("toggle-desktop-lyric", (_event, val: boolean) => {
if (val) {
if (!isWinAlive(lyricWin)) {
lyricWin = lyricWindow.create();
// 监听关闭,置空引用,防止后续调用报错
lyricWin?.on("closed", () => {
lyricWin = null;
});
// 设置位置
const { x, y } = store.get("lyric");
const xPos = Number(x);
const yPos = Number(y);
if (Number.isFinite(xPos) && Number.isFinite(yPos)) {
lyricWin?.setPosition(Math.round(xPos), Math.round(yPos));
}
} else {
lyricWin.show();
}
if (isWinAlive(lyricWin)) {
lyricWin.setAlwaysOnTop(true, "screen-saver");
}
} else {
// 关闭:不销毁窗口,直接隐藏,保留位置与状态
if (!isWinAlive(lyricWin)) return;
lyricWin.hide();
}
});
// 更新歌词窗口数据
ipcMain.on("update-desktop-lyric-data", (_, lyricData) => {
if (!lyricData || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", lyricData);
});
// 更新歌词窗口配置
ipcMain.on("update-desktop-lyric-option", (_, option, callback: boolean = false) => {
const mainWin = mainWindow.getWin();
if (!option || !isWinAlive(lyricWin)) return;
// 增量更新
const prevOption = store.get("lyric.config");
if (prevOption) {
option = { ...prevOption, ...option };
}
store.set("lyric.config", option);
// 触发窗口更新
if (callback && isWinAlive(lyricWin)) {
lyricWin.webContents.send("update-desktop-lyric-option", option);
}
if (isWinAlive(mainWin)) {
mainWin?.webContents.send("update-desktop-lyric-option", option);
}
});
// 播放状态更改
ipcMain.on("play-status-change", (_, status) => {
if (!isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", { playStatus: status });
});
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", { playName: title });
});
// 音乐歌词更改
ipcMain.on("play-lyric-change", (_, lyricData) => {
if (!lyricData || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", lyricData);
});
// 获取窗口位置
ipcMain.handle("get-window-bounds", () => {
if (!isWinAlive(lyricWin)) return {};
return lyricWin.getBounds();
});
// 获取屏幕尺寸
ipcMain.handle("get-screen-size", () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
return { width, height };
});
// 获取多屏虚拟边界(支持负坐标)
ipcMain.handle("get-virtual-screen-bounds", () => {
const displays = screen.getAllDisplays();
const bounds = displays.map((d) => d.workArea);
const minX = Math.min(...bounds.map((b) => b.x));
const minY = Math.min(...bounds.map((b) => b.y));
const maxX = Math.max(...bounds.map((b) => b.x + b.width));
const maxY = Math.max(...bounds.map((b) => b.y + b.height));
return { minX, minY, maxX, maxY };
});
// 移动窗口
ipcMain.on("move-window", (_, x, y, width, height) => {
if (!isWinAlive(lyricWin)) return;
lyricWin.setBounds({ x, y, width, height });
// 保存配置
store.set("lyric", { ...store.get("lyric"), x, y, width, height });
});
// 更新歌词窗口宽高
ipcMain.on("update-lyric-size", (_, width, height) => {
if (!isWinAlive(lyricWin)) return;
// 更新窗口宽度
lyricWin.setBounds({ width, height });
store.set("lyric", { ...store.get("lyric"), width, height });
});
// 更新高度
ipcMain.on("update-window-height", (_, height) => {
if (!isWinAlive(lyricWin)) return;
const store = useStore();
const { width } = lyricWin.getBounds();
// 更新窗口高度
lyricWin.setBounds({ width, height });
store.set("lyric", { ...store.get("lyric"), height });
});
// 是否固定当前最大宽高
ipcMain.on(
"toggle-fixed-max-size",
(_, options: { width: number; height: number; fixed: boolean }) => {
if (!isWinAlive(lyricWin)) return;
const { width, height, fixed } = options;
if (fixed) {
lyricWin.setMaximumSize(width, height);
} else {
lyricWin.setMaximumSize(1400, 360);
}
},
);
// 请求歌词数据
ipcMain.on("request-desktop-lyric-data", () => {
const mainWin = mainWindow.getWin();
if (!isWinAlive(lyricWin) || !isWinAlive(mainWin)) return;
// 触发窗口更新
mainWin?.webContents.send("request-desktop-lyric-data");
});
// 请求歌词配置
ipcMain.handle("request-desktop-lyric-option", () => {
const config = store.get("lyric.config");
if (isWinAlive(lyricWin)) {
lyricWin.webContents.send("update-desktop-lyric-option", config);
}
return config;
});
// 关闭桌面歌词
ipcMain.on("closeDesktopLyric", () => {
const mainWin = mainWindow.getWin();
if (!isWinAlive(lyricWin) || !isWinAlive(mainWin)) return;
lyricWin.hide();
mainWin?.webContents.send("closeDesktopLyric");
});
// 锁定/解锁桌面歌词
ipcMain.on("toogleDesktopLyricLock", (_, isLock: boolean, isTemp: boolean = false) => {
const mainWin = mainWindow.getWin();
if (!isWinAlive(lyricWin) || !isWinAlive(mainWin)) return;
// 是否穿透
if (isLock) {
lyricWin.setIgnoreMouseEvents(true, { forward: true });
} else {
lyricWin.setIgnoreMouseEvents(false);
}
if (isTemp) return;
store.set("lyric.config", { ...store.get("lyric.config"), isLock });
// 触发窗口更新
const config = store.get("lyric.config");
mainWin?.webContents.send("update-desktop-lyric-option", config);
});
};
export default initLyricIpc;

View File

@@ -0,0 +1,36 @@
import { ipcMain } from "electron";
import { isShortcutRegistered, registerShortcut, unregisterShortcuts } from "../shortcut";
import mainWindow from "../windows/main-window";
/**
* 初始化快捷键 IPC 主进程
* @returns void
*/
const initShortcutIpc = (): void => {
// 快捷键是否被注册
ipcMain.handle("is-shortcut-registered", (_, shortcut: string) => isShortcutRegistered(shortcut));
// 注册快捷键
ipcMain.handle("register-all-shortcut", (_, allShortcuts: any): string[] | false => {
const mainWin = mainWindow.getWin();
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 initShortcutIpc;

View File

@@ -0,0 +1,45 @@
import { ipcMain } from "electron";
import { useStore } from "../store";
import type { StoreType } from "../store";
/**
* 初始化 store IPC 主进程
*/
const initStoreIpc = (): void => {
const store = useStore();
if (!store) return;
// 获取配置项
ipcMain.handle("store-get", (_event, key: keyof StoreType) => {
return store.get(key as any);
});
// 设置配置项
ipcMain.handle("store-set", (_event, key: keyof StoreType, value: unknown) => {
store.set(key as any, value as any);
return true;
});
// 判断配置项是否存在
ipcMain.handle("store-has", (_event, key: keyof StoreType) => {
return store.has(key as any);
});
// 删除配置项
ipcMain.handle("store-delete", (_event, key: keyof StoreType) => {
store.delete(key as any);
return true;
});
// 重置配置(支持指定 keys 或全部重置)
ipcMain.handle("store-reset", (_event, keys?: (keyof StoreType)[]) => {
if (keys && keys.length > 0) {
store.reset(...(keys as any));
} else {
store.reset();
}
return true;
});
};
export default initStoreIpc;

View File

@@ -0,0 +1,105 @@
import { app, ipcMain, net, powerSaveBlocker, session } from "electron";
import { ipcLog } from "../logger";
import { getFonts } from "font-list";
import { useStore } from "../store";
import mainWindow from "../windows/main-window";
/**
* 初始化系统 IPC 通信
* @returns void
*/
const initSystemIpc = (): void => {
const store = useStore();
/** 阻止系统息屏 ID */
let preventId: number | null = null;
// 是否阻止系统息屏
ipcMain.on("prevent-sleep", (_event, val: boolean) => {
if (val) {
preventId = powerSaveBlocker.start("prevent-display-sleep");
ipcLog.info("⏾ System sleep prevention started");
} else {
if (preventId !== null) {
powerSaveBlocker.stop(preventId);
ipcLog.info("✅ System sleep prevention stopped");
}
}
});
// 退出应用
ipcMain.on("quit-app", () => {
app.exit(0);
app.quit();
});
// 获取系统全部字体
ipcMain.handle("get-all-fonts", async () => {
try {
const fonts = await getFonts();
return fonts;
} catch (error) {
ipcLog.error(`❌ Failed to get all system fonts: ${error}`);
return [];
}
});
// 取消代理
ipcMain.on("remove-proxy", () => {
const mainWin = mainWindow.getWin();
store.set("proxy", "");
if (mainWin) {
mainWin?.webContents.session.setProxy({ proxyRules: "" });
}
ipcLog.info("✅ Remove proxy successfully");
});
// 配置网络代理
ipcMain.on("set-proxy", (_, config) => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
const proxyRules = `${config.protocol}://${config.server}:${config.port}`;
store.set("proxy", proxyRules);
mainWin?.webContents.session.setProxy({ proxyRules });
ipcLog.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) {
ipcLog.info("✅ Proxy test successful");
resolve(true);
} else {
ipcLog.error(`❌ Proxy test failed with status code: ${response.statusCode}`);
resolve(false);
}
});
request.on("error", (error) => {
ipcLog.error("❌ Error testing proxy:", error);
resolve(false);
});
request.end();
});
} catch (error) {
ipcLog.error("❌ Error testing proxy:", error);
return false;
}
});
// 重置全部设置
ipcMain.on("reset-setting", () => {
store.reset();
ipcLog.info("✅ Reset setting successfully");
});
};
export default initSystemIpc;

View File

@@ -0,0 +1,15 @@
import { ipcMain } from "electron";
import { getThumbar } from "../thumbar";
const initThumbarIpc = (): void => {
// 更新工具栏
ipcMain.on("play-status-change", (_, playStatus: boolean) => {
const thumbar = getThumbar();
if (!thumbar) {
return;
}
thumbar.updateThumbar(playStatus);
});
};
export default initThumbarIpc;

View File

@@ -0,0 +1,48 @@
import { ipcMain } from "electron";
import { getMainTray } from "../tray";
import lyricWindow from "../windows/lyric-window";
/**
* 托盘 IPC
*/
const initTrayIpc = (): void => {
const tray = getMainTray();
// 音乐播放状态更改
ipcMain.on("play-status-change", (_, playStatus: boolean) => {
const lyricWin = lyricWindow.getWin();
tray?.setPlayState(playStatus ? "play" : "pause");
if (!lyricWin) return;
lyricWin.webContents.send("play-status-change", playStatus);
});
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title) return;
// 更改标题
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("toggle-desktop-lyric", (_, val: boolean) => {
tray?.setDesktopLyricShow(val);
});
// 锁定/解锁桌面歌词
ipcMain.on("toogleDesktopLyricLock", (_, isLock: boolean) => {
tray?.setDesktopLyricLock(isLock);
});
};
export default initTrayIpc;

View File

@@ -0,0 +1,17 @@
import { ipcMain } from "electron";
import { checkUpdate, startDownloadUpdate } from "../update";
import mainWindow from "../windows/main-window";
const initUpdateIpc = () => {
// 检查更新
ipcMain.on("check-update", (_event, showTip) => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
checkUpdate(mainWin, showTip);
});
// 开始下载更新
ipcMain.on("start-download-update", () => startDownloadUpdate());
};
export default initUpdateIpc;

View File

@@ -0,0 +1,152 @@
import { app, ipcMain } from "electron";
import { useStore } from "../store";
import { isDev } from "../utils/config";
import { initThumbar } from "../thumbar";
import mainWindow from "../windows/main-window";
import loadWindow from "../windows/load-window";
import loginWindow from "../windows/login-window";
/**
* 窗口 IPC 通信
* @returns void
*/
const initWindowsIpc = (): void => {
// store
const store = useStore();
// 当前窗口状态
ipcMain.on("win-state", (event) => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
event.returnValue = mainWin?.isMaximized();
});
// 加载完成
ipcMain.on("win-loaded", () => {
const loadWin = loadWindow.getWin();
const mainWin = mainWindow.getWin();
if (loadWin && !loadWin.isDestroyed()) loadWin.destroy();
const isMaximized = store.get("window")?.maximized;
if (isMaximized) mainWin?.maximize();
if (!mainWin) return;
mainWin?.show();
mainWin?.focus();
// 解决窗口不立即显示
mainWin?.setAlwaysOnTop(true);
// 100ms 后取消置顶
const timer = setTimeout(() => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.setAlwaysOnTop(false);
mainWin.focus();
clearTimeout(timer);
}
}, 100);
// 初始化缩略图工具栏
if (mainWin) initThumbar(mainWin);
});
// 最小化
ipcMain.on("win-min", (event) => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
event.preventDefault();
mainWin?.minimize();
});
// 最大化
ipcMain.on("win-max", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.maximize();
});
// 还原
ipcMain.on("win-restore", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.restore();
});
// 隐藏
ipcMain.on("win-hide", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.hide();
});
// 显示
ipcMain.on("win-show", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.show();
mainWin?.focus();
});
// 重启
ipcMain.on("win-reload", () => {
app.quit();
app.relaunch();
});
// 向主窗口发送事件
ipcMain.on("send-to-mainWin", (_, eventName, ...args) => {
const mainWin = mainWindow.getWin();
if (!mainWin || mainWin.isDestroyed() || mainWin.webContents.isDestroyed()) return;
mainWin.webContents.send(eventName, ...args);
});
// 显示进度
ipcMain.on("set-bar", (_event, val: number | "none" | "indeterminate" | "error" | "paused") => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
switch (val) {
case "none":
mainWin?.setProgressBar(-1);
break;
case "indeterminate":
mainWin?.setProgressBar(2, { mode: "indeterminate" });
break;
case "error":
mainWin?.setProgressBar(1, { mode: "error" });
break;
case "paused":
mainWin?.setProgressBar(1, { mode: "paused" });
break;
default:
if (typeof val === "number") {
mainWin?.setProgressBar(val / 100);
} else {
mainWin?.setProgressBar(-1);
}
break;
}
});
// 开启控制台
ipcMain.on("open-dev-tools", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.webContents.openDevTools({
title: "SPlayer DevTools",
mode: isDev ? "right" : "detach",
});
});
// 开启登录窗口
ipcMain.on("open-login-web", () => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
loginWindow.create(mainWin);
});
// 开启设置
ipcMain.on("open-setting", (_, type) => {
const mainWin = mainWindow.getWin();
if (!mainWin) return;
mainWin?.show();
mainWin?.focus();
mainWin?.webContents.send("openSetting", type);
});
};
export default initWindowsIpc;

View File

@@ -0,0 +1,47 @@
// 日志输出
import { existsSync, mkdirSync } from "fs";
import { join } from "path";
import { app } from "electron";
import log from "electron-log";
// 日志文件路径
const logDir = join(app.getPath("logs"));
// 是否存在日志目录
if (!existsSync(logDir)) mkdirSync(logDir);
// 获取日期 - YYYY-MM-DD
const dateString = new Date().toISOString().slice(0, 10);
const logFilePath = join(logDir, `${dateString}.log`);
// 配置日志系统
log.transports.console.useStyles = true; // 颜色输出
log.transports.file.level = "info"; // 仅记录 info 及以上级别
log.transports.file.resolvePathFn = (): string => logFilePath; // 日志文件路径
log.transports.file.maxSize = 2 * 1024 * 1024; // 文件最大 2MB
// 日志格式化
// log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}] [{level}] [{scope}] {text}";
// 绑定默认事件
const defaultLog = log.scope("default");
console.log = defaultLog.log;
console.info = defaultLog.info;
console.warn = defaultLog.warn;
console.error = defaultLog.error;
// 分作用域导出
export { defaultLog };
export const ipcLog = log.scope("ipc");
export const trayLog = log.scope("tray");
export const thumbarLog = log.scope("thumbar");
export const storeLog = log.scope("store");
export const updateLog = log.scope("update");
export const systemLog = log.scope("system");
export const configLog = log.scope("config");
export const windowsLog = log.scope("windows");
export const processLog = log.scope("process");
export const preloadLog = log.scope("preload");
export const rendererLog = log.scope("renderer");
export const shortcutLog = log.scope("shortcut");
export const serverLog = log.scope("server");

View File

@@ -0,0 +1,30 @@
import { globalShortcut } from "electron";
import { shortcutLog } from "../logger";
// 注册快捷键并检查
export const registerShortcut = (shortcut: string, callback: () => void): boolean => {
try {
const success = globalShortcut.register(shortcut, callback);
if (!success) {
shortcutLog.error(`❌ Failed to register shortcut: ${shortcut}`);
return false;
} else {
shortcutLog.info(`✅ Shortcut registered: ${shortcut}`);
return true;
}
} catch (error) {
shortcutLog.error(` Error registering shortcut ${shortcut}:`, error);
return false;
}
};
// 检查快捷键是否被注册
export const isShortcutRegistered = (shortcut: string): boolean => {
return globalShortcut.isRegistered(shortcut);
};
// 卸载所有快捷键
export const unregisterShortcuts = () => {
globalShortcut.unregisterAll();
shortcutLog.info("🚫 All shortcuts unregistered.");
};

View File

@@ -0,0 +1,52 @@
import { screen } from "electron";
import { storeLog } from "../logger";
import type { LyricConfig } from "../../../src/types/desktop-lyric";
import defaultLyricConfig from "../../../src/assets/data/lyricConfig";
import Store from "electron-store";
storeLog.info("🌱 Store init");
export interface StoreType {
window: {
width: number;
height: number;
x?: number;
y?: number;
maximized?: boolean;
};
lyric: {
// 窗口位置
x?: number;
y?: number;
width?: number;
height?: number;
// 配置
config?: LyricConfig;
};
proxy: string;
}
/**
* 使用 Store
* @returns Store<StoreType>
*/
export const useStore = () => {
// 获取主屏幕
const screenData = screen.getPrimaryDisplay();
return new Store<StoreType>({
defaults: {
window: {
width: 1280,
height: 800,
},
lyric: {
x: screenData.workAreaSize.width / 2 - 400,
y: screenData.workAreaSize.height - 90,
width: 800,
height: 136,
config: defaultLyricConfig,
},
proxy: "",
},
});
};

View File

@@ -0,0 +1,141 @@
import { BrowserWindow, nativeImage, nativeTheme, ThumbarButton } from "electron";
import { join } from "path";
import { isWin } from "../utils/config";
import { thumbarLog } from "../logger";
enum ThumbarKeys {
Play = "play",
Pause = "pause",
Prev = "prev",
Next = "next",
}
type ThumbarMap = Map<ThumbarKeys, ThumbarButton>;
export interface Thumbar {
clearThumbar(): void;
updateThumbar(playing: boolean, clean?: boolean): void;
}
// 缩略图单例
let thumbar: Thumbar | null = null;
// 工具栏图标
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("playPrev"),
})
.set(ThumbarKeys.Next, {
tooltip: "下一曲",
icon: thumbarIcon("next"),
click: () => win.webContents.send("playNext"),
})
.set(ThumbarKeys.Play, {
tooltip: "播放",
icon: thumbarIcon("play"),
click: () => win.webContents.send("play"),
})
.set(ThumbarKeys.Pause, {
tooltip: "暂停",
icon: thumbarIcon("pause"),
click: () => win.webContents.send("pause"),
});
};
// 创建缩略图工具栏
class createThumbar implements Thumbar {
// 窗口
private _win: BrowserWindow;
// 工具栏
private _thumbar: ThumbarMap;
// 工具栏按钮
private _prev: ThumbarButton;
private _next: ThumbarButton;
private _play: ThumbarButton;
private _pause: ThumbarButton;
// 当前播放状态
private _isPlaying: boolean = false;
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();
// 监听主题变化
this.initThemeListener();
}
// 初始化主题监听器
private initThemeListener() {
nativeTheme.on("updated", () => {
this.refreshThumbarButtons();
});
}
// 刷新工具栏按钮(主题变化时)
private refreshThumbarButtons() {
// 重新创建按钮
this._thumbar = createThumbarButtons(this._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(this._isPlaying);
}
// 更新工具栏
updateThumbar(playing: boolean = false, clean: boolean = false) {
this._isPlaying = playing;
if (clean) return this.clearThumbar();
this._win.setThumbarButtons([this._prev, playing ? this._pause : this._play, this._next]);
}
// 清除工具栏
clearThumbar() {
this._win.setThumbarButtons([]);
}
}
/**
* 初始化缩略图工具栏
* @param win 窗口
* @returns 缩略图工具栏
*/
export const initThumbar = (win: BrowserWindow) => {
try {
// 若非 Win
if (!isWin) return null;
thumbarLog.info("🚀 ThumbarButtons Startup");
thumbar = new createThumbar(win);
return thumbar;
} catch (error) {
thumbarLog.error("❌ ThumbarButtons Error", error);
throw error;
}
};
/**
* 获取缩略图工具栏
* @returns 缩略图工具栏
*/
export const getThumbar = () => thumbar;

327
electron/main/tray/index.ts Normal file
View File

@@ -0,0 +1,327 @@
import {
app,
Tray,
Menu,
MenuItemConstructorOptions,
BrowserWindow,
nativeImage,
nativeTheme,
} from "electron";
import { isWin, appName } from "../utils/config";
import { join } from "path";
import { trayLog } from "../logger";
import { useStore } from "../store";
import lyricWindow from "../windows/lyric-window";
// 播放模式
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;
}
// 托盘单例
let mainTrayInstance: MainTray | null = null;
// 托盘图标
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): 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: () => {
const store = useStore();
// 更新锁定状态
store.set("lyric.config", { ...store.get("lyric.config"), isLock: !desktopLyricLock });
// 触发窗口更新
const config = store.get("lyric.config");
const lyricWin = lyricWindow.getWin();
if (!lyricWin) return;
lyricWin.webContents.send("update-desktop-lyric-option", config);
},
},
{
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 _tray: Tray;
// 菜单
private _menu: MenuItemConstructorOptions[];
private _contextMenu: Menu;
constructor(win: BrowserWindow) {
// 托盘图标
const icon = trayIcon(isWin ? "tray.ico" : "tray@32.png").resize({
height: 32,
width: 32,
});
// 初始化数据
this._win = win;
this._tray = new Tray(icon);
this._menu = createTrayMenu(this._win);
this._contextMenu = Menu.buildFromTemplate(this._menu);
// 初始化事件
this.initTrayMenu();
this.initEvents();
this.setTitle(appName);
}
// 托盘菜单
private initTrayMenu() {
this._menu = createTrayMenu(this._win);
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();
});
}
// 设置标题
/**
* 设置标题
* @param title 标题
*/
setTitle(title: string) {
this._win.setTitle(title);
this._tray.setTitle(title);
this._tray.setToolTip(title);
}
/**
* 设置播放名称
* @param name 播放名称
*/
setPlayName(name: string) {
// 超长处理
if (name.length > 20) name = name.slice(0, 20) + "...";
playName = name;
// 更新菜单
this.initTrayMenu();
}
/**
* 设置播放状态
* @param state 播放状态
*/
setPlayState(state: PlayState) {
playState = state;
// 更新菜单
this.initTrayMenu();
}
/**
* 设置播放模式
* @param mode 播放模式
*/
setPlayMode(mode: PlayMode) {
playMode = mode;
// 更新菜单
this.initTrayMenu();
}
/**
* 设置喜欢状态
* @param like 喜欢状态
*/
setLikeState(like: boolean) {
likeSong = like;
// 更新菜单
this.initTrayMenu();
}
/**
* 桌面歌词开关
* @param show 桌面歌词开关状态
*/
setDesktopLyricShow(show: boolean) {
desktopLyricShow = show;
// 更新菜单
this.initTrayMenu();
}
/**
* 锁定桌面歌词
* @param lock 锁定桌面歌词状态
*/
setDesktopLyricLock(lock: boolean) {
desktopLyricLock = lock;
// 更新菜单
this.initTrayMenu();
}
/**
* 销毁托盘
*/
destroyTray() {
this._tray.destroy();
}
}
/**
* 初始化托盘
* @param win 主窗口
* @param lyricWin 歌词窗口
* @returns 托盘实例
*/
export const initTray = (win: BrowserWindow) => {
try {
trayLog.info("🚀 Tray Process Startup");
const tray = new CreateTray(win);
// 保存单例实例
mainTrayInstance = tray;
return tray;
} catch (error) {
trayLog.error("❌ Tray Process Error", error);
return null;
}
};
/**
* 获取托盘实例
* @returns 托盘实例
*/
export const getMainTray = (): MainTray | null => mainTrayInstance;

View File

@@ -0,0 +1,84 @@
import { app, type BrowserWindow } from "electron";
import { updateLog } from "../logger";
import electronUpdater from "electron-updater";
import { isDev } from "../utils/config";
// import
const { autoUpdater } = electronUpdater;
// 开发环境启用
if (isDev) {
Object.defineProperty(app, "isPackaged", {
get: () => true,
});
}
// 更新源
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);
updateLog.info(`🚀 New version available: ${info.version}`);
});
// 更新下载进度
autoUpdater.on("download-progress", (progress) => {
win.webContents.send("download-progress", progress);
updateLog.info(`🚀 Downloading: ${progress.percent}%`);
});
// 当下载完成时
autoUpdater.on("update-downloaded", (info) => {
win.webContents.send("update-downloaded", info);
updateLog.info(`🚀 Update downloaded: ${info.version}`);
// 安装更新
autoUpdater.quitAndInstall();
});
// 当没有新版本时
autoUpdater.on("update-not-available", (info) => {
if (isShowTip) win.webContents.send("update-not-available", info);
updateLog.info(`✅ No new version available: ${info.version}`);
});
// 更新错误
autoUpdater.on("error", (err) => {
win.webContents.send("update-error", err);
updateLog.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();
};

View File

@@ -0,0 +1,60 @@
import { is } from "@electron-toolkit/utils";
import { app } from "electron";
/**
* 是否为开发环境
* @returns boolean
*/
export const isDev = is.dev;
/** 是否为 Windows 系统 */
export const isWin = process.platform === "win32";
/** 是否为 macOS 系统 */
export const isMac = process.platform === "darwin";
/** 是否为 Linux 系统 */
export const isLinux = process.platform === "linux";
/**
* 软件版本
* @returns string
*/
export const appVersion = app.getVersion();
/**
* 程序名称
* @returns string
*/
export const appName = app.getName() || "SPlayer";
/**
* 服务器端口
* @returns number
*/
export const port = Number(import.meta.env["VITE_SERVER_PORT"] || 25884);
/**
* 主窗口加载地址
* @returns string
*/
export const mainWinUrl =
isDev && process.env["ELECTRON_RENDERER_URL"]
? process.env["ELECTRON_RENDERER_URL"]
: `http://localhost:${port}`;
/**
* 歌词窗口加载地址
* @returns string
*/
export const lyricWinUrl =
isDev && process.env["ELECTRON_RENDERER_URL"]
? `${process.env["ELECTRON_RENDERER_URL"]}/#/desktop-lyric`
: `http://localhost:${port}/#/desktop-lyric`;
/**
* 加载窗口地址
* @returns string
*/
export const loadWinUrl =
isDev && process.env["ELECTRON_RENDERER_URL"]
? `${process.env["ELECTRON_RENDERER_URL"]}/web/loading/index.html`
: `http://localhost:${port}/web/loading/index.html`;

View File

@@ -0,0 +1,58 @@
import { createHash } from "crypto";
import { readFile } from "fs/promises";
/**
* 生成文件唯一ID
* @param filePath 文件路径
* @returns 唯一ID
*/
export const getFileID = (filePath: string): number => {
// SHA-256
const hash = 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
* @param path 文件路径
* @returns MD5值
*/
export const getFileMD5 = async (path: string): Promise<string> => {
const data = await readFile(path);
const hash = createHash("md5");
hash.update(data);
return hash.digest("hex");
};
/**
* 将 music-metadata 库中的歌词数组转换为LRC格式字符串
* @param lyrics 歌词数组,每个元素包含时间戳(毫秒)和歌词文本
* @returns LRC格式的字符串
*/
export const metaDataLyricsArrayToLrc = (
lyrics: {
text: string;
timestamp?: number;
}[],
): string => {
return lyrics
.map(({ timestamp, text }) => {
if (!timestamp) return "";
const totalSeconds = Math.floor(timestamp / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((timestamp % 1000) / 10);
// 格式化为两位数字
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
const cs = String(centiseconds).padStart(2, "0");
return `[${mm}:${ss}.${cs}]${text}`;
})
.join("\n");
};

View File

@@ -0,0 +1,25 @@
import { app } from "electron";
import { systemLog } from "../logger";
import mainWindow from "../windows/main-window";
/**
* 初始化单实例锁
* @returns 如果当前实例获得了锁,返回 true否则返回 false
*/
export const initSingleLock = (): boolean => {
const gotTheLock = app.requestSingleInstanceLock();
// 如果未获得锁,退出当前实例
if (!gotTheLock) {
app.quit();
systemLog.warn("❌ 已有一个实例正在运行");
return false;
}
// 当第二个实例启动时触发
else {
app.on("second-instance", () => {
systemLog.warn("❌ 第二个实例将要启动");
mainWindow.getWin()?.show();
});
}
return true;
};

View File

@@ -0,0 +1,43 @@
import { BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { windowsLog } from "../logger";
import { appName } from "../utils/config";
import { join } from "path";
import icon from "../../../public/icons/favicon.png?asset";
export const createWindow = (
options: BrowserWindowConstructorOptions = {},
): BrowserWindow | null => {
try {
const defaultOptions: BrowserWindowConstructorOptions = {
title: appName,
width: 1280,
height: 720,
frame: false, // 是否显示窗口边框
center: true, // 窗口居中
icon, // 窗口图标
autoHideMenuBar: true, // 隐藏菜单栏
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
// 禁用渲染器沙盒
sandbox: false,
// 禁用同源策略
webSecurity: false,
// 允许 HTTP
allowRunningInsecureContent: true,
// 禁用拼写检查
spellcheck: false,
// 启用 Node.js
nodeIntegration: true,
nodeIntegrationInWorker: true,
},
};
// 合并参数
options = Object.assign(defaultOptions, options);
// 创建窗口
const win = new BrowserWindow(options);
return win;
} catch (error) {
windowsLog.error(error);
return null;
}
};

View File

@@ -0,0 +1,60 @@
import { BrowserWindow } from "electron";
import { createWindow } from "./index";
import { loadWinUrl } from "../utils/config";
class LoadWindow {
private win: BrowserWindow | null = null;
private winURL: string;
constructor() {
this.winURL = loadWinUrl;
}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return;
// 准备好显示
this.win.on("ready-to-show", () => {
this.win?.show();
});
}
/**
* 创建窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
this.win = createWindow({
width: 800,
height: 560,
maxWidth: 800,
maxHeight: 560,
resizable: false,
alwaysOnTop: true,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false,
show: false,
});
if (!this.win) return null;
// 加载地址
this.win.loadURL(this.winURL);
// 窗口事件
this.event();
return this.win;
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
return this.win;
}
}
export default new LoadWindow();

View File

@@ -0,0 +1,99 @@
import { app, BrowserWindow, session } from "electron";
import { createWindow } from "./index";
import { join } from "path";
class LoginWindow {
private win: BrowserWindow | null = null;
private loginTimer: NodeJS.Timeout | null = null;
private loginSession: Electron.Session | null = null;
constructor() {}
private getLoginSession(): Electron.Session {
if (!this.loginSession) {
this.loginSession = session.fromPartition("persist:login");
}
return this.loginSession;
}
// 事件绑定
private event(mainWin: BrowserWindow): void {
if (!this.win) return;
// 阻止新窗口创建
this.win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
// 加载完成后显示并开始轮询登录状态
this.win.webContents.once("did-finish-load", () => {
this.win?.show();
this.loginTimer = setInterval(() => this.checkLogin(mainWin), 1000);
this.win?.on("closed", () => {
if (this.loginTimer) clearInterval(this.loginTimer);
this.loginTimer = null;
});
});
}
// 检查是否已登录
private async checkLogin(mainWin: BrowserWindow) {
if (!this.win) return;
try {
this.win.webContents.executeJavaScript(
"document.title = '登录网易云音乐( 若遇到无响应请关闭后重试 '",
);
// 判断 MUSIC_U
const MUSIC_U = await this.getLoginSession().cookies.get({ name: "MUSIC_U" });
if (MUSIC_U && MUSIC_U.length > 0) {
if (this.loginTimer) clearInterval(this.loginTimer);
this.loginTimer = null;
const value = `MUSIC_U=${MUSIC_U[0].value};`;
// 发送回主进程
mainWin?.webContents.send("send-cookies", value);
this.win.destroy();
this.win = null;
}
} catch (error) {
console.error(error);
}
}
// 创建登录窗口
async create(mainWin: BrowserWindow): Promise<BrowserWindow | null> {
await app.whenReady();
const loginSession = this.getLoginSession();
// 清理登录会话存储
await loginSession.clearStorageData({
storages: ["cookies", "localstorage"],
});
this.win = createWindow({
parent: mainWin,
title: "登录网易云音乐( 若遇到无响应请关闭后重试 ",
width: 1280,
height: 800,
center: true,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
sandbox: false,
webSecurity: false,
allowRunningInsecureContent: true,
spellcheck: false,
nodeIntegration: true,
nodeIntegrationInWorker: true,
session: loginSession,
},
});
if (!this.win) return null;
// 加载登录地址
this.win.loadURL("https://music.163.com/#/login/");
// 绑定事件
this.event(mainWin);
return this.win;
}
// 获取窗口
getWin(): BrowserWindow | null {
return this.win;
}
}
export default new LoginWindow();

View File

@@ -0,0 +1,89 @@
import { BrowserWindow } from "electron";
import { createWindow } from "./index";
import { useStore } from "../store";
import { lyricWinUrl } from "../utils/config";
import mainWindow from "./main-window";
class LyricWindow {
private win: BrowserWindow | null = null;
constructor() {}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return;
// 准备好显示
this.win.on("ready-to-show", () => {
this.win?.show();
});
// 歌词窗口缩放
this.win?.on("resized", () => {
const store = useStore();
const bounds = this.win?.getBounds();
if (bounds) {
const { width, height } = bounds;
store.set("lyric", { ...store.get("lyric"), width, height });
}
});
// 歌词窗口关闭
this.win?.on("close", () => {
this.win = null;
const mainWin = mainWindow?.getWin();
if (mainWin) {
mainWin?.webContents.send("closeDesktopLyric");
}
});
}
/**
* 创建主窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
const store = useStore();
const { width, height, x, y } = store.get("lyric");
this.win = createWindow({
width: width || 800,
height: height || 180,
minWidth: 640,
minHeight: 140,
maxWidth: 1400,
maxHeight: 360,
// 没有指定位置时居中显示
center: !(x && y),
// 窗口位置
x,
y,
transparent: true,
backgroundColor: "rgba(0, 0, 0, 0)",
alwaysOnTop: true,
resizable: true,
movable: true,
show: false,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false,
});
if (!this.win) return null;
// 加载地址
this.win.loadURL(lyricWinUrl);
// 窗口事件
this.event();
return this.win;
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
if (this.win && !this.win?.isDestroyed()) return this.win;
return null;
}
}
export default new LyricWindow();

View File

@@ -0,0 +1,134 @@
import { BrowserWindow, shell } from "electron";
import { createWindow } from "./index";
import { mainWinUrl } from "../utils/config";
import { useStore } from "../store";
import { isLinux } from "../utils/config";
class MainWindow {
private win: BrowserWindow | null = null;
private winURL: string;
constructor() {
this.winURL = mainWinUrl;
}
/**
* 保存窗口大小和状态
*/
private saveBounds() {
if (this.win?.isFullScreen()) return;
const store = useStore();
const bounds = this.win?.getBounds();
if (bounds) {
const maximized = this.win?.isMaximized();
store.set("window", { ...bounds, maximized });
}
}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return;
const store = useStore();
// 配置网络代理
if (store.get("proxy")) {
this.win.webContents.session.setProxy({ proxyRules: store.get("proxy") });
}
// 窗口打开处理程序
this.win.webContents.setWindowOpenHandler((details) => {
const { url } = details;
if (url.startsWith("https://") || url.startsWith("http://")) {
shell.openExternal(url);
}
return { action: "deny" };
});
// 窗口显示时
this.win?.on("show", () => {
this.win?.webContents.send("lyricsScroll");
});
// 窗口获得焦点时
this.win?.on("focus", () => {
this.saveBounds();
});
// 窗口大小改变时
this.win?.on("resized", () => {
// 若处于全屏则不保存
if (this.win?.isFullScreen()) return;
this.saveBounds();
});
// 窗口位置改变时
this.win?.on("moved", () => {
this.saveBounds();
});
// 窗口最大化时
this.win?.on("maximize", () => {
this.saveBounds();
this.win?.webContents.send("win-state-change", true);
});
// 窗口取消最大化时
this.win?.on("unmaximize", () => {
this.saveBounds();
this.win?.webContents.send("win-state-change", false);
});
// Linux 无法使用 resized 和 moved
if (isLinux) {
this.win?.on("resize", () => {
// 若处于全屏则不保存
if (this.win?.isFullScreen()) return;
this.saveBounds();
});
this.win?.on("move", () => {
this.saveBounds();
});
}
// 窗口关闭
this.win?.on("close", (event) => {
event.preventDefault();
this.win?.hide();
});
}
/**
* 创建窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
const store = useStore();
const { width, height } = store.get("window");
this.win = createWindow({
// 菜单栏
titleBarStyle: "customButtonsOnHover",
width,
height,
minHeight: 600,
minWidth: 800,
show: false,
});
if (!this.win) return null;
// 加载地址
this.win.loadURL(this.winURL);
// 窗口事件
this.event();
return this.win;
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
if (this.win && !this.win.isDestroyed()) {
return this.win;
}
return null;
}
/**
* 显示主窗口
*/
showWindow() {
if (this.win) {
this.win.show();
if (this.win.isMinimized()) this.win.restore();
this.win.focus();
}
}
}
export default new MainWindow();

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

@@ -0,0 +1,17 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { StoreType } from "../main/store";
declare global {
interface Window {
electron: ElectronAPI;
api: {
store: {
get<K extends keyof StoreType>(key: K): Promise<StoreType[K]>;
set<K extends keyof StoreType>(key: K, value: StoreType[K]): Promise<boolean>;
has(key: keyof StoreType): Promise<boolean>;
delete(key: keyof StoreType): Promise<boolean>;
reset(keys?: (keyof StoreType)[]): Promise<boolean>;
};
};
}
}

23
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
// 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.exposeInMainWorld("electron", electronAPI);
// Expose store API via preload
contextBridge.exposeInMainWorld("api", {
store: {
get: (key: string) => ipcRenderer.invoke("store-get", key),
set: (key: string, value: unknown) => ipcRenderer.invoke("store-set", key, value),
has: (key: string) => ipcRenderer.invoke("store-has", key),
delete: (key: string) => ipcRenderer.invoke("store-delete", key),
reset: (keys?: string[]) => ipcRenderer.invoke("store-reset", keys),
},
});
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,187 @@
import { FastifyInstance } from "fastify";
import mainWindow from "../../main/windows/main-window";
/**
* 播放控制接口
* @param fastify Fastify 实例
*/
export const initControlAPI = async (fastify: FastifyInstance) => {
// 播放控制路由前缀
await fastify.register(
async (fastify) => {
// 播放
fastify.get("/play", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
mainWin.webContents.send("play");
return reply.send({
code: 200,
message: "播放命令已发送",
data: null,
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "播放失败",
data: error,
});
}
});
// 暂停
fastify.get("/pause", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
mainWin.webContents.send("pause");
return reply.send({
code: 200,
message: "暂停命令已发送",
data: null,
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "暂停失败",
data: error,
});
}
});
// 播放/暂停切换
fastify.get("/toggle", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
// 这里可以根据当前播放状态来决定发送 play 还是 pause
// 暂时先发送 toggle 事件,如果渲染进程支持的话
mainWin.webContents.send("toggle");
return reply.send({
code: 200,
message: "播放/暂停切换命令已发送",
data: null,
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "播放/暂停切换失败",
data: error,
});
}
});
// 下一曲
fastify.get("/next", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
mainWin.webContents.send("playNext");
return reply.send({
code: 200,
message: "下一曲命令已发送",
data: null,
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "下一曲失败",
data: error,
});
}
});
// 上一曲
fastify.get("/prev", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
mainWin.webContents.send("playPrev");
return reply.send({
code: 200,
message: "上一曲命令已发送",
data: null,
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "上一曲失败",
data: error,
});
}
});
// 获取播放状态(可选功能)
fastify.get("/status", async (_request, reply) => {
try {
const mainWin = mainWindow.getWin();
if (!mainWin) {
return reply.code(500).send({
code: 500,
message: "主窗口未找到",
data: null,
});
}
// 这里可以通过 IPC 获取当前播放状态
// 暂时返回基本信息
return reply.send({
code: 200,
message: "获取状态成功",
data: {
connected: true,
window: "available",
},
});
} catch (error) {
return reply.code(500).send({
code: 500,
message: "获取状态失败",
data: error,
});
}
});
},
{ prefix: "/control" },
);
};

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

@@ -0,0 +1,63 @@
import { join } from "path";
import { isDev } from "../main/utils/config";
import { serverLog } from "../main/logger";
import { initNcmAPI } from "./netease";
import { initUnblockAPI } from "./unblock";
import { initControlAPI } from "./control";
import fastifyCookie from "@fastify/cookie";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import fastify from "fastify";
const initAppServer = async () => {
try {
const server = fastify({
routerOptions: {
// 忽略尾随斜杠
ignoreTrailingSlash: true,
},
});
// 注册插件
server.register(fastifyCookie);
server.register(fastifyMultipart);
// 生产环境启用静态文件
if (!isDev) {
serverLog.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" });
server.register(initControlAPI, { prefix: "/api" });
// 启动端口
const port = Number(process.env["VITE_SERVER_PORT"] || 25884);
await server.listen({ port });
serverLog.info(`🌐 Starting AppServer on port ${port}`);
return server;
} catch (error) {
serverLog.error("🚫 AppServer failed to start");
throw error;
}
};
export default initAppServer;

View File

@@ -0,0 +1,87 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { pathCase } from "change-case";
import { serverLog } from "../../main/logger";
import NeteaseCloudMusicApi from "@neteasecloudmusicapienhanced/api";
// 获取数据
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
return async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
serverLog.log("🌐 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) {
serverLog.error("❌ NcmAPI Error:", error);
if ([400, 301].includes(error.status)) {
return reply.status(error.status).send(error.body);
}
return reply.status(500);
}
};
};
// 初始化 NcmAPI
export const initNcmAPI = async (fastify: FastifyInstance) => {
// 主信息
fastify.get("/netease", (_, reply) => {
reply.send({
name: "@neteaseapireborn/api",
version: "4.29.2",
description: "网易云音乐 API Enhanced",
author: "@MoeFurina",
license: "MIT",
url: "https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced",
});
});
// 注册 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);
}
});
// 获取 TTML 歌词
fastify.get(
"/netease/lyric/ttml",
async (req: FastifyRequest<{ Querystring: { id: string } }>, reply: FastifyReply) => {
const { id } = req.query;
if (!id) {
return reply.status(400).send({ error: "id is required" });
}
const url = `https://amll-ttml-db.stevexmh.net/ncm/${id}`;
try {
const response = await fetch(url);
if (response.status !== 200) {
return reply.send(null);
}
const data = await response.text();
return reply.send(data);
} catch (error) {
serverLog.error("❌ TTML Lyric Fetch Error:", error);
return reply.send(null);
}
},
);
serverLog.info("🌐 Register NcmAPI successfully");
};

14
electron/server/port.ts Normal file
View File

@@ -0,0 +1,14 @@
import getPort from "get-port";
// 默认端口
let webPort: number;
let servePort: number;
const getSafePort = async () => {
if (webPort && servePort) return { webPort, servePort };
webPort = await getPort({ port: 14558 });
servePort = await getPort({ port: 25884 });
return { webPort, servePort };
};
export default getSafePort;

View File

@@ -0,0 +1,161 @@
import { SongUrlResult } from "./unblock";
import { serverLog } from "../../main/logger";
import { createHash } from "crypto";
import axios from "axios";
/**
* 生成随机设备 ID
* @returns 随机设备 ID
*/
const getRandomDeviceId = () => {
const min = 0;
const max = 100000000000;
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
return randomNum.toString();
};
/** 随机设备 ID */
const deviceId = getRandomDeviceId();
/**
* 格式化歌曲信息
* @param song 歌曲信息
* @returns 格式化后的歌曲信息
*/
const format = (song: any) => ({
id: song.MUSICRID.split("_").pop(),
name: song.SONGNAME,
duration: song.DURATION * 1000,
album: { id: song.ALBUMID, name: song.ALBUM },
artists: song.ARTIST.split("&").map((name: any, index: any) => ({
id: index ? null : song.ARTISTID,
name,
})),
});
/**
* 生成签名
* @param str 请求字符串
* @returns 包含签名的请求字符串
*/
const generateSign = (str: string) => {
const url = new URL(str);
const currentTime = Date.now();
str += `&timestamp=${currentTime}`;
const filteredChars = str
.substring(str.indexOf("?") + 1)
.replace(/[^a-zA-Z0-9]/g, "")
.split("")
.sort();
const dataToEncrypt = `kuwotest${filteredChars.join("")}${url.pathname}`;
const md5 = createHash("md5").update(dataToEncrypt).digest("hex");
return `${str}&sign=${md5}`;
};
/**
* 搜索歌曲
* @param keyword 搜索关键词
* @returns 歌曲 ID 或 null
*/
const search = async (info: string): Promise<string | null> => {
try {
const keyword = encodeURIComponent(info.replace(" - ", " "));
const url =
"http://search.kuwo.cn/r.s?&correct=1&vipver=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 list = result.data.content[1].musicpage.abslist.map(format);
if (list[0] && !list[0]?.id) return null;
return list[0].id;
} catch (error) {
serverLog.error("❌ Get BodianSongId Error:", error);
return null;
}
};
/**
* 发送广告免费请求
* @returns 包含广告免费响应的 Promise
*/
const sendAdFreeRequest = () => {
try {
const adurl =
"http://bd-api.kuwo.cn/api/service/advert/watch?uid=-1&token=&timestamp=1724306124436&sign=15a676d66285117ad714e8c8371691da";
const headers = {
"user-agent": "Dart/2.19 (dart:io)",
plat: "ar",
channel: "aliopen",
devid: deviceId,
ver: "3.9.0",
host: "bd-api.kuwo.cn",
qimei36: "1e9970cbcdc20a031dee9f37100017e1840e",
"content-type": "application/json; charset=utf-8",
};
const data = JSON.stringify({
type: 5,
subType: 5,
musicId: 0,
adToken: "",
});
return axios.post(adurl, data, { headers });
} catch (error) {
serverLog.error("❌ Get Bodian Ad Free Error:", error);
return null;
}
};
/**
* 获取波点音乐歌曲 URL
* @param keyword 搜索关键词
* @returns 包含歌曲 URL 的结果对象
*/
const getBodianSongUrl = async (keyword: string): Promise<SongUrlResult> => {
try {
if (!keyword) return { code: 404, url: null };
const songId = await search(keyword);
if (!songId) return { code: 404, url: null };
// 请求地址
const headers = {
"user-agent": "Dart/2.19 (dart:io)",
plat: "ar",
channel: "aliopen",
devid: deviceId,
ver: "3.9.0",
host: "bd-api.kuwo.cn",
"X-Forwarded-For": "1.0.1.114",
};
let audioUrl = `http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=${"320kmp3"}&musicId=${songId}`;
// 生成签名
audioUrl = generateSign(audioUrl);
// 获取广告
await sendAdFreeRequest();
// 获取歌曲地址
const result = await axios.get(audioUrl, { headers });
if (typeof result.data === "object") {
const urlMatch = result.data.data.audioUrl;
serverLog.log("🔗 BodianSong URL:", urlMatch);
return { code: 200, url: urlMatch };
}
return { code: 404, url: null };
} catch (error) {
serverLog.error("❌ Get BodianSong URL Error:", error);
return { code: 404, url: null };
}
};
export default getBodianSongUrl;

View File

@@ -0,0 +1,107 @@
import { SongUrlResult } from "./unblock";
import { serverLog } from "../../main/logger";
import axios from "axios";
import { randomBytes } from "crypto";
/**
* 搜索歌曲获取 ID
* @param keyword 搜索关键词
* @returns 歌曲 ID 或 null
*/
const search = async (keyword: string): Promise<string | null> => {
try {
const searchUrl = `https://www.gequbao.com/s/${encodeURIComponent(keyword)}`;
const { data } = await axios.get(searchUrl);
// 匹配第一个歌曲链接 /music/12345
// <a href="/music/17165" target="_blank" class="music-link d-block">
const match = data.match(
/<a href="\/music\/(\d+)" target="_blank" class="music-link d-block">/,
);
if (match && match[1]) {
return match[1];
}
return null;
} catch (error) {
serverLog.error("❌ Get GequbaoSongId Error:", error);
return null;
}
};
/**
* 获取播放 ID
* @param id 歌曲 ID
* @returns 播放 ID 或 null
*/
const getPlayId = async (id: string): Promise<string | null> => {
try {
const url = `https://www.gequbao.com/music/${id}`;
const { data } = await axios.get(url);
// 匹配 window.appData 中的 play_id
// "play_id":"EFwMVSQDBgsBQV5WBCUDAVkCSQ9WX3kFXV9XEl0KBSEaVldTR19NVndQVlhXRl5cUA=="
const match = data.match(/"play_id":"(.*?)"/);
if (match && match[1]) {
return match[1];
}
return null;
} catch (error) {
serverLog.error("❌ Get GequbaoPlayId Error:", error);
return null;
}
};
/**
* 获取歌曲 URL
* @param keyword 搜索关键词
* @returns 包含歌曲 URL 的结果对象
*/
const getGequbaoSongUrl = async (keyword: string): Promise<SongUrlResult> => {
try {
if (!keyword) return { code: 404, url: null };
// 1. 获取 ID
const id = await search(keyword);
if (!id) return { code: 404, url: null };
// 2. 获取 play_id
const playId = await getPlayId(id);
if (!playId) return { code: 404, url: null };
// 3. 获取播放链接
const url = "https://www.gequbao.com/api/play-url";
const headers = {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
pragma: "no-cache",
priority: "u=1, i",
"sec-ch-ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-requested-with": "XMLHttpRequest",
cookie: `server_name_session=${randomBytes(16).toString("hex")}`,
Referer: `https://www.gequbao.com/music/${id}`,
};
const body = `id=${encodeURIComponent(playId)}`;
const { data } = await axios.post(url, body, { headers });
if (data.code === 1 && data.data && data.data.url) {
serverLog.log("🔗 GequbaoSong URL:", data.data.url);
return { code: 200, url: data.data.url };
}
return { code: 404, url: null };
} catch (error) {
serverLog.error("❌ Get GequbaoSong URL Error:", error);
return { code: 404, url: null };
}
};
export default getGequbaoSongUrl;

View File

@@ -0,0 +1,92 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { SongUrlResult } from "./unblock";
import { serverLog } from "../../main/logger";
import axios from "axios";
import getKuwoSongUrl from "./kuwo";
import getBodianSongUrl from "./bodian";
import getGequbaoSongUrl from "./gequbao";
/**
* 直接获取 网易云云盘 链接
* 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;
serverLog.log("🔗 NeteaseSongUrl URL:", songUrl);
return { code: 200, url: songUrl };
} catch (error) {
serverLog.error("❌ Get NeteaseSongUrl Error:", error);
return { code: 404, url: null };
}
};
// 初始化 UnblockAPI
export const initUnblockAPI = 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);
},
);
// bodian
fastify.get(
"/unblock/bodian",
async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
const { keyword } = req.query;
const result = await getBodianSongUrl(keyword);
return reply.send(result);
},
);
// gequbao
fastify.get(
"/unblock/gequbao",
async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
const { keyword } = req.query;
const result = await getGequbaoSongUrl(keyword);
return reply.send(result);
},
);
serverLog.info("🌐 Register UnblockAPI successfully");
};

View File

@@ -0,0 +1,66 @@
import { encryptQuery } from "./kwDES";
import { SongUrlResult } from "./unblock";
import { serverLog } 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) {
serverLog.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];
serverLog.log("🔗 KuwoSong URL:", urlMatch);
return { code: 200, url: urlMatch };
}
return { code: 404, url: null };
} catch (error) {
serverLog.error("❌ Get KuwoSong URL Error:", error);
return { code: 404, url: null };
}
};
export default getKuwoSongUrl;

View File

@@ -0,0 +1,573 @@
/*
Thanks to
https://github.com/XuShaohua/kwplayer/blob/master/kuwo/DES.py
https://github.com/Levi233/MusicPlayer/blob/master/app/src/main/java/com/chenhao/musicplayer/utils/crypt/KuwoDES.java
*/
const Long = (n) => {
const bN = BigInt(n);
return {
low: Number(bN),
valueOf: () => bN.valueOf(),
toString: () => bN.toString(),
not: () => Long(~bN),
isNegative: () => bN < 0,
or: (x) => Long(bN | BigInt(x)),
and: (x) => Long(bN & BigInt(x)),
xor: (x) => Long(bN ^ BigInt(x)),
equals: (x) => bN === BigInt(x),
multiply: (x) => Long(bN * BigInt(x)),
shiftLeft: (x) => Long(bN << BigInt(x)),
shiftRight: (x) => Long(bN >> BigInt(x)),
};
};
const range = (n) => Array.from(new Array(n).keys());
const power = (base, index) =>
Array(index)
.fill(null)
.reduce((result) => result.multiply(base), Long(1));
const LongArray = (...array) => array.map((n) => (n === -1 ? Long(-1, -1) : Long(n)));
// EXPANSION
const arrayE = LongArray(
31,
0,
1,
2,
3,
4,
-1,
-1,
3,
4,
5,
6,
7,
8,
-1,
-1,
7,
8,
9,
10,
11,
12,
-1,
-1,
11,
12,
13,
14,
15,
16,
-1,
-1,
15,
16,
17,
18,
19,
20,
-1,
-1,
19,
20,
21,
22,
23,
24,
-1,
-1,
23,
24,
25,
26,
27,
28,
-1,
-1,
27,
28,
29,
30,
31,
30,
-1,
-1,
);
// INITIAL_PERMUTATION
const arrayIP = LongArray(
57,
49,
41,
33,
25,
17,
9,
1,
59,
51,
43,
35,
27,
19,
11,
3,
61,
53,
45,
37,
29,
21,
13,
5,
63,
55,
47,
39,
31,
23,
15,
7,
56,
48,
40,
32,
24,
16,
8,
0,
58,
50,
42,
34,
26,
18,
10,
2,
60,
52,
44,
36,
28,
20,
12,
4,
62,
54,
46,
38,
30,
22,
14,
6,
);
// INVERSE_PERMUTATION
const arrayIP_1 = LongArray(
39,
7,
47,
15,
55,
23,
63,
31,
38,
6,
46,
14,
54,
22,
62,
30,
37,
5,
45,
13,
53,
21,
61,
29,
36,
4,
44,
12,
52,
20,
60,
28,
35,
3,
43,
11,
51,
19,
59,
27,
34,
2,
42,
10,
50,
18,
58,
26,
33,
1,
41,
9,
49,
17,
57,
25,
32,
0,
40,
8,
48,
16,
56,
24,
);
// ROTATES
const arrayLs = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
const arrayLsMask = LongArray(0, 0x100001, 0x300003);
const arrayMask = range(64).map((n) => power(2, n));
arrayMask[arrayMask.length - 1] = arrayMask[arrayMask.length - 1].multiply(-1);
// PERMUTATION
const arrayP = LongArray(
15,
6,
19,
20,
28,
11,
27,
16,
0,
14,
22,
25,
4,
17,
30,
9,
1,
7,
23,
13,
31,
26,
2,
8,
18,
12,
29,
5,
21,
10,
3,
24,
);
// PERMUTED_CHOICE1
const arrayPC_1 = LongArray(
56,
48,
40,
32,
24,
16,
8,
0,
57,
49,
41,
33,
25,
17,
9,
1,
58,
50,
42,
34,
26,
18,
10,
2,
59,
51,
43,
35,
62,
54,
46,
38,
30,
22,
14,
6,
61,
53,
45,
37,
29,
21,
13,
5,
60,
52,
44,
36,
28,
20,
12,
4,
27,
19,
11,
3,
);
// PERMUTED_CHOICE2
const arrayPC_2 = LongArray(
13,
16,
10,
23,
0,
4,
-1,
-1,
2,
27,
14,
5,
20,
9,
-1,
-1,
22,
18,
11,
3,
25,
7,
-1,
-1,
15,
6,
26,
19,
12,
1,
-1,
-1,
40,
51,
30,
36,
46,
54,
-1,
-1,
29,
39,
50,
44,
32,
47,
-1,
-1,
43,
48,
38,
55,
33,
52,
-1,
-1,
45,
41,
49,
35,
28,
31,
-1,
-1,
);
const matrixNSBox = [
[
14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15, 6, 9, 10, 1, 8, 12, 7,
8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8, 12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4,
2, 11, 14, 1, 7, 8, 13,
],
[
15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4, 0, 3, 14, 11, 13, 6, 4,
1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1, 7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7,
1, 10, 12, 14, 2, 5, 9,
],
[
10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3, 15, 4, 10, 14, 9, 7, 12,
5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13, 5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9,
0, 14, 3, 10, 7, 1, 12,
],
[
7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11, 12, 2, 3, 0, 5, 14, 10,
13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2, 5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5,
6, 12, 11, 3, 8, 9, 14,
],
[
2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10, 13, 0, 3, 1, 11, 15, 5,
6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12, 15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4,
12, 7, 10, 9, 1, 13, 6, 3,
],
[
12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2, 8, 7, 13, 15, 5, 4, 10,
8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13, 1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2,
12, 14, 7, 5, 10, 8, 13,
],
[
4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3, 10, 5, 14, 13, 7, 8,
13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5, 0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7,
8, 12, 15, 10, 7, 6, 12,
],
[
13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15, 12, 0, 3, 4, 1, 14, 13,
1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14, 6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8,
7, 11, 0, 4, 13, 2, 11,
],
];
const bitTransform = (arrInt, n, l) => {
// int[], int, long : long
let l2 = Long(0);
range(n).forEach((i) => {
if (arrInt[i].isNegative() || l.and(arrayMask[arrInt[i].low]).equals(0)) return;
l2 = l2.or(arrayMask[i]);
});
return l2;
};
const DES64 = (longs, l) => {
const pR = range(8).map(() => Long(0));
const pSource = [Long(0), Long(0)];
let L = Long(0);
let R = Long(0);
let out = bitTransform(arrayIP, 64, l);
pSource[0] = out.and(0xffffffff);
pSource[1] = out.and(-4294967296).shiftRight(32);
range(16).forEach((i) => {
let SOut = Long(0);
R = Long(pSource[1]);
R = bitTransform(arrayE, 64, R);
R = R.xor(longs[i]);
range(8).forEach((j) => {
pR[j] = R.shiftRight(j * 8).and(255);
});
range(8)
.reverse()
.forEach((sbi) => {
SOut = SOut.shiftLeft(4).or(matrixNSBox[sbi][pR[sbi]]);
});
R = bitTransform(arrayP, 32, SOut);
L = Long(pSource[0]);
pSource[0] = Long(pSource[1]);
pSource[1] = L.xor(R);
});
pSource.reverse();
out = pSource[1].shiftLeft(32).and(-4294967296).or(pSource[0].and(0xffffffff));
out = bitTransform(arrayIP_1, 64, out);
return out;
};
const subKeys = (l, longs, n) => {
// long, long[], int
let l2 = bitTransform(arrayPC_1, 56, l);
range(16).forEach((i) => {
l2 = l2
.and(arrayLsMask[arrayLs[i]])
.shiftLeft(28 - arrayLs[i])
.or(l2.and(arrayLsMask[arrayLs[i]].not()).shiftRight(arrayLs[i]));
longs[i] = bitTransform(arrayPC_2, 64, l2);
});
if (n === 1) {
range(8).forEach((j) => {
[longs[j], longs[15 - j]] = [longs[15 - j], longs[j]];
});
}
};
const crypt = (msg, key, mode) => {
// 处理密钥块
let l = Long(0);
range(8).forEach((i) => {
l = Long(key[i])
.shiftLeft(i * 8)
.or(l);
});
const j = Math.floor(msg.length / 8);
// arrLong1 存放的是转换后的密钥块, 在解密时只需要把这个密钥块反转就行了
const arrLong1 = range(16).map(() => Long(0));
subKeys(l, arrLong1, mode);
// arrLong2 存放的是前部分的明文
const arrLong2 = range(j).map(() => Long(0));
range(j).forEach((m) => {
range(8).forEach((n) => {
arrLong2[m] = Long(msg[n + m * 8])
.shiftLeft(n * 8)
.or(arrLong2[m]);
});
});
// 用于存放密文
const arrLong3 = range(Math.floor((1 + 8 * (j + 1)) / 8)).map(() => Long(0));
// 计算前部的数据块(除了最后一部分)
range(j).forEach((i1) => {
arrLong3[i1] = DES64(arrLong1, arrLong2[i1]);
});
// 保存多出来的字节
const arrByte1 = msg.slice(j * 8);
let l2 = Long(0);
range(msg.length % 8).forEach((i1) => {
l2 = Long(arrByte1[i1])
.shiftLeft(i1 * 8)
.or(l2);
});
// 计算多出的那一位(最后一位)
if (arrByte1.length || mode === 0) arrLong3[j] = DES64(arrLong1, l2); // 解密不需要
// 将密文转为字节型
const arrByte2 = range(8 * arrLong3.length).map(() => 0);
let i4 = 0;
arrLong3.forEach((l3) => {
range(8).forEach((i6) => {
arrByte2[i4] = l3.shiftRight(i6 * 8).and(255).low;
i4 += 1;
});
});
return Buffer.from(arrByte2);
};
const SECRET_KEY = Buffer.from("ylzsxkwm");
export const encrypt = (msg) => crypt(msg, SECRET_KEY, 0);
export const decrypt = (msg) => crypt(msg, SECRET_KEY, 1);
export const encryptQuery = (query) => encrypt(Buffer.from(query)).toString("base64");

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;
}

65
eslint.config.mjs Normal file
View File

@@ -0,0 +1,65 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
import vue from "eslint-plugin-vue";
import js from "@eslint/js";
import globals from "globals";
import path from "node:path";
import autoEslint from "./auto-eslint.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: [
"**/node_modules",
"**/dist",
"**/out",
"**/.gitignore",
"**/auto-imports.d.ts",
"**/components.d.ts",
],
},
...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"),
{
plugins: {
"@typescript-eslint": typescriptEslint,
vue,
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...autoEslint.globals,
},
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
parser: "@typescript-eslint/parser",
},
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
},
},
{
files: ["**/.eslintrc.{js,cjs}"],
languageOptions: {
globals: { ...globals.node },
ecmaVersion: 5,
sourceType: "commonjs",
},
},
];

View File

@@ -1,79 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="<%- logo %>" />
<link rel="apple-touch-icon" href="<%- logo %>" />
<link rel="bookmark" href="<%- logo %>" />
<link
rel="apple-touch-icon-precomposed"
sizes="200x200"
href="<%- logo %>"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" /> -->
<title><%- title %></title>
<meta name="apple-mobile-web-app-title" content="<%- title %>" />
<meta name="author" content="<%- author %>" />
<meta name="keywords" content="<%- keywords %>" />
<meta name="description" content="<%- description %>" />
<meta name="theme-color" content="#ffffff" />
<!-- HarmonyOS Sans -->
<link
rel="stylesheet"
href="https://s1.hdslb.com/bfs/static/jinkela/long/font/regular.css"
/>
<!-- IE Out -->
<script>
if (
/*@cc_on!@*/ false ||
(!!window.MSInputMethodContext && !!document.documentMode)
)
window.location.href =
"https://support.dmeng.net/upgrade-your-browser.html?referrer=" +
encodeURIComponent(window.location.href);
</script>
<% if (tongji) { %>
<!-- 百度统计 -->
<script>
var _hmt = _hmt || [];
(function () {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?<%- tongji %>";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
<% } %>
<style>
noscript {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 16px);
font-family: "HarmonyOS_Regular", sans-serif;
}
noscript .title {
font-size: 30px;
font-weight: bold;
margin-top: 50px;
}
noscript .tip {
opacity: 0.6;
margin: 0;
}
</style>
</head>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/icon" href="/icons/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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.ts"></script>
</body>
<body>
<div id="app"></div>
<noscript>
<img src="<%- logo %>" alt="logo" />
<p class="title"><%- title %></p>
<p class="tip">请开启 JavaScript</p>
</noscript>
<script type="module" src="/src/main.js"></script>
</body>
</html>

52
nginx.conf Normal file
View File

@@ -0,0 +1,52 @@
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/song/url/v1 {
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/song/url/v1;
sub_filter '"url":"http://music.163.com' '"url":"/music/unblock';
sub_filter '"url":"https://music.163.com' '"url":"/music/unblock';
sub_filter_types application/json;
sub_filter_once off;
}
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/;
}
location /music/unblock/ {
proxy_pass https://music.163.com/;
proxy_buffering off;
proxy_request_buffering off;
}
}

View File

@@ -1,44 +1,139 @@
{
"name": "splayer",
"version": "1.1.7",
"productName": "SPlayer",
"version": "3.0.0-beta.6",
"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": ">=20",
"npm": ">=10"
},
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
"format": "prettier --write .",
"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": "node scripts/dev.mjs",
"build": "npx rimraf dist && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:web": "npm run build",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win --config electron-builder.config.ts",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.config.ts",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.config.ts"
},
"dependencies": {
"@icon-park/vue-next": "^1.4.2",
"@ivanv/vue-collapse-transition": "^1.0.2",
"artplayer": "^4.5.12",
"axios": "^1.2.0",
"colorthief": "^2.4.0",
"howler": "^2.2.3",
"pinia": "^2.0.26",
"pinia-plugin-persistedstate": "^3.0.1",
"plyr": "^3.7.3",
"qrcode.vue": "^3.3.3",
"sass": "^1.56.1",
"screenfull": "^6.0.2",
"swiper": "^9.3.2",
"throttle-debounce": "^5.0.0",
"vite-plugin-html": "^3.2.0",
"vue": "^3.2.45",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6",
"vue-slider-component": "4.1.0-beta.7"
"@applemusic-like-lyrics/core": "^0.1.3",
"@applemusic-like-lyrics/lyric": "^0.3.0",
"@applemusic-like-lyrics/vue": "^0.1.5",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
"@pixi/filter-blur": "^7.4.3",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@vueuse/core": "^13.9.0",
"@vueuse/integrations": "^14.0.0",
"axios": "^1.13.2",
"axios-retry": "^4.5.0",
"change-case": "^5.4.4",
"dayjs": "^1.11.19",
"electron-dl": "^4.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.6.2",
"file-saver": "^2.0.5",
"font-list": "^2.0.1",
"get-port": "^7.1.0",
"github-markdown-css": "^5.8.1",
"howler": "^2.2.4",
"js-cookie": "^3.0.5",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lodash-es": "^4.17.21",
"marked": "^16.4.2",
"md5": "^2.3.0",
"music-metadata": "^11.10.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"plyr": "^3.8.3",
"sortablejs": "^1",
"vue-virt-list": "^1.6.1"
},
"devDependencies": {
"@jridgewell/sourcemap-codec": "^1.4.14",
"@rollup/plugin-terser": "^0.4.0",
"@vicons/material": "^0.12.0",
"@vitejs/plugin-vue": "^4.2.3",
"naive-ui": "^2.34.4",
"unplugin-auto-import": "^0.12.0",
"unplugin-vue-components": "^0.22.11",
"vite": "^4.3.8",
"vite-plugin-pwa": "^0.15.0"
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron-toolkit/utils": "^4",
"@fastify/cookie": "^11.0.2",
"@fastify/http-proxy": "^11.3.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@types/file-saver": "^2.0.7",
"@types/howler": "^2.2.12",
"@types/js-cookie": "^3.0.6",
"@types/md5": "^2.3.6",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"@vitejs/plugin-vue": "^6.0.1",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
"electron": "38.2.2",
"electron-builder": "^26.0.12",
"electron-log": "^5.4.3",
"electron-vite": "^4.0.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"fast-glob": "^3.3.3",
"fastify": "^5.6.2",
"naive-ui": "^2.43.1",
"node-taglib-sharp": "^6.0.1",
"prettier": "^3.6.2",
"sass": "^1.94.0",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.2.0",
"vite": "^7.2.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
"vue-tsc": "^3.1.3"
},
"pnpm": {
"overrides": {
"dmg-builder": "26.0.12",
"electron-builder-squirrel-windows": "26.0.12"
},
"onlyBuiltDependencies": [
"@applemusic-like-lyrics/core",
"@applemusic-like-lyrics/lyric",
"@applemusic-like-lyrics/vue",
"@parcel/watcher",
"core-js",
"electron",
"electron-winstaller",
"esbuild",
"sharp",
"vue-demi"
]
}
}
}

12013
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

6
public/fonts/font.css Normal file
View File

@@ -0,0 +1,6 @@
@font-face {
font-family: "logo";
src: url("./logo.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}

BIN
public/fonts/logo.woff2 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icons/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/icons/icon.icns Normal file

Binary file not shown.

BIN
public/icons/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
public/icons/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

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