🦄 refactor: convert to TypeScript #174

This commit is contained in:
imsyy
2024-09-26 11:57:23 +08:00
parent 8a842aa6d6
commit 2b6d68ecbd
508 changed files with 32112 additions and 30991 deletions

View File

@@ -1,38 +1,6 @@
# 程序配置
## 程序名称
MAIN_VITE_TITLE = "SPlayer"
## 程序主端口
MAIN_VITE_MAIN_PORT = 7899
## 程序开发环境运行端口
MAIN_VITE_DEV_PORT = 6944
# 全局 API 配置
## API 运行地址
MAIN_VITE_SERVER_HOST = 127.0.0.1
## API 运行端口
MAIN_VITE_SERVER_PORT = 11451
## API 在线地址( 网址结尾不要加 /
### 用于非客户端( 浏览器环境
RENDERER_VITE_SERVER_URL = /api
# 程序信息
RENDERER_VITE_SITE_TITLE = "SPlayer"
RENDERER_VITE_SITE_ANTHOR = "無名"
RENDERER_VITE_SITE_KEYWORDS = "SPlayer,云音乐,播放器,在线音乐,在线播放器,音乐播放器"
RENDERER_VITE_SITE_DES = "一个简约的在线音乐播放器具有音乐搜索、播放、每日推荐、私人FM、歌词显示、歌曲评论、网易云登录与云盘等功能"
RENDERER_VITE_SITE_URL = "imsyy.top"
# Cookie
## 咪咕音乐 Cookie
MAIN_VITE_MIGU_COOKIE = ""
# 公告配置
## 若无需公告,请将标题或内容任意一项设为空即可
## 公告类型
RENDERER_VITE_ANN_TYPE = "info"
## 公告标题
RENDERER_VITE_ANN_TITLE = ""
## 公告内容
RENDERER_VITE_ANN_CONTENT = ""
## 公告时长(毫秒)不可超过 999999
RENDERER_VITE_ANN_DURATION = 8000
## WEB 端口
VITE_WEB_PORT = 14558
## API 端口
VITE_SERVER_PORT = 25884
## API 地址 - 结尾不要加 /
VITE_API_URL = /api/netease

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

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

View File

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

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ coverage
*.local
out
.env
auto-imports.d.ts
components.d.ts
# Editor directories and files
.vscode/*

4
.npmrc
View File

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

View File

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

7
.prettierrc.json Normal file
View File

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

View File

@@ -1,8 +0,0 @@
# 是否使用单引号而不是双引号
singleQuote: false
# 是否在语句末尾使用分号
semi: true
# 每行的最大打印宽度
printWidth: 100
# 是否在对象和数组的末尾加上逗号
trailingComma: all

View File

@@ -1,5 +1,5 @@
# build
FROM node:18-alpine as builder
FROM node:20-alpine AS builder
RUN apk update && apk add --no-cache git
@@ -11,12 +11,13 @@ 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.25.3-alpine-slim as app
FROM nginx:1.27-alpine-slim AS app
COPY --from=builder /app/out/renderer /usr/share/nginx/html

213
README.md
View File

@@ -12,11 +12,11 @@
>
> - 请务必遵守 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 许可协议
> - 在您的修改、演绎、分发或派生项目中,必须同样采用 **AGPL-3.0** 许可协议,**并在适当的位置包含本项目的许可和版权信息**
> - **禁止用于售卖或其他商业用途**,如若发现,作者保留追究法律责任的权利
> - 若发现未遵守 **AGPL-3.0** 许可协议的行为,**本项目将永久停更**
> - **禁止用于售卖或其他盈利用途**,如若发现,作者保留追究法律责任的权利
> - 禁止在二开项目中修改程序原版权信息( 您可以添加二开作者信息
> - 感谢您的尊重与理解
- 本项目采用 [Vue 3](https://cn.vuejs.org/) 全家桶和 [Naïve UI](https://www.naiveui.com/) 组件库及 [Electron](https://www.electronjs.org/zh/docs/latest/) 开发
- 本项目采用 [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`,其他平台可自行解决兼容性后进行构建
- 仅对移动端做了基础适配,**不保证功能全部可用**
@@ -33,11 +33,13 @@
- ✨ 支持扫码登录
- 📱 支持手机号登录
- 📅 自动进行每日签到及云贝签到
- 🎨 封面主题色自适应
- 🌚 Light / Dark 模式自动切换
- 💻 支持切换为本地播放器,此模式将不会连接网络
- 🎨 封面主题色自适应,支持全站着色
- 🌚 Light / Dark / Auto 模式自动切换
- 📁 本地歌曲管理及分类(建议先使用 [音乐标签](https://www.cnblogs.com/vinlxc/p/11347744.html) 进行匹配后再使用)
- 📁 简易的本地音乐标签编辑及封面修改
- 🎵 **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- ⬇️ 下载歌曲(最高支持 Hi-Res
- ⬇️ 下载歌曲( 最高支持 Hi-Res,需具有相应会员账号
- 新建歌单及歌单编辑
- ❤️ 收藏 / 取消收藏歌单或歌手
- 🎶 每日推荐歌曲
@@ -52,8 +54,7 @@
- 🎶 音乐频谱显示
- ⏭️ 音乐渐入渐出
- 🔄 支持 PWA
- 💬 支持评论区及评论点赞
- 🌓 明暗模式自动 / 手动切换
- 💬 支持评论区
- 📱 移动端基础适配
- ~~🌐 `i18n` 支持~~
@@ -128,7 +129,7 @@
docker build -t splayer .
# 运行
docker run -d --name SPlayer -p 7899:7899 splayer
docker run -d --name SPlayer -p 25884:25884 splayer
# 或使用 Docker Compose
docker-compose up -d
```
@@ -142,10 +143,10 @@ docker pull imsyy/splayer:latest
docker pull ghcr.io/imsyy/splayer:latest
# 运行
docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
docker run -d --name SPlayer -p 25884:25884 imsyy/splayer:latest
```
以上步骤成功后,将会在本地 [localhost:7899](http://localhost:7899/) 启动,如需更换端口,请自行修改命令行中的端口号
以上步骤成功后,将会在本地 [localhost:25884](http://localhost:25884/) 启动,如需更换端口,请自行修改命令行中的端口号
## ⚙️ Vercel 部署
@@ -154,10 +155,10 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
1. 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目,并成功取得在线访问地址
2. 点击本仓库右上角的 `Fork`,复制本仓库到你的 `GitHub` 账号
3. 复制 `/.env.example` 文件并重命名为 `/.env`
4.`.env` 文件中的 `RENDERER_VITE_SERVER_URL` 改为第一步得到的 API 地址
4.`.env` 文件中的 `VITE_API_URL` 改为第一步得到的 API 地址
```js
RENDERER_VITE_SERVER_URL = "https://example.com";
VITE_API_URL = "https://example.com";
```
5. 将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
@@ -171,19 +172,17 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
1. 重复 `⚙️ Vercel 部署` 中的 1 - 4 步骤
2. 克隆仓库
> 将链接中的 example/repository.git 替换为你要克隆的实际仓库的地址
```bash
git clone https://github.com/example/repository.git
git clone https://github.com/imsyy/SPlayer.git
```
3. 安装依赖
```bash
pnpm install
# 或
# 或
yarn install
# 或
# 或
npm install
```
@@ -191,9 +190,9 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
```bash
pnpm build
# 或
# 或
yarn build
# 或
# 或
npm build
```
@@ -226,6 +225,7 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
- [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
- [YesPlayMusic](https://github.com/qier222/YesPlayMusic)
- [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server)
- [applemusic-like-lyrics](https://github.com/Steve-xmh/applemusic-like-lyrics)
- [Vue-mmPlayer](https://github.com/maomao1996/Vue-mmPlayer)
- [refined-now-playing-netease](https://github.com/solstice23/refined-now-playing-netease)
- [material-color-utilities](https://github.com/material-foundation/material-color-utilities)
@@ -251,179 +251,6 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
5. **社区参与:** 欢迎社区的参与和贡献,我们鼓励开发者一同改进和维护本项目
6. **许可证链接:** 请阅读 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 了解更多详情
## 📂 目录结构
<details>
<summary>查看目录结构详情</summary>
> ChatGPT 写的,如有错误,请见谅
```dir
├── auto-imports.d.ts # 自动导入
├── components.d.ts # 自动导入
├── docker-compose.yml # Docker Compose
├── Dockerfile # Docker
├── electron # Electron
│   ├── main # Electron 主进程
│   │   ├── index.js # 主进程入口
│   │   ├── mainIpcMain.js # 主进程与渲染进程通信
│   │   ├── startMainServer.js # 启动主进程服务器
│   │   ├── startNcmServer.js # 启动网易云音乐服务
│   │   └── utils # 主进程工具函数
│   │   ├── checkUpdates.js # 检查更新
│   │   ├── createGlobalShortcut.js # 创建全局快捷键
│   │   ├── createSystemTray.js # 创建系统托盘
│   │   ├── getNeteaseMusicUrl.js # 解灰
│   │   ├── kwDES.js # DES加密算法
│   │   └── readDirAsync.js # 异步读取目录
│   └── preload # Electron 预加载脚本
│   └── index.js # 预加载脚本入口文件
├── electron-builder.yml # Electron Builder
├── electron.vite.config.js # Electron Vite
├── index.html # 主页面 HTML
├── LICENSE # 项目许可证
├── nginx.conf # Nginx 配置
├── src # 项目源代码
│   ├── api # API 相关
│   │   ├── ./..
│   ├── App.vue # 根组件
│   ├── assets # 静态资源
│   │   ├── emoji.json # 表情数据
│   │   ├── icon.json # 图标数据
│   │   └── themeColor.json # 主题颜色数据
│   ├── components # 组件目录
│   │   ├── Cover # 封面相关组件目录
│   │   │   ├── CoverDropdown.vue # 封面下拉组件
│   │   │   ├── MainCover.vue # 主封面组件
│   │   │   ├── SpecialCoverCard.vue # 特殊封面卡片组件
│   │   │   └── SpecialCover.vue # 特殊封面组件
│   │   ├── Global # 全局组件目录
│   │   │   ├── MainLayout.vue # 主布局组件
│   │   │   ├── Menu.vue # 菜单组件
│   │   │   ├── Pagination.vue # 分页组件
│   │   │   ├── Playlist.vue # 歌单组件
│   │   │   ├── Provider.vue # 全局化配置组件
│   │   │   └── SvgIcon.vue # SVG 图标组件
│   │   ├── List # 列表组件目录
│   │   │   ├── CommentList.vue # 评论列表组件
│   │   │   ├── SongListDropdown.vue # 歌曲下拉组件
│   │   │   └── SongList.vue # 歌曲列表组件
│   │   ├── Modal # 弹窗相关组件目录
│   │   │   ├── AddPlaylist.vue # 添加歌单组件
│   │   │   ├── CloudSongMatch.vue # 云盘歌曲匹配组件
│   │   │   ├── CreatePlaylist.vue # 创建歌单组件
│   │   │   ├── DownloadSong.vue # 下载歌曲组件
│   │   │   ├── LoginPhone.vue # 手机登录组件
│   │   │   ├── LoginQRCode.vue # 二维码登录组件
│   │   │   ├── Login.vue # 登录组件
│   │   │   ├── PlaylistUpdate.vue # 歌单编辑组件
│   │   │   └── UpCloudSong.vue # 上传云盘歌曲组件
│   │   ├── Nav # 导航相关组件目录
│   │   │   ├── MainNav.vue # 主导航组件
│   │   │   └── UserData.vue # 用户数据组件
│   │   ├── Player # 播放器相关组件目录
│   │   │   ├── CountDown.vue # 倒计时组件
│   │   │   ├── FullPlayer.vue # 全屏播放器组件
│   │   │   ├── Lyric.vue # 歌词组件
│   │   │   ├── MainControl.vue # 主控制组件
│   │   │   ├── PlayerControl.vue # 播放器控制组件
│   │   │   ├── PlayerCover.vue # 播放器封面组件
│ │ │ └── PrivateFm.vue # 私人 FM 组件
│ │ ├── Search # 搜索相关组件
│ │ │ ├── SearchHot.vue # 热门搜索组件
│ │ │ ├── SearchInp.vue # 搜索输入组件
│ │ │ └── SearchSuggestions.vue # 搜索建议组件
│ │ └── WinDom # 窗口 DOM 相关组件
│ │ └── TitleBar.vue # 标题栏组件
│ ├── main.js # Vue 应用的入口文件
│ ├── router # Vue Router 相关文件夹
│ │ ├── index.js # Vue Router 入口文件
│ │ └── routes.js # 路由配置文件
│ ├── stores # Vuex Store 相关文件夹
│ │ ├── indexedDB.js # IndexedDB 数据库相关文件
│ │ ├── index.js # Vuex Store 入口文件
│ │ ├── musicData.js # 音乐数据相关文件
│ │ ├── siteData.js # 网站数据相关文件
│ │ ├── siteSettings.js # 网站设置相关文件
│ │ └── siteStatus.js # 网站状态相关文件
│ ├── style # 样式相关文件夹
│ │ ├── animate.scss # 动画样式文件
│ │ └── main.scss # 主样式文件
│ ├── utils # 工具函数文件夹
│ │ ├── auth.js # 认证相关函数
│ │ ├── base64.js # Base64编码解码相关函数
│ │ ├── color-utils.js # 颜色工具函数
│ │ ├── cover-color.js # 封面颜色相关函数
│ │ ├── debounce.js # 防抖函数
│ │ ├── formatData.js # 数据格式化函数
│ │ ├── formRules.js # 表单验证规则
│ │ ├── globalEvents.js # 全局事件处理函数
│ │ ├── globalShortcut.js # 全局快捷键相关函数
│ │ ├── helper.js # 辅助函数
│ │ ├── parseLyric.js # 解析歌词函数
│ │ ├── Player.js # 播放器控制相关函数
│ │ ├── request.js # 网络请求相关函数
│ │ ├── throttle.js # 节流函数
│ │ ├── timeTools.js # 时间工具函数
│ │ └── userSignIn.js # 用户登录相关函数
│ └── views # Vue组件文件夹
│ ├── Artist # 艺术家相关组件
│ │ ├── albums.vue # 艺术家专辑组件
│ │ ├── hot.vue # 艺术家热门组件
│ │ ├── index.vue # 艺术家主组件
│ │ ├── songs.vue # 艺术家歌曲组件
│ │ └── videos.vue # 艺术家视频组件
│ ├── Cloud.vue # 云盘组件
│ ├── Comment.vue # 评论组件
│ ├── DailySongs.vue # 每日推荐组件
│ ├── Discover # 发现音乐相关组件
│ │ ├── artists.vue # 发现音乐艺术家组件
│ │ ├── index.vue # 发现音乐主组件
│ │ ├── new.vue # 发现音乐新歌组件
│ │ ├── playlists.vue # 发现音乐歌单组件
│ │ └── toplists.vue # 发现音乐排行榜组件
│ ├── History.vue # 历史记录组件
│ ├── Home.vue # 主页组件
│ ├── Like # 我喜欢的相关组件
│ │ ├── albums.vue # 我喜欢的专辑组件
│ │ ├── artists.vue # 我喜欢的艺术家组件
│ │ ├── index.vue # 我喜欢的主组件
│ │ ├── playlists.vue # 我喜欢的歌单组件
│ │ └── videos.vue # 我喜欢的视频组件
│ ├── List # 列表相关组件
│ │ ├── album.vue # 专辑组件
│ │ └── playlist.vue # 歌单组件
│ │ └── dj.vue # 电台组件
│ ├── Local # 本地音乐相关组件
│ │ ├── albums.vue # 本地音乐专辑组件
│ │ ├── artists.vue # 本地音乐艺术家组件
│ │ ├── index.vue # 本地音乐主组件
│ │ └── songs.vue # 本地音乐歌曲组件
│ ├── Player.vue # 视频播放器组件
│ ├── Dj # 电台相关组件
│ │ └── index.vue # 电台主组件
│ │ └── type.vue # 电台分类组件
│ ├── Search # 搜索相关组件
│ │ ├── albums.vue # 搜索专辑组件
│ │ ├── artists.vue # 搜索艺术家组件
│   │   ├── index.vue # 搜索主组件
│   │   ├── playlists.vue # 搜索歌单组件
│   │   ├── songs.vue # 搜索歌曲组件
│   │   └── videos.vue # 搜索视频组件
│   │   └── djs.vue # 搜索电台组件
│   ├── Setting # 设置相关组件
│   │   └── index.vue # 设置主组件
│   ├── Song.vue
│   ├── State
│   │   ├── 403.vue
│   │   ├── 404.vue
│   │   └── 500.vue
│   └── Test.vue
└── vercel.json # Vercel 部署配置
```
</details>
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=imsyy/SPlayer&type=Date)](https://star-history.com/#imsyy/SPlayer&Date)

226
auto-imports.d.ts vendored
View File

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

89
components.d.ts vendored
View File

@@ -1,38 +1,52 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AddPlaylist: typeof import('./src/components/Modal/AddPlaylist.vue')['default']
CloudSongMatch: typeof import('./src/components/Modal/CloudSongMatch.vue')['default']
AboutSetting: typeof import('./src/components/Setting/AboutSetting.vue')['default']
ArtistList: typeof import('./src/components/List/ArtistList.vue')['default']
BatchList: typeof import('./src/components/Modal/batchList.vue')['default']
CloudMatch: typeof import('./src/components/Modal/CloudMatch.vue')['default']
CommentList: typeof import('./src/components/List/CommentList.vue')['default']
CountDown: typeof import('./src/components/Player/CountDown.vue')['default']
CoverDropdown: typeof import('./src/components/Cover/CoverDropdown.vue')['default']
CoverPlayBtn: typeof import('./src/components/Cover/CoverPlayBtn.vue')['default']
CoverList: typeof import('./src/components/List/CoverList.vue')['default']
CoverMenu: typeof import('./src/components/Menu/CoverMenu.vue')['default']
CreatePlaylist: typeof import('./src/components/Modal/CreatePlaylist.vue')['default']
DownloadSong: typeof import('./src/components/Modal/DownloadSong.vue')['default']
FullPlayer: typeof import('./src/components/Player/FullPlayer.vue')['default']
GeneralSetting: typeof import('./src/components/Setting/GeneralSetting.vue')['default']
JumpArtist: typeof import('./src/components/Modal/JumpArtist.vue')['default']
KeyboardSetting: typeof import('./src/components/Setting/KeyboardSetting.vue')['default']
LocalSetting: typeof import('./src/components/Setting/LocalSetting.vue')['default']
Login: typeof import('./src/components/Modal/Login.vue')['default']
LoginPhone: typeof import('./src/components/Modal/LoginPhone.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/LoginQRCode.vue')['default']
Lyric: typeof import('./src/components/Player/Lyric.vue')['default']
MainControl: typeof import('./src/components/Player/MainControl.vue')['default']
MainCover: typeof import('./src/components/Cover/MainCover.vue')['default']
MainLayout: typeof import('./src/components/Global/MainLayout.vue')['default']
MainNav: typeof import('./src/components/Nav/MainNav.vue')['default']
Menu: typeof import('./src/components/Global/Menu.vue')['default']
LoginPhone: typeof import('./src/components/Modal/loginPhone.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/loginQRCode.vue')['default']
LyricsSetting: typeof import('./src/components/Setting/LyricsSetting.vue')['default']
MainAMLyric: typeof import('./src/components/Player/MainAMLyric.vue')['default']
MainLyric: typeof import('./src/components/Player/MainLyric.vue')['default']
MainPlayer: typeof import('./src/components/Player/MainPlayer.vue')['default']
MainPlayList: typeof import('./src/components/Player/MainPlayList.vue')['default']
MainSetting: typeof import('./src/components/Setting/MainSetting.vue')['default']
Menu: typeof import('./src/components/Layout/Menu.vue')['default']
NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert']
Nav: typeof import('./src/components/Layout/Nav.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
@@ -41,32 +55,37 @@ declare module 'vue' {
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFloatButton: typeof import('naive-ui')['NFloatButton']
NFloatButtonGroup: typeof import('naive-ui')['NFloatButtonGroup']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NH4: typeof import('naive-ui')['NH4']
NH6: typeof import('naive-ui')['NH6']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
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']
NPagination: typeof import('naive-ui')['NPagination']
NOl: typeof import('naive-ui')['NOl']
NP: typeof import('naive-ui')['NP']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
@@ -86,27 +105,35 @@ declare module 'vue' {
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NVirtualList: typeof import('naive-ui')['NVirtualList']
Pagination: typeof import('./src/components/Global/Pagination.vue')['default']
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']
Playlist: typeof import('./src/components/Global/Playlist.vue')['default']
PlaylistUpdate: typeof import('./src/components/Modal/PlaylistUpdate.vue')['default']
PrivateFm: typeof import('./src/components/Player/PrivateFm.vue')['default']
PlayerData: typeof import('./src/components/Player/PlayerData.vue')['default']
PlayerMenu: typeof import('./src/components/Player/PlayerMenu.vue')['default']
PlayerSpectrum: typeof import('./src/components/Player/PlayerSpectrum.vue')['default']
PlaylistAdd: typeof import('./src/components/Modal/PlaylistAdd.vue')['default']
PlaySetting: typeof import('./src/components/Setting/PlaySetting.vue')['default']
Provider: typeof import('./src/components/Global/Provider.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchHot: typeof import('./src/components/Search/SearchHot.vue')['default']
SearchDefault: typeof import('./src/components/Search/SearchDefault.vue')['default']
SearchInp: typeof import('./src/components/Search/SearchInp.vue')['default']
SearchSuggestions: typeof import('./src/components/Search/SearchSuggestions.vue')['default']
SearchInpMenu: typeof import('./src/components/Menu/SearchInpMenu.vue')['default']
SearchSuggest: typeof import('./src/components/Search/SearchSuggest.vue')['default']
Sider: typeof import('./src/components/Layout/Sider.vue')['default']
SongDataCard: typeof import('./src/components/Card/SongDataCard.vue')['default']
SongInfoEditor: typeof import('./src/components/Modal/SongInfoEditor.vue')['default']
SongList: typeof import('./src/components/List/SongList.vue')['default']
SongListDrawer: typeof import('./src/components/List/SongListDrawer.vue')['default']
SongListDropdown: typeof import('./src/components/List/SongListDropdown.vue')['default']
SpecialCover: typeof import('./src/components/Cover/SpecialCover.vue')['default']
SpecialCoverCard: typeof import('./src/components/Cover/SpecialCoverCard.vue')['default']
Spectrum: typeof import('./src/components/Player/Spectrum.vue')['default']
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
TitleBar: typeof import('./src/components/WinDom/TitleBar.vue')['default']
UpCloudSong: typeof import('./src/components/Modal/UpCloudSong.vue')['default']
UserData: typeof import('./src/components/Nav/UserData.vue')['default']
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']
UpdatePlaylist: typeof import('./src/components/Modal/UpdatePlaylist.vue')['default']
User: typeof import('./src/components/Layout/User.vue')['default']
UserAgreement: typeof import('./src/components/Modal/UserAgreement.vue')['default']
}
}

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

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

View File

@@ -8,5 +8,5 @@ services:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
ports:
- 7899:7899
- 25884:25884
restart: always

View File

@@ -5,9 +5,12 @@ productName: SPlayer
copyright: Copyright © imsyy 2023
# 构建资源所在的目录
directories:
buildResources: build
# 包含在最终应用程序构建中的文件列表,这里使用通配符 ! 表示排除不需要的文件
buildResources: public
# 包含在最终应用程序构建中的文件列表
# 使用通配符 ! 表示排除不需要的文件
files:
- "public/**"
- "out/**"
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
@@ -16,17 +19,20 @@ files:
# 哪些文件将不会被压缩,而是解压到构建目录
asarUnpack:
- public/**
# Windows 平台配置
win:
# 可执行文件名
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/imgs/icons/favicon-512x512.png
icon: public/icons/favicon-512x512.png
# 构建类型
target: nsis
target:
# 安装版
- nsis
# 打包版
- portable
# NSIS 安装器配置
nsis:
# 一键式安装程序还是辅助安装程序
# 是否一键式安装
oneClick: false
# 安装程序的生成名称
artifactName: ${productName}-${version}-setup.${ext}
@@ -41,23 +47,23 @@ nsis:
# 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true
# 安装包图标
installerIcon: public/imgs/icons/favicon.ico
installerIcon: public/icons/favicon.ico
# 卸载命令图标
uninstallerIcon: public/imgs/icons/favicon.ico
uninstallerIcon: public/icons/favicon.ico
# macOS 平台配置
mac:
# 可执行文件名
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/imgs/icons/favicon-512x512.png
icon: public/icons/favicon-512x512.png
# 权限继承的文件路径
entitlementsInherit: build/entitlements.mac.plist
# 扩展信息,如权限描述
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.
- 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
@@ -74,13 +80,13 @@ mac:
# macOS 平台的 DMG 配置
dmg:
# DMG 文件的生成名称
artifactName: ${productName}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
# Linux 平台配置
linux:
# 可执行文件名
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/imgs/icons/favicon-512x512.png
icon: public/icons/favicon-512x512.png
# 构建类型
target:
- pacman
@@ -95,7 +101,7 @@ linux:
# AppImage 配置
appImage:
# AppImage 文件的生成名称
artifactName: ${productName}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
# 是否在构建之前重新编译原生模块
npmRebuild: false
# 自动更新的配置

View File

@@ -1,160 +0,0 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin, loadEnv } from "electron-vite";
import { NaiveUiResolver } from "unplugin-vue-components/resolvers";
import { VitePWA } from "vite-plugin-pwa";
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 checkPort from "./electron/main/utils/checkPort";
export default defineConfig(async ({ mode }) => {
// 读取环境变量
const getEnv = (name) => {
return loadEnv(mode, process.cwd())[name];
};
// 获取端口
const devPort = await checkPort(getEnv("MAIN_VITE_DEV_PORT"));
const serverPort = await checkPort(getEnv("MAIN_VITE_SERVER_PORT"));
// 返回配置
return {
// 主进程
main: {
resolve: {
alias: {
"@main": resolve(__dirname, "electron/main"),
},
},
plugins: [externalizeDepsPlugin()],
build: {
publicDir: resolve(__dirname, "public"),
rollupOptions: {
input: {
index: resolve(__dirname, "electron/main/index.js"),
},
},
},
},
// 预渲染
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.mjs"),
},
},
},
},
// 渲染进程
renderer: {
resolve: {
extensions: [".js", ".vue", ".json"],
alias: {
"@": resolve(__dirname, "src"),
},
},
plugins: [
vue(),
AutoImport({
imports: [
"vue",
{
"naive-ui": ["useDialog", "useMessage", "useNotification", "useLoadingBar"],
},
],
}),
Components({
resolvers: [NaiveUiResolver()],
}),
// viteCompression
viteCompression(),
// PWA
VitePWA({
registerType: "autoUpdate",
workbox: {
clientsClaim: true,
skipWaiting: true,
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /(.*?)\.(woff2|woff|ttf)/,
handler: "CacheFirst",
options: {
cacheName: "file-cache",
},
},
{
urlPattern: /(.*?)\.(webp|png|jpe?g|svg|gif|bmp|psd|tiff|tga|eps)/,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
},
},
],
},
manifest: {
name: getEnv("RENDERER_VITE_SITE_TITLE"),
short_name: getEnv("RENDERER_VITE_SITE_TITLE"),
description: getEnv("RENDERER_VITE_SITE_DES"),
display: "standalone",
start_url: "/",
theme_color: "#fff",
background_color: "#efefef",
icons: [
{
src: "/imgs/icons/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "/imgs/icons/favicon-96x96.png",
sizes: "96x96",
type: "image/png",
},
{
src: "/imgs/icons/favicon-256x256.png",
sizes: "256x256",
type: "image/png",
},
{
src: "/imgs/icons/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
// 服务器配置
server: {
port: devPort,
// 代理
proxy: {
"/api": {
target: `http://${getEnv("MAIN_VITE_SERVER_HOST")}:${serverPort}`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
// 构建
root: ".",
build: {
minify: "terser",
publicDir: resolve(__dirname, "public"),
rollupOptions: {
input: {
index: resolve(__dirname, "index.html"),
},
},
terserOptions: {
compress: {
pure_funcs: ["console.log"],
},
},
sourcemap: false,
},
},
};
});

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

@@ -0,0 +1,113 @@
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 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"),
loading: resolve(__dirname, "web/loading.html"),
},
},
},
},
// 预加载
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.ts"),
},
},
},
},
// 渲染进程
renderer: {
root: ".",
plugins: [
vue(),
AutoImport({
imports: [
"vue",
"vue-router",
"@vueuse/core",
{
"naive-ui": ["useDialog", "useMessage", "useNotification", "useLoadingBar"],
},
],
eslintrc: {
enabled: true,
filepath: "./.eslintrc-auto-import.json",
},
}),
Components({
resolvers: [NaiveUiResolver()],
}),
viteCompression(),
wasm(),
],
resolve: {
alias: {
"@": resolve(__dirname, "src/"),
},
},
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"),
},
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" />

View File

@@ -1,256 +0,0 @@
import { join } from "path";
import { app, protocol, shell, BrowserWindow, globalShortcut, nativeImage } from "electron";
import { platform, optimizer, is } from "@electron-toolkit/utils";
import { startNcmServer } from "@main/startNcmServer";
import { startMainServer } from "@main/startMainServer";
import createSystemTray from "@main/utils/createSystemTray";
import createGlobalShortcut from "@main/utils/createGlobalShortcut";
import mainIpcMain from "@main/mainIpcMain";
import Store from "electron-store";
import log from "electron-log";
// 屏蔽报错
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 配置 log
log.transports.file.resolvePathFn = () =>
join(app.getPath("documents"), "/SPlayer/SPlayer-log.txt");
// 设置日志文件的最大大小为 2 MB
log.transports.file.maxSize = 2 * 1024 * 1024;
// 绑定 console 事件
console.error = log.error.bind(log);
console.warn = log.warn.bind(log);
console.info = log.info.bind(log);
console.debug = log.debug.bind(log);
// 主进程
class MainProcess {
constructor() {
// 主窗口
this.mainWindow = null;
// 主代理
this.mainServer = null;
// 网易云 API
this.ncmServer = null;
// Store
this.store = new Store({
// 窗口大小
windowSize: {
width: { type: "number", default: 1280 },
height: { type: "number", default: 740 },
},
});
// 设置应用程序名称
if (process.platform === "win32") app.setAppUserModelId(app.getName());
// 初始化
this.checkApp().then(async (lockObtained) => {
if (lockObtained) {
await this.init();
}
});
}
// 单例锁
async checkApp() {
if (!app.requestSingleInstanceLock()) {
log.error("已有一个程序正在运行,本次启动阻止");
app.quit();
// 未获得锁
return false;
}
// 聚焦到当前程序
else {
app.on("second-instance", () => {
if (this.mainWindow) {
this.mainWindow.show();
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
}
});
// 获得锁
return true;
}
}
// 初始化程序
async init() {
log.info("主进程初始化");
// 启动网易云 API
try {
this.ncmServer = await startNcmServer({
port: import.meta.env.MAIN_VITE_SERVER_PORT,
host: import.meta.env.MAIN_VITE_SERVER_HOST,
});
} catch (error) {
console.error("启动网易云 API 失败:", error);
}
// 非开发环境启动代理
if (!is.dev) {
this.mainServer = await startMainServer();
}
// 注册应用协议
app.setAsDefaultProtocolClient("SPlayer");
// 应用程序准备好之前注册
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } },
]);
// 主应用程序事件
this.mainAppEvents();
}
// 创建主窗口
createWindow() {
// 创建浏览器窗口
this.mainWindow = new BrowserWindow({
title: app.getName() || "SPlayer",
width: this.store.get("windowSize.width") || 1280, // 窗口宽度
height: this.store.get("windowSize.height") || 740, // 窗口高度
minHeight: 700, // 最小高度
minWidth: 1200, // 最小宽度
center: true, // 是否出现在屏幕居中的位置
show: false, // 初始时不显示窗口
frame: false, // 无边框
// transparent: true, // 透明窗口
titleBarStyle: "customButtonsOnHover", // Macos 隐藏菜单栏
autoHideMenuBar: true, // 失去焦点后自动隐藏菜单栏
// 图标配置
icon: nativeImage.createFromPath(join(__dirname, "../../public/imgs/icons/favicon.png")),
// 预加载
webPreferences: {
// devTools: is.dev,
preload: join(__dirname, "../preload/index.mjs"),
sandbox: false,
webSecurity: false,
hardwareAcceleration: true,
},
});
// 窗口准备就绪时显示窗口
this.mainWindow.once("ready-to-show", () => {
this.mainWindow.show();
// mainWindow.maximize();
this.store.set("windowSize", this.mainWindow.getBounds());
});
// 主窗口事件
this.mainWindowEvents();
// 设置窗口打开处理程序
this.mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
// 渲染路径
// 在开发模式
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
this.mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
}
// 生产模式
else {
console.log("生产模式渲染端口: " + process.env.MAIN_VITE_MAIN_PORT ?? 7899);
this.mainWindow.loadURL(`http://127.0.0.1:${process.env.MAIN_VITE_MAIN_PORT ?? 7899}`);
}
// 配置网络代理
const proxyRules = this.store.get("proxy");
if (proxyRules) {
this.mainWindow.webContents.session.setProxy({ proxyRules }, (result) => {
console.info("网络代理配置:", result);
});
}
}
// 主应用程序事件
mainAppEvents() {
app.whenReady().then(async () => {
// 创建主窗口
this.createWindow();
// 引入主 Ipc
mainIpcMain(this.mainWindow, this.store);
// 系统托盘
createSystemTray(this.mainWindow);
// 注册快捷键
createGlobalShortcut(this.mainWindow);
});
// 开发环境下 F12 打开控制台
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// 在 macOS 上,当单击 Dock 图标且没有其他窗口时,通常会重新创建窗口
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) this.createWindow();
});
// 自定义协议
app.on("open-url", (_, url) => {
console.log("Received custom protocol URL:", url);
});
// 将要退出
app.on("will-quit", () => {
// 注销全部快捷键
globalShortcut.unregisterAll();
});
// 当所有窗口都关闭时退出应用macOS 除外
app.on("window-all-closed", () => {
if (!platform.isMacOS) {
app.quit();
}
});
}
// 主窗口事件
mainWindowEvents() {
this.mainWindow.on("show", () => {
this.mainWindow.webContents.send("lyricsScroll");
});
// this.mainWindow.on("hide", () => {
// console.info("窗口隐藏");
// });
this.mainWindow.on("focus", () => {
this.mainWindow.webContents.send("lyricsScroll");
});
// this.mainWindow.on("blur", () => {
// console.info("窗口失去焦点");
// });
this.mainWindow.on("maximize", () => {
this.mainWindow.webContents.send("windowState", true);
});
this.mainWindow.on("unmaximize", () => {
this.mainWindow.webContents.send("windowState", false);
});
this.mainWindow.on("resize", () => {
this.store.set("windowSize", this.mainWindow.getBounds());
});
this.mainWindow.on("move", () => {
this.store.set("windowSize", this.mainWindow.getBounds());
});
// 窗口关闭
this.mainWindow.on("close", (event) => {
event.preventDefault();
if (!app.isQuiting) {
this.mainWindow.hide();
} else {
app.exit();
}
});
}
}
new MainProcess();

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

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

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

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

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

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

View File

@@ -1,321 +0,0 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { configureAutoUpdater } from "@main/utils/checkUpdates";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { download } from "electron-dl";
import { getFonts } from "font-list";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import axios from "axios";
import fs from "fs/promises";
/**
* 监听主进程的 IPC 事件
* @param {BrowserWindow} win - 要监听 IPC 事件的程序窗口
* @param {Store} store - 存储对象
*/
const mainIpcMain = (win, store) => {
// 窗口操作部分
ipcMain.on("window-min", (ev) => {
// 阻止最小化
ev.preventDefault();
// 最小化
win.minimize();
});
ipcMain.on("window-maxOrRestore", (ev) => {
const winSizeState = win.isMaximized();
winSizeState ? win.restore() : win.maximize();
ev.reply("windowState", win.isMaximized());
});
ipcMain.on("window-restore", () => {
win.restore();
});
ipcMain.on("window-hide", () => {
win.hide();
});
ipcMain.on("window-close", () => {
win.close();
app.isQuiting = true;
app.quit();
});
ipcMain.on("window-relaunch", () => {
app.isQuiting = true;
app.relaunch();
app.quit();
});
ipcMain.on("check-updates", () => {
console.info("开始检查更新");
configureAutoUpdater();
});
// 显示进度
ipcMain.on("setProgressBar", (_, val) => {
if (val === "close") {
win.setProgressBar(-1);
return false;
}
win.setProgressBar(val / 100);
});
// 解灰
ipcMain.handle("getMusicNumUrl", async (_, data) => {
// 解析传入数据
const songData = JSON.parse(data);
const songName = `${songData?.name}-${songData?.artists?.[0].name}`;
console.log("开始解灰:", songName);
const url = await getNeteaseMusicUrl(songName);
console.log("解灰地址:", url);
return url;
});
// bili 链接解析
ipcMain.handle("getBiliUrlData", async (_, url) => {
const data = await getBiliUrlBase64(url);
return data;
});
// 默认音乐文件夹
ipcMain.handle("getdefaultMusicPath", async () => {
const path = app.getPath("music");
return path;
});
// 选择文件夹
ipcMain.handle("selectDir", async (_, isChooseDl = false) => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
title: isChooseDl ? "选择下载目录" : "选择添加目录",
defaultPath: isChooseDl ? app.getPath("downloads") : app.getPath("music"),
properties: ["openDirectory", "createDirectory"],
buttonLabel: "选择文件夹",
});
if (!canceled) {
const selectedDirectory = filePaths[0];
return selectedDirectory;
}
} catch (err) {
console.error("选择文件夹时发生错误:", err);
throw err;
}
});
// 读取文件夹内容
ipcMain.handle("getDirContents", async (_, selectedDir) => {
try {
// 使用 readDirAsync 函数递归地读取文件夹内容
const directoryContents = await readDirAsync(selectedDir);
return directoryContents;
} catch (err) {
console.error("读取文件夹内容时发生错误:", err);
throw err;
}
});
// 读取音乐歌词
ipcMain.handle("getMusicLyric", async (_, path) => {
try {
const data = await parseFile(path);
const lyric = data.common.lyrics;
if (lyric && lyric.length > 0) {
return lyric[0];
}
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
else {
const lrcFilePath = path.replace(/\.[^.]+$/, ".lrc");
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
// 返回读取的 lrc 数据,如果没有则返回 null
return lrcData || null;
}
} catch (error) {
console.error("读取音乐歌词出错:", error);
return null;
}
});
// 读取音乐封面
ipcMain.handle("getMusicCover", async (_, path) => {
try {
const data = await parseFile(path);
const picture = data.common.picture;
if (picture && picture.length > 0) {
const coverData = picture[0].data;
const coverFormat = picture[0].format;
return { coverData, coverFormat };
}
// 如果封面数据不存在,尝试读取同名的封面图片文件
else {
const coverFilePath = path.replace(/\.[^.]+$/, ".jpg");
const coverData = await fs.readFile(coverFilePath);
// 返回读取的封面图片数据,如果没有则返回 null
return coverData ? { coverData, coverFormat: "jpg" } : null;
}
} catch (error) {
console.error("读取音乐封面出错:", error);
return null;
}
});
// 执行复制操作
ipcMain.handle("copyData", async (_, data) => {
try {
clipboard.writeText(data);
return true;
} catch (error) {
console.error("复制操作出错:", error);
return false;
}
});
// 本地磁盘文件删除
ipcMain.handle("deleteFile", async (_, path) => {
try {
// 检查文件是否存在
if (fs.access(path)) {
// 尝试删除文件
fs.unlink(path);
console.log(`文件已删除:${path}`);
return true;
} else {
console.log(`文件不存在:${path}`);
return false;
}
} catch (err) {
console.error(`文件删除操作出错:${path}`, err);
return false;
}
});
// 打开歌曲目录
ipcMain.on("openSongLocal", (_, path) => {
try {
if (fs.access(path)) {
shell.showItemInFolder(path);
} else {
console.log(`文件不存在:${path}`);
}
} catch (error) {
console.error("打开歌曲目录时出错:", error);
}
});
// 下载文件至指定目录
ipcMain.handle("downloadFile", async (_, songData, options) => {
try {
const { url, data, lyric, name, type } = JSON.parse(songData);
const { path, downloadMeta, downloadCover, downloadLyrics } = JSON.parse(options);
if (fs.access(path)) {
console.info("开始下载:", name, url);
// 下载歌曲
const songDownload = await download(win, url, {
directory: path,
filename: `${name}.${type}`,
});
// 若关闭,则不进行元信息写入
if (!downloadMeta) return true;
// 下载封面
const coverDownload = await download(win, data.cover, {
directory: path,
filename: `${name}.jpg`,
});
// 读取歌曲文件
const songFile = File.createFromPath(songDownload.getSavePath());
// 生成图片信息
const songCover = Picture.fromPath(coverDownload.getSavePath());
// 保存修改后的元数据
Id3v2Settings.forceDefaultVersion = true;
Id3v2Settings.defaultVersion = 3;
songFile.tag.title = data.name || "未知曲目";
songFile.tag.album = data.album?.name || "未知专辑";
songFile.tag.performers = data?.artists?.map((ar) => ar.name) || ["未知艺术家"];
if (downloadLyrics) songFile.tag.lyrics = lyric;
if (downloadCover) songFile.tag.pictures = [songCover];
// 保存元信息
songFile.save();
songFile.dispose();
// 删除封面
await fs.unlink(coverDownload.getSavePath());
return true;
} else {
console.log(`目录不存在:${path}`);
return false;
}
} catch (error) {
console.error("下载文件时出错:", error);
return false;
}
});
// 读取系统全部字体
ipcMain.handle("getAllFonts", async () => {
try {
const fonts = await getFonts();
return fonts;
} catch (error) {
console.error("获取系统字体时出错:", error);
return [];
}
});
// 配置网络代理
ipcMain.on("set-proxy", (_, config) => {
console.log(config);
const proxyRules = `${config.protocol}://${config.server}:${config.port}`;
store.set("proxy", proxyRules);
win.webContents.session.setProxy({ proxyRules }, () => {
console.info("网络代理配置完成");
});
});
// 取消代理
ipcMain.on("remove-proxy", () => {
store.set("proxy", "");
win.webContents.session.setProxy({ proxyRules: "" }, () => {
console.info("取消网络代理配置");
});
});
};
/**
* 从 Bilibili 视频中获取文件的 Base64 数据
*
* @param {string} url - 要获取的文件的 URL
* @returns {Promise<string>} - 文件的 Base64 数据
*/
const getBiliUrlBase64 = async (url) => {
try {
const response = await axios.get(url, {
headers: {
Referer: "https://www.bilibili.com/",
"User-Agent": "okhttp/3.4.1",
},
responseType: "arraybuffer",
withCredentials: false,
});
// 将二进制数据转换为缓冲区
const buffer = toBuffer(response.data);
// 将缓冲区中的数据转换为 Base64 编码的字符串
const encodedData = buffer.toString("base64");
// 返回 Base64 编码的文件数据
return encodedData;
} catch (error) {
console.error("获取文件数据时发生错误:" + error);
return null;
}
};
/**
* 将数据转换为缓冲区( Buffer
*
* @param {ArrayBuffer|Buffer|Uint8Array} data - 要转换的数据
* @returns {Buffer} - 转换后的缓冲区
*/
const toBuffer = (data) => {
if (data instanceof Buffer) {
return data;
} else {
return Buffer.from(data);
}
};
export default mainIpcMain;

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,37 +0,0 @@
import net from "net";
/**
* 检查端口是否可用, 如果被占用或不可访问,则尝试下一个端口
* @param {number} port 端口号
* @param {number} [maxPort=65535] 端口号上限
* @returns {Promise<number>} 返回可用的端口号
*/
const checkPort = (port, maxPort = 65535) => {
return new Promise((resolve, reject) => {
if (port > maxPort) {
reject(new Error(`${port} 超出端口范围,无法找到可用端口`));
return;
}
port = Number(port);
const server = net.createServer();
server.listen(port, "0.0.0.0", () => {
server.once("close", () => {
resolve(port);
});
server.close();
});
server.on("error", (err) => {
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
resolve(checkPort(port + 1, maxPort));
} else {
reject(err);
}
});
});
};
export default checkPort;

View File

@@ -1,65 +0,0 @@
import { dialog } from "electron";
import { is } from "@electron-toolkit/utils";
import pkg from "electron-updater";
const { autoUpdater } = pkg;
// 更新弹窗
const hasNewVersion = (info) => {
dialog
.showMessageBox({
title: "发现新版本 v" + info.version,
message: "发现新版本 v" + info.version,
detail: "是否立即下载并安装新版本?",
buttons: ["立即下载", "取消"],
type: "question",
noLink: true,
})
.then((result) => {
if (result.response === 0) {
// 触发手动下载
autoUpdater.downloadUpdate();
}
});
};
export const configureAutoUpdater = () => {
if (is.dev) return false;
// 监听下载进度事件
autoUpdater.on("download-progress", (progressObj) => {
console.log(`更新下载进度: ${progressObj.percent}%`);
});
// 下载完成
autoUpdater.on("update-downloaded", () => {
// 显示安装弹窗
dialog
.showMessageBox({
title: "下载完成",
message: "新版本已下载完成,是否现在安装?",
buttons: ["是", "稍后"],
type: "question",
})
.then((result) => {
if (result.response === 0) {
// 安装更新
autoUpdater.quitAndInstall();
}
});
});
// 下载失败
autoUpdater.on("error", (err) => {
console.error("下载更新失败:", err);
dialog.showErrorBox("下载更新失败", "请检查网络连接并稍后重试!");
});
// 若有更新
autoUpdater.on("update-available", (info) => {
hasNewVersion(info);
});
// 检查更新
autoUpdater.checkForUpdatesAndNotify();
};

View File

@@ -1,24 +0,0 @@
import { globalShortcut } from "electron";
/**
* 注册全局快捷键
* @param {BrowserWindow} win - 程序窗口
*/
const createGlobalShortcut = (win) => {
// 刷新程序
globalShortcut.register("CmdOrCtrl+Shift+R", () => {
if (win && win.isFocused()) win?.reload();
});
// 打开开发者工具
globalShortcut.register("CmdOrCtrl+Shift+I", () => {
if (win && win.isFocused()) {
win?.webContents.openDevTools({
mode: "right",
activate: true,
});
}
});
};
export default createGlobalShortcut;

View File

@@ -1,139 +0,0 @@
import { Tray, Menu, app, ipcMain, nativeImage, nativeTheme } from "electron";
import { join } from "path";
// 当前歌曲数据
let playSongName = "当前暂无播放歌曲";
let playSongState = false;
/**
* 创建系统托盘
* @param {BrowserWindow} win - 程序窗口
*/
const createSystemTray = (win) => {
// 系统托盘
const mainTray = new Tray(
nativeImage
.createFromPath(
join(
__dirname,
process.platform === "win32"
? "../../public/imgs/icons/favicon.ico"
: "../../public/imgs/icons/favicon-32x32.png",
),
)
.resize({
height: 32,
width: 32,
}),
);
// 应用内菜单
Menu.setApplicationMenu(createTrayMenu(win));
// 默认名称
win.setTitle(app.getName());
mainTray.setTitle(app.getName());
mainTray.setToolTip(app.getName());
// 左键事件
mainTray.on("click", () => win.show());
// 托盘菜单
mainTray.setContextMenu(createTrayMenu(win));
// 系统主题改变
nativeTheme.on("updated", () => {
mainTray.setContextMenu(createTrayMenu(win));
});
// 播放歌曲改变
ipcMain.on("songNameChange", (_, val) => {
playSongName = val;
win.setTitle(val);
mainTray.setTitle(val);
mainTray.setToolTip(val);
mainTray.setContextMenu(createTrayMenu(win));
});
// 播放状态改变
ipcMain.on("songStateChange", (_, val) => {
playSongState = val;
mainTray.setContextMenu(createTrayMenu(win));
});
};
// 生成图标
const createIcon = (name) => {
// 系统是否为暗色
const isDarkMode = nativeTheme.shouldUseDarkColors;
// 返回图标
return nativeImage
.createFromPath(
isDarkMode
? join(__dirname, `../../public/imgs/icons/${name}-dark.png`)
: join(__dirname, `../../public/imgs/icons/${name}-light.png`),
)
.resize({ width: 16, height: 16 });
};
// 生成右键菜单
const createTrayMenu = (win) => {
// 返回菜单
return Menu.buildFromTemplate([
{
label: playSongName,
icon: createIcon("open"),
click() {
win.show();
win.focus();
win.webContents.send("showPlayer");
},
},
{
type: "separator",
},
{
label: "上一曲",
icon: createIcon("prev"),
accelerator: "CmdOrCtrl+Left",
click: () => {
win.webContents.send("playNextOrPrev", "prev");
},
},
{
label: playSongState ? "暂停" : "播放",
icon: createIcon(playSongState ? "pause" : "play"),
accelerator: "CmdOrCtrl+Space",
click: () => {
win.webContents.send("playOrPause");
},
},
{
label: "下一曲",
icon: createIcon("next"),
accelerator: "CmdOrCtrl+Right",
click: () => {
win.webContents.send("playNextOrPrev", "next");
},
},
{
type: "separator",
},
{
label: "全局设置",
icon: createIcon("setting"),
click: () => {
win.show();
win.focus();
win.webContents.send("open-setting");
},
},
{
type: "separator",
},
{
label: "退出",
icon: createIcon("power"),
click: () => {
win.close();
app.isQuiting = true;
app.quit();
},
},
]);
};
export default createSystemTray;

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

5
env.d.ts vendored Normal file
View File

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

View File

@@ -1,23 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/imgs/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/imgs/icons/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/imgs/icons/apple-touch-icon.png" />
<link rel="icon" type="image/icon" href="/icons/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%RENDERER_VITE_SITE_TITLE%</title>
<meta name="apple-mobile-web-app-title" content="%RENDERER_VITE_SITE_TITLE%" />
<meta name="author" content="%RENDERER_VITE_SITE_ANTHOR%" />
<meta name="keywords" content="%RENDERER_VITE_SITE_KEYWORDS%" />
<meta name="description" content="%RENDERER_VITE_SITE_DES%" />
<link rel="mask-icon" href="/imgs/icons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>SPlayer</title>
<!-- font -->
<link rel="stylesheet" href="/fonts/font.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,8 +1,9 @@
server {
gzip on;
listen 7899;
listen [::]:7899;
listen 25884;
listen [::]:25884;
server_name localhost;
client_max_body_size 100M;
location / {
root /usr/share/nginx/html;
@@ -14,10 +15,10 @@ server {
rewrite ^(.*)$ /index.html last;
}
location /api/ {
proxy_buffers 16 32k;
location /api/netease/ {
proxy_buffers 16 64k;
proxy_buffer_size 128k;
proxy_busy_buffers_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;

View File

@@ -1,77 +1,113 @@
{
"name": "splayer",
"version": "2.1.0",
"productName": "SPlayer",
"version": "3.0.0-alpha.1",
"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": ">=18.16.0",
"npm": ">=9.6.7",
"pnpm": ">=8.14.0"
"node": ">=20",
"npm": ">=10"
},
"type": "module",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"lint": "npx eslint . --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev --watch",
"build": "electron-vite build",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
"build:mac": "npm run build && electron-builder --mac --config",
"build:linux": "npm run build && electron-builder --linux --config"
"build:web": "npm run build",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@applemusic-like-lyrics/core": "^0.1.3",
"@applemusic-like-lyrics/lyric": "^0.2.2",
"@applemusic-like-lyrics/vue": "^0.1.5",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.19.9",
"axios": "^1.7.2",
"colorthief": "^2.4.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@pixi/app": "^7.4.2",
"@pixi/core": "^7.4.2",
"@pixi/display": "^7.4.2",
"@pixi/filter-blur": "^7.4.2",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
"@pixi/sprite": "^7.4.2",
"@vueuse/core": "^10.11.1",
"NeteaseCloudMusicApi": "^4.22.0",
"axios": "^1.7.7",
"change-case": "^5.4.4",
"dayjs": "^1.11.13",
"electron-dl": "^3.5.2",
"electron-store": "^8.2.0",
"electron-updater": "^6.2.1",
"express": "^4.19.2",
"express-http-proxy": "^2.0.0",
"electron-updater": "^6.3.4",
"file-saver": "^2.0.5",
"font-list": "^1.5.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": "^14.1.2",
"music-metadata": "7.14.0",
"node-taglib-sharp": "^5.2.3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"pinia": "^2.2.2",
"pinia-plugin-persistedstate": "^3.2.3",
"plyr": "^3.7.8",
"screenfull": "^6.0.2",
"vue-router": "^4.3.3",
"vue-slider-component": "4.1.0-beta.7"
"vue-virtual-scroller": "2.0.0-beta.8"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@rushstack/eslint-patch": "^1.10.3",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"ajv": "^8.16.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@fastify/cookie": "^9.4.0",
"@fastify/http-proxy": "^9.5.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@types/file-saver": "^2.0.7",
"@types/howler": "^2.2.11",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.5.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-vue": "^5.1.3",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
"electron": "^28.3.3",
"electron-builder": "^24.13.3",
"electron-log": "^5.1.5",
"electron-vite": "^2.2.0",
"electron-log": "^5.2.0",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.26.0",
"naive-ui": "^2.38.2",
"prettier": "^3.3.1",
"sass": "^1.77.4",
"terser": "^5.31.1",
"unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.13",
"eslint-plugin-vue": "^9.28.0",
"fast-glob": "^3.3.2",
"fastify": "^4.28.1",
"naive-ui": "^2.39.0",
"node-taglib-sharp": "^5.2.3",
"prettier": "^3.3.3",
"sass": "^1.78.0",
"terser": "^5.33.0",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.3",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^0.17.5",
"vue": "3.4.8"
"vite-plugin-wasm": "^3.3.0",
"vue": "3.4.38",
"vue-router": "^4.4.3",
"vue-tsc": "^2.1.6"
}
}

11143
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,13 +0,0 @@
@font-face {
font-family: "HarmonyOS Sans";
src: url("./HarmonyOS_Sans_SC.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "HarmonyOS Sans";
src: url("./HarmonyOS_Sans_SC_Bold.woff2") format("woff2");
font-weight: bold;
font-style: normal;
}

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.

View File

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 644 B

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/icons/icon.icns Normal file

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 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.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 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.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

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