Compare commits

..

67 Commits

Author SHA1 Message Date
imsyy
adbda459ba 🐞 fix: 修复下载歌曲元信息不正确导致无法正常播放 #113 2024-01-11 16:09:09 +08:00
imsyy
984d747179 🐞 fix: 修复本地歌词翻译显示异常 #121 2024-01-11 11:39:29 +08:00
imsyy
c012f84064 🐳 chore: Docker 自动部署 2024-01-10 16:00:02 +08:00
imsyy
a57a18b9f5 feat: 播放模式支持点击切换 2024-01-10 14:47:59 +08:00
imsyy
309c323a14 🔧 build: support ESM and upgrade to Vite 5
- 临时解决下载歌曲无法正常播放 #113
2024-01-09 18:13:01 +08:00
imsyy
6a1e606d6d feat: 移动端基础适配
- 修复未登录时无法使用本地歌曲
- 修复部分样式异常
2024-01-08 18:20:47 +08:00
imsyy
af3931847e 🐞 fix: 修复音乐缓存导致播放异常 2024-01-05 11:32:40 +08:00
imsyy
41eadb5843 🌈 style: 优化部分样式 2024-01-05 11:15:24 +08:00
imsyy
8963d719d9 feat: 侧边栏支持显示歌单封面 #111 2024-01-03 16:36:54 +08:00
imsyy
0a7761ffff 🐞 fix: 解决音频资源过期问题 2024-01-03 11:33:59 +08:00
imsyy
1a63771f2d feat: 新增雷达歌单 2024-01-03 10:54:27 +08:00
imsyy
1f9141ba33 🔧 build: 更新部分依赖版本 2024-01-02 18:13:01 +08:00
imsyy
a341a69d48 feat: 卡片播放按钮可直接播放 #111 2023-12-29 14:32:10 +08:00
imsyy
0cedfe0af3 feat: 支持播放超大歌单
- 支持大于 2000 首歌曲的歌单播放
2023-12-28 17:46:57 +08:00
imsyy
59f492ed8f feat: 新增音乐频谱显示 2023-12-27 16:47:10 +08:00
imsyy
8f416ff841 🐞 fix: 修复当电台模式时播放列表出现错误 2023-12-27 10:26:42 +08:00
imsyy
99ab194e4b 🐳 chore: Change Dockerfile 2023-12-26 13:55:31 +08:00
底层用户
43fb9b48dc 🔧 Merge pull request #109 from imsyy/dependabot/npm_and_yarn/postcss-8.4.32
build(deps): bump postcss from 8.4.28 to 8.4.32
2023-12-26 09:42:46 +08:00
底层用户
c61e54d6a3 🔧 Merge pull request #108 from imsyy/dependabot/npm_and_yarn/babel/traverse-7.23.6
build(deps-dev): bump @babel/traverse from 7.22.11 to 7.23.6
2023-12-26 09:42:38 +08:00
底层用户
c8d195053f 🔧 Merge pull request #107 from imsyy/dependabot/npm_and_yarn/vite-4.4.12
build(deps-dev): bump vite from 4.4.9 to 4.4.12
2023-12-26 09:41:50 +08:00
底层用户
8cfe5d0481 🔧 Merge pull request #106 from imsyy/dependabot/npm_and_yarn/axios-1.6.0
build(deps): bump axios from 1.4.0 to 1.6.0
2023-12-26 09:41:27 +08:00
dependabot[bot]
fcc2f5015f build(deps): bump postcss from 8.4.28 to 8.4.32
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.28 to 8.4.32.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.28...8.4.32)

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

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

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

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

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

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

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

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

12
.dockerignore Normal file
View File

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

View File

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

View File

@@ -21,9 +21,18 @@ RENDERER_VITE_SITE_ANTHOR = "無名"
RENDERER_VITE_SITE_KEYWORDS = "SPlayer,云音乐,播放器,在线音乐,在线播放器,音乐播放器"
RENDERER_VITE_SITE_DES = "一个简约的在线音乐播放器具有音乐搜索、播放、每日推荐、私人FM、歌词显示、歌曲评论、网易云登录与云盘等功能"
RENDERER_VITE_SITE_URL = "imsyy.top"
RENDERER_VITE_SITE_LOGO = "/images/logo/favicon.svg"
RENDERER_VITE_SITE_APPLE_LOGO = "/images/logo/favicon-apple.png"
# Cookie
## 咪咕音乐 Cookie
MAIN_VITE_MIGU_COOKIE = ""
# 公告配置
## 若无需公告,请将标题或内容任意一项设为空即可
## 公告类型
RENDERER_VITE_ANN_TYPE = "info"
## 公告标题
RENDERER_VITE_ANN_TITLE = ""
## 公告内容
RENDERER_VITE_ANN_CONTENT = ""
## 公告时长(毫秒)不可超过 999999
RENDERER_VITE_ANN_DURATION = 8000

View File

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

View File

@@ -49,5 +49,8 @@ module.exports = {
$notification: true,
$changeThemeColor: true,
$canNotConnect: true,
$refreshCloudCatch: true,
$cleanAll: true,
$player: true,
},
};

View File

@@ -1,6 +1,6 @@
name: 添加功能
description: 请填写希望添加的功能的具体信息
title: "添加功能"
title: 添加功能】请填写标题
labels: [add]
body:
- type: input

View File

@@ -1,5 +1,6 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
title: 【遇到问题】请填写标题
labels: [bug]
body:
- type: input

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

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

View File

@@ -21,6 +21,14 @@ jobs:
uses: actions/setup-node@v4.0.0
with:
node-version: "18.x"
# 复制环境变量文件
- name: Copy .env.example
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: npm install

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

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

View File

@@ -12,6 +12,7 @@ jobs:
build-windows:
name: Build for Windows
runs-on: windows-latest
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
@@ -21,6 +22,14 @@ jobs:
uses: actions/setup-node@v4.0.0
with:
node-version: "18.x"
# 复制环境变量文件
- name: Copy .env.example
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: npm install
@@ -36,24 +45,21 @@ jobs:
with:
name: SPlarer-Win
if-no-files-found: ignore
path: |
dist/*.exe
dist/*.msi
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.exe
dist/*.msi
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Mac
build-macos:
name: Build for macOS
runs-on: macos-latest
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
@@ -63,7 +69,15 @@ jobs:
uses: actions/setup-node@v4.0.0
with:
node-version: "18.x"
# 安装项目依赖
# 复制环境变量文件
- name: Copy .env.example
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
# 构建 Electron App
@@ -78,24 +92,21 @@ jobs:
with:
name: SPlarer-Macos
if-no-files-found: ignore
path: |
dist/*.dmg
dist/*.zip
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.dmg
dist/*.zip
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Linux
build-linux:
name: Build for Linux
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
# 检出 Git 仓库
- name: Check out git repository
@@ -108,6 +119,14 @@ jobs:
# 更新 Ubuntu 软件源
- name: Ubuntu Update with sudo
run: sudo apt-get update
# 复制环境变量文件
- name: Copy .env.example
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
@@ -123,19 +142,13 @@ jobs:
with:
name: SPlarer-Linux
if-no-files-found: ignore
path: |
dist/*.AppImage
dist/*.deb
dist/*.rpm
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v0.1.15
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
files: |
dist/*.AppImage
dist/*.deb
dist/*.rpm
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ dist-ssr
coverage
*.local
out
.env
/cypress/videos/
/cypress/screenshots/

5
.npmrc
View File

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

View File

@@ -4,3 +4,5 @@ pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
auto-imports.d.ts
components.d.ts

View File

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

29
Dockerfile Normal file
View File

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

149
LICENSE
View File

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

414
README.md
View File

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

3
auto-imports.d.ts vendored
View File

@@ -65,5 +65,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}

16
components.d.ts vendored
View File

@@ -8,27 +8,31 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AddPlaylist: typeof import('./src/components/Modal/AddPlaylist.vue')['default']
CloudSongMatch: typeof import('./src/components/Modal/CloudSongMatch.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']
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']
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']
NAlert: typeof import('naive-ui')['NAlert']
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']
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']
@@ -36,6 +40,7 @@ declare module 'vue' {
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
@@ -48,7 +53,9 @@ declare module 'vue' {
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
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']
NList: typeof import('naive-ui')['NList']
@@ -58,9 +65,11 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
@@ -68,7 +77,6 @@ declare module 'vue' {
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
@@ -77,8 +85,10 @@ declare module 'vue' {
NTag: typeof import('naive-ui')['NTag']
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']
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']
@@ -89,9 +99,11 @@ declare module 'vue' {
SearchInp: typeof import('./src/components/Search/SearchInp.vue')['default']
SearchSuggestions: typeof import('./src/components/Search/SearchSuggestions.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']
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']

12
docker-compose.yml Normal file
View File

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

View File

@@ -19,15 +19,11 @@ asarUnpack:
# Windows 平台配置
win:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
icon: public/images/icons/favicon-512x512.png
# 构建类型
target:
- nsis
- portable
# 管理员权限
requestedExecutionLevel: highestAvailable
target: nsis
# NSIS 安装器配置
nsis:
# 一键式安装程序还是辅助安装程序
@@ -44,12 +40,16 @@ nsis:
allowElevation: true
# 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true
# 安装包图标
installerIcon: public/images/icons/favicon.ico
# 卸载命令图标
uninstallerIcon: public/images/icons/favicon.ico
# macOS 平台配置
mac:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_512.png
icon: public/images/icons/favicon-512x512.png
# 权限继承的文件路径
entitlementsInherit: build/entitlements.mac.plist
# 扩展信息,如权限描述
@@ -69,13 +69,12 @@ dmg:
# Linux 平台配置
linux:
# 可执行文件名
executableName: splayer
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
icon: public/images/icons/favicon-512x512.png
# 构建类型
target:
- AppImage
- snap
- deb
- rpm
- tar.gz

View File

@@ -6,6 +6,7 @@ import {
splitVendorChunkPlugin,
} 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";
@@ -71,6 +72,62 @@ export default defineConfig(({ mode }) => {
viteCompression(),
// splitVendorChunkPlugin
splitVendorChunkPlugin(),
// 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: "/images/icons/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "/images/icons/favicon-96x96.png",
sizes: "96x96",
type: "image/png",
},
{
src: "/images/icons/favicon-256x256.png",
sizes: "256x256",
type: "image/png",
},
{
src: "/images/icons/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
// 服务器配置
server: {
@@ -100,9 +157,6 @@ export default defineConfig(({ mode }) => {
},
},
sourcemap: false,
win: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
},
},
};

View File

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

View File

@@ -1,7 +1,7 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { write } from "node-id3";
import { download } from "electron-dl";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import axios from "axios";
@@ -23,7 +23,7 @@ const mainIpcMain = (win) => {
ipcMain.on("window-maxOrRestore", (ev) => {
const winSizeState = win.isMaximized();
winSizeState ? win.restore() : win.maximize();
ev.reply("window-state", win.isMaximized());
ev.reply("windowState", win.isMaximized());
});
ipcMain.on("window-restore", () => {
win.restore();
@@ -193,32 +193,39 @@ const mainIpcMain = (win) => {
});
// 下载文件至指定目录
ipcMain.handle("downloadFile", async (_, data, song, songName, songType, path) => {
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)) {
const songData = JSON.parse(song);
console.info("开始下载:", songData, data);
console.info("开始下载:", name, url);
// 下载歌曲
const songDownload = await download(win, data.url, {
const songDownload = await download(win, url, {
directory: path,
filename: `${songName}.${songType}`,
filename: `${name}.${type}`,
});
// 若关闭,则不进行元信息写入
if (!downloadMeta) return true;
// 下载封面
const coverDownload = await download(win, songData.cover, {
const coverDownload = await download(win, data.cover, {
directory: path,
filename: `${songName}.jpg`,
filename: `${name}.jpg`,
});
// 生成歌曲文件的元数据
const songTag = {
title: songData.name,
artist: Array.isArray(songData.artists)
? songData.artists.map((ar) => ar.name).join(" / ")
: songData.artists || "未知歌手",
album: songData.album?.name || songData.album,
image: coverDownload.getSavePath(),
};
// 读取歌曲文件
const songFile = File.createFromPath(songDownload.getSavePath());
// 生成图片信息
const songCover = Picture.fromPath(coverDownload.getSavePath());
// 保存修改后的元数据
write(songTag, songDownload.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;

View File

@@ -5,7 +5,7 @@ const netEaseApi = require("NeteaseCloudMusicApi");
*
* @async
* @param {Object} options - 服务器配置
* @param {number} [options.port=12141] - 服务器端口
* @param {number} [options.port=11451] - 服务器端口
* @param {string} [options.host="127.0.0.1"] - 服务器主机地址
* @returns {Promise<void>} 返回一个 Promise在 API 服务器成功启动后 resolve
*/

View File

@@ -1,29 +1,65 @@
const { autoUpdater } = require("electron-updater");
import { dialog } from "electron";
import { is } from "@electron-toolkit/utils";
import pkg from "electron-updater";
const checkForUpdates = () => {
autoUpdater.checkForUpdates();
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 = () => {
checkForUpdates();
if (is.dev) return false;
// 监听检查更新的事件
autoUpdater.on("checking-for-update", () => {
console.log("Checking for update...");
});
autoUpdater.on("update-available", (info) => {
console.log("Update available:", info);
});
autoUpdater.on("update-not-available", () => {
console.log("Update not available.");
// 监听下载进度事件
autoUpdater.on("download-progress", (progressObj) => {
console.log(`更新下载进度: ${progressObj.percent}%`);
});
// 下载完成
autoUpdater.on("update-downloaded", () => {
console.log("Update downloaded. Ready to install.");
// 在需要的时候,触发安装更新
autoUpdater.quitAndInstall();
// 显示安装弹窗
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

@@ -6,8 +6,18 @@ import { globalShortcut } from "electron";
*/
const createGlobalShortcut = (win) => {
// 刷新程序
globalShortcut.register("CommandOrControl+R", () => {
win?.reload();
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,
});
}
});
};

View File

@@ -1,59 +1,78 @@
import { join } from "path";
import { Tray, Menu, app, ipcMain, nativeImage, nativeTheme } from "electron";
import { join } from "path";
// 当前播放歌曲数据
// 当前歌曲数据
let playSongName = "当前暂无播放歌曲";
let playSongState = false;
/**
* 创建系统自定义信息
* 创建系统托盘
* @param {BrowserWindow} win - 程序窗口
*/
const createSystemInfo = (win) => {
// 弹出列表
app.setUserTasks([]);
const createSystemTray = (win) => {
// 系统托盘
const mainTray = new Tray(join(__dirname, "../../public/images/logo/favicon.png"));
// 给托盘图标设置气球提示
const mainTray = new Tray(
nativeImage
.createFromPath(
join(
__dirname,
process.platform === "win32"
? "../../public/images/icons/favicon.ico"
: "../../public/images/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;
// 托盘图标标题
mainTray.setToolTip(val);
// 更改应用标题
win.setTitle(val);
mainTray.setTitle(val);
mainTray.setToolTip(val);
mainTray.setContextMenu(createTrayMenu(win));
});
// 播放状态改变
ipcMain.on("songStateChange", (_, val) => {
playSongState = val;
mainTray.setContextMenu(createTrayMenu(win));
});
// 左键事件
mainTray.on("click", () => {
// 显示窗口
win.show();
});
// 右键事件
mainTray.on("right-click", () => {
mainTray.popUpContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
});
};
// 生成图标
const createIcon = (name) => {
// 系统是否为暗色
const isDarkMode = nativeTheme.shouldUseDarkColors;
// 返回图标
return nativeImage
.createFromPath(
isDarkMode
? join(__dirname, `../../public/images/icons/${name}-dark.png`)
: join(__dirname, `../../public/images/icons/${name}-light.png`),
)
.resize({ width: 16, height: 16 });
};
// 生成右键菜单
const createTrayMenu = (win) => {
// 系统是否为暗色
const isDarkMode = nativeTheme.shouldUseDarkColors;
// 生成图标
const createIcon = (name) => {
return nativeImage
.createFromPath(
isDarkMode
? join(__dirname, `../../public/images/icon/${name}-dark.png`)
: join(__dirname, `../../public/images/icon/${name}-light.png`),
)
.resize({ width: 16, height: 16 });
};
// 返回菜单
return [
return Menu.buildFromTemplate([
{
label: playSongName,
icon: createIcon("open"),
@@ -69,6 +88,7 @@ const createTrayMenu = (win) => {
{
label: "上一曲",
icon: createIcon("prev"),
accelerator: "CmdOrCtrl+Left",
click: () => {
win.webContents.send("playNextOrPrev", "prev");
},
@@ -76,6 +96,7 @@ const createTrayMenu = (win) => {
{
label: playSongState ? "暂停" : "播放",
icon: createIcon(playSongState ? "pause" : "play"),
accelerator: "CmdOrCtrl+Space",
click: () => {
win.webContents.send("playOrPause");
},
@@ -83,6 +104,7 @@ const createTrayMenu = (win) => {
{
label: "下一曲",
icon: createIcon("next"),
accelerator: "CmdOrCtrl+Right",
click: () => {
win.webContents.send("playNextOrPrev", "next");
},
@@ -94,7 +116,9 @@ const createTrayMenu = (win) => {
label: "全局设置",
icon: createIcon("setting"),
click: () => {
win.webContents.send("setting");
win.show();
win.focus();
win.webContents.send("open-setting");
},
},
{
@@ -109,7 +133,7 @@ const createTrayMenu = (win) => {
app.quit();
},
},
];
]);
};
export default createSystemInfo;
export default createSystemTray;

View File

@@ -1,11 +1,11 @@
import { encryptQuery } from "@main/utils/kwDES";
import axios from "axios";
/**
* 网易云音乐解灰
* 目前音源采用 咪咕音乐
*/
// 请求头
// 咪咕音乐请求头
const requestHeader = {
Origin: "http://music.migu.cn/",
Referer: "http://m.music.migu.cn/v3/",
@@ -19,48 +19,149 @@ const requestHeader = {
* @returns {Promise<any>}
*/
const getMiguSongId = async (keyword) => {
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) {
console.log(result.data.musics[0]);
const oldName = keyword.split("-");
const songName = result.data.musics[0]?.songName;
if (songName && !songName?.includes(oldName[0])) {
console.log("匹配失败");
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;
}
return result.data.musics[0].id;
// 是否与原曲吻合
const originalName = keyword.split("-");
const songName = result.data.content[1].musicpage.abslist[0]?.SONGNAME;
if (songName && !songName?.includes(originalName[0])) {
return null;
}
const songId = result.data.content[1].musicpage.abslist[0].MUSICRID;
return songId.slice("MUSIC_".length);
} catch (error) {
console.error("获取酷我音乐歌曲 ID 失败:", error);
return null;
}
};
/**
* 获取酷我音乐歌曲 URL
* @param {string} keyword - 搜索关键字
* @returns {Promise<any>}
*/
const getKuwoSongUrl = async (keyword) => {
try {
const songId = await getKuwoSongId(keyword);
if (!songId) return null;
console.info("酷我解灰歌曲 ID", songId);
const url = encryptQuery
? "http://mobi.kuwo.cn/mobi.s?f=kuwo&q=" +
encryptQuery(
"corp=kuwo&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&p2p=1&type=convert_url2&sig=0&format=mp3" +
"&rid=" +
songId,
)
: "http://antiserver.kuwo.cn/anti.s?type=convert_url&format=mp3&response=url&rid=MUSIC_" +
songId;
const result = await axios.get(url, { "user-agent": "okhttp/3.10.0" });
if (result.data) {
const urlMatch = result.data.match(/http[^\s$"]+/)[0];
console.info("酷我解灰歌曲 URL", urlMatch);
return urlMatch;
}
return null;
} catch (error) {
console.error("获取酷我音乐歌曲 URL 失败:", error);
return null;
}
return null;
};
/**
* 获取给定关键字的音乐 URL
* @param {string} keyword - 关键字
* @returns {Promise<?string>} 如果找到,则解析为音乐 URL 的 Promise如果未找到则为 null
* @throws {Error} 抛出错误
* @returns {Promise<?string>} 音乐 URL
*/
const getNeteaseMusicUrl = async (keyword) => {
const songId = await getMiguSongId(keyword);
if (!songId) return null;
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) {
return result.data.data.url;
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;
}
return null;
};
export default getNeteaseMusicUrl;

View File

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

View File

@@ -1,24 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/images/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/images/icons/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/images/icons/apple-touch-icon.png" />
<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="/images/icons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
</head>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="%RENDERER_VITE_SITE_LOGO%" />
<link rel="apple-touch-icon" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="bookmark" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="apple-touch-icon-precomposed" sizes="200x200" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%RENDERER_VITE_SITE_TITLE%</title>
<meta name="apple-mobile-web-app-title" content="%RENDERER_VITE_SITE_TITLE%" />
<meta name="author" content="%RENDERER_VITE_SITE_ANTHOR%" />
<meta name="keywords" content="%RENDERER_VITE_SITE_KEYWORDS%" />
<meta name="description" content="%RENDERER_VITE_SITE_DES%" />
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

28
nginx.conf Normal file
View File

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

View File

@@ -1,19 +1,24 @@
{
"name": "splayer",
"version": "2.0.0-beta.2",
"version": "2.0.1",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
"home": "https://imsyy.top",
"github": "https://github.com/imsyy/SPlayer",
"repository": "github:imsyy/SPlayer",
"license": "AGPL-3.0",
"license-file": "LICENSE",
"engines": {
"node": ">=16.16.0"
"node": ">=18.16.0",
"npm": ">=9.6.7",
"pnpm": ">=8.14.0"
},
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"start": "electron-vite preview",
"dev": "chcp 65001 && electron-vite dev --watch",
"dev": "electron-vite dev --watch",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
@@ -21,48 +26,50 @@
"build:linux": "npm run build && electron-builder --linux --config"
},
"dependencies": {
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^2.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.13.5",
"axios": "^1.4.0",
"NeteaseCloudMusicApi": "^4.14.0",
"axios": "^1.6.5",
"colorthief": "^2.4.0",
"electron-dl": "^3.5.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"express": "^4.18.2",
"express-http-proxy": "^1.6.3",
"howler": "^2.2.3",
"express-http-proxy": "^2.0.0",
"howler": "^2.2.4",
"js-cookie": "^3.0.5",
"localforage": "^1.10.0",
"music-metadata": "7.13.4",
"node-id3": "^0.2.6",
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"music-metadata": "7.14.0",
"node-taglib-sharp": "^5.2.3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"plyr": "^3.7.8",
"qrcode.vue": "^3.4.1",
"screenfull": "^6.0.2",
"vue-router": "^4.2.4",
"vue-router": "^4.2.5",
"vue-slider-component": "4.1.0-beta.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.1",
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"electron": "^25.6.0",
"electron-builder": "^24.6.4",
"electron-log": "^5.0.1",
"electron-vite": "^1.0.27",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"naive-ui": "^2.34.4",
"prettier": "^3.0.2",
"sass": "^1.66.1",
"terser": "^5.19.2",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"@electron-toolkit/eslint-config": "^1.0.2",
"@rushstack/eslint-patch": "^1.6.1",
"@vitejs/plugin-vue": "^5.0.2",
"@vue/eslint-config-prettier": "^9.0.0",
"ajv": "^8.12.0",
"electron": "^28.1.2",
"electron-builder": "^24.9.1",
"electron-log": "^5.0.3",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"naive-ui": "^2.37.3",
"prettier": "^3.1.1",
"sass": "^1.69.7",
"terser": "^5.26.0",
"unplugin-auto-import": "^0.17.3",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.11",
"vite-plugin-compression": "^0.5.1",
"vue": "^3.3.4"
"vite-plugin-pwa": "^0.17.4",
"vue": "3.4.4"
}
}

4129
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2400 4444 c-14 -2 -63 -8 -110 -14 -407 -50 -737 -184 -1077 -436
-124 -93 -348 -309 -406 -394 -100 -144 -190 -286 -223 -352 -101 -200 -199
-506 -226 -707 -17 -127 -22 -436 -8 -546 40 -338 161 -687 327 -942 138 -213
226 -319 372 -450 237 -211 471 -353 761 -460 147 -54 156 -57 330 -88 383
-71 516 -67 965 25 76 16 288 91 370 132 28 13 66 31 85 38 39 16 158 88 270
164 242 164 474 410 628 668 254 422 362 920 308 1408 -16 139 -23 172 -73
344 -80 273 -188 496 -346 711 -104 143 -302 348 -404 421 l-48 34 -192 0
c-184 0 -193 -1 -203 -21 -8 -14 -11 -271 -10 -882 1 -474 -1 -896 -5 -937
-29 -306 -228 -596 -507 -737 -207 -104 -426 -126 -663 -66 -168 43 -300 125
-453 283 -118 122 -203 310 -223 495 -10 86 -3 271 11 320 41 135 58 180 95
248 103 193 330 376 545 441 157 47 364 49 513 5 38 -11 78 -19 90 -17 l22 3
-3 635 c-2 349 -7 638 -10 642 -22 22 -413 47 -502 32z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/images/pic/video.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,33 +1,33 @@
<template>
<Provider>
<!-- 主框架 -->
<n-layout :class="['all-layout', status.showFullPlayer ? 'full-player' : null]">
<n-layout :class="['all-layout', { 'full-player': showFullPlayer }]">
<!-- 导航栏 -->
<n-layout-header bordered>
<MainNav />
<TitleBar v-if="checkPlatform.electron()" />
</n-layout-header>
<!-- 主内容 -->
<!-- 主内容 - 有侧边栏 -->
<n-layout
v-if="showSider"
:class="{
'body-layout': true,
'player-bar': Object.keys(music.playSongData)?.length && status.showPlayBar,
'player-bar': music.getPlaySongData?.id && showPlayBar,
}"
position="absolute"
has-sider
>
<!-- 侧边栏 -->
<n-layout-sider
class="main-sider"
:collapsed="status.asideMenuCollapsed"
:collapsed="asideMenuCollapsed"
:native-scrollbar="false"
:collapsed-width="64"
:width="240"
class="main-sider"
show-trigger="bar"
collapse-mode="width"
bordered
@collapse="status.asideMenuCollapsed = true"
@expand="status.asideMenuCollapsed = false"
@collapse="asideMenuCollapsed = true"
@expand="asideMenuCollapsed = false"
>
<div class="sider-all">
<Menu />
@@ -35,32 +35,29 @@
</n-layout-sider>
<!-- 页面区 -->
<n-layout :native-scrollbar="false" embedded>
<main id="main-layout" class="main-layout">
{{ music.getplaySongData }}
<!-- 回顶 -->
<n-back-top bottom="110">
<n-icon size="26">
<SvgIcon icon="chevron-up" />
</n-icon>
</n-back-top>
<!-- 路由页面 -->
<router-view v-slot="{ Component }">
<keep-alive>
<Transition name="router" mode="out-in">
<component :is="Component" />
</Transition>
</keep-alive>
</router-view>
</main>
<MainLayout />
</n-layout>
</n-layout>
<!-- 主内容 - 无侧边栏 -->
<n-layout-content
v-else
:class="{
'body-layout': true,
'player-bar': music.getPlaySongData?.id && showPlayBar,
}"
:native-scrollbar="false"
position="absolute"
embedded
>
<MainLayout />
</n-layout-content>
</n-layout>
<!-- 主播放器 -->
<MainControl />
<!-- 全屏播放器 -->
<FullPlayer />
<!-- 全局播放列表 -->
<n-config-provider v-if="status.showFullPlayer" :theme="darkTheme">
<n-config-provider v-if="showFullPlayer" :theme="darkTheme">
<Playlist />
</n-config-provider>
<Playlist v-else />
@@ -81,18 +78,83 @@
</template>
<script setup>
import { darkTheme } from "naive-ui";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { darkTheme, NButton } from "naive-ui";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { initPlayer } from "@/utils/Player";
import { checkPlatform } from "@/utils/helper";
import { initPlayer } from "@/utils/Player";
import userSignIn from "@/utils/userSignIn";
import globalShortcut from "@/utils/globalShortcut";
import globalEvents from "@/utils/globalEvents";
import packageJson from "@/../package.json";
const router = useRouter();
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { autoPlay, showSider, autoSignIn } = storeToRefs(settings);
const { showPlayBar, asideMenuCollapsed, showFullPlayer } = storeToRefs(status);
// 公告数据
const annShow =
import.meta.env.RENDERER_VITE_ANN_TITLE && import.meta.env.RENDERER_VITE_ANN_CONTENT
? true
: false;
const annType = import.meta.env.RENDERER_VITE_ANN_TYPE;
const annTitle = import.meta.env.RENDERER_VITE_ANN_TITLE;
const annContene = import.meta.env.RENDERER_VITE_ANN_CONTENT;
const annDuration = Number(import.meta.env.RENDERER_VITE_ANN_DURATION);
// PWA
if ("serviceWorker" in navigator) {
// 更新完成提醒
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (checkPlatform.electron()) {
$notification.create({
title: "🎉 有更新啦",
content: "检测到软件内资源有更新,是否重新启动软件以应用更新?",
meta: "当前版本 v " + (packageJson.version || "1.0.0"),
action: () =>
h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
electron.ipcRenderer.send("window-relaunch");
},
},
{
default: () => "更新",
},
),
onAfterLeave: () => {
$message.info("已取消本次更新,更新将在下次启动软件后生效", {
duration: 6000,
});
},
});
} else {
console.info("站点资源有更新,请刷新以应用更新");
$message.info("站点资源有更新,请刷新以应用更新", {
closable: true,
duration: 0,
});
}
});
}
// 显示公告
const showAnnouncements = () => {
if (annShow) {
$notification[annType]({
content: annTitle,
meta: annContene,
duration: annDuration,
});
}
};
// 网络无法连接
const canNotConnect = (error) => {
@@ -102,36 +164,49 @@ const canNotConnect = (error) => {
title: "网络连接错误",
content: "网络连接错误,请检查您当前的网络状态",
positiveText: "重试",
negativeText: "前往本地歌曲",
negativeText: checkPlatform.electron() ? "前往本地歌曲" : "取消",
onPositiveClick: () => {
location.reload();
},
onNegativeClick: () => {
router.push("/local");
if (checkPlatform.electron()) router.push("/local");
},
});
};
onMounted(() => {
// 网页端键盘事件
const handleKeyUp = (event) => {
globalShortcut(event, router);
};
onMounted(async () => {
// 挂载方法
window.$canNotConnect = canNotConnect;
// 主播放器
initPlayer(settings.autoPlay);
await initPlayer(autoPlay.value);
// 全局事件
globalEvents(router);
// 键盘监听
window.addEventListener("keyup", globalShortcut);
if (!checkPlatform.electron()) {
window.addEventListener("keyup", handleKeyUp);
}
// 自动签到
if (autoSignIn.value) await userSignIn();
// 显示公告
showAnnouncements();
});
onUnmounted(() => {
window.removeEventListener("keyup", globalShortcut);
if (!checkPlatform.electron()) window.removeEventListener("keyup", handleKeyUp);
});
</script>
<style lang="scss" scoped>
.all-layout {
height: 100%;
transition: transform 0.3s;
transition:
transform 0.3s,
opacity 0.3s;
.n-layout-header {
height: 60px;
display: flex;
@@ -142,9 +217,6 @@ onUnmounted(() => {
.body-layout {
top: 60px;
transition: bottom 0.3s;
&.player-bar {
bottom: 80px;
}
.main-sider {
:deep(.n-scrollbar-content) {
height: 100%;
@@ -152,16 +224,17 @@ onUnmounted(() => {
.sider-all {
height: 100%;
}
@media (max-width: 720px) {
@media (max-width: 900px) {
display: none;
}
}
.main-layout {
padding: 24px;
&.player-bar {
bottom: 80px;
}
}
&.full-player {
transform: scale(0.95);
opacity: 0;
transform: scale(0.9);
}
}
</style>

136
src/api/dj.js Normal file
View File

@@ -0,0 +1,136 @@
import axios from "@/utils/request";
/**
* 电台部分
*/
/**
* 获取电台 - 分类
*/
export const getDjCatelist = () => {
return axios({
method: "GET",
url: "/dj/catelist",
});
};
/**
* 获取电台 - 推荐
*/
export const getDjRecommend = () => {
return axios({
method: "GET",
url: "/dj/recommend",
});
};
/**
* 电台个性推荐
*/
export const getDjPersonalRec = () => {
return axios({
method: "GET",
url: "/dj/personalize/recommend",
});
};
/**
* 获取电台 - 推荐类型
*/
export const getDjCategoryRec = () => {
return axios({
method: "GET",
url: "/dj/category/recommend",
});
};
/**
* 私人 DJ
*/
export const getPrivateDj = () => {
return axios({
method: "GET",
url: "/aidj/content/rcmd",
});
};
/**
* 电台 - 类别热门电台
* @param {string} cateId - 类别 id
* @param {number} [limit=50] - 返回数量,默认 50
* @param {number} [offset=0] - 偏移数量,默认 0
*/
export const getRadioHot = (cateId, limit = 50, offset = 0) => {
return axios({
method: "GET",
url: "/dj/radio/hot",
params: {
cateId,
limit,
offset,
},
});
};
/**
* 电台 - 分类推荐
* @param {string} type - 类别 id
*/
export const getRecType = (type) => {
return axios({
method: "GET",
url: "/dj/recommend/type",
params: {
type,
},
});
};
/**
* 电台 - 详情
* @param {string} rid - 电台 的 id
*/
export const getDjDetail = (rid) => {
return axios({
method: "GET",
url: "/dj/detail",
params: {
rid,
},
});
};
/**
* 电台 - 节目
* @param {string} rid - 电台 的 id
* @param {number} [limit=50] - 返回数量,默认 50
* @param {number} [offset=0] - 偏移数量,默认 0
*/
export const getDjProgram = (rid, limit = 50, offset = 0) => {
return axios({
method: "GET",
url: "/dj/program",
params: {
rid,
limit,
offset,
},
});
};
/**
* 电台 - 订阅
* @param {number} rid - 电台 的 id
* @param {number} t - 操作类型1为收藏0为取消收藏
*/
export const likeDj = (rid, t) => {
return axios({
method: "GET",
url: "/dj/sub",
params: {
rid,
t,
timestamp: new Date().getTime(),
},
});
};

View File

@@ -67,10 +67,10 @@ export const getPlayListDetail = (id) => {
/**
* 获取歌单中所有歌曲信息
* @param {number} id - 歌单id
* @param {number} [limit=30] - 返回数量,默认30
* @param {number} [limit=50] - 返回数量,默认50
* @param {number} [offset=0] - 偏移数量默认0
*/
export const getAllPlayList = (id, limit = 30, offset = 0) => {
export const getAllPlayList = (id, limit = 50, offset = 0) => {
return axios({
method: "GET",
url: "/playlist/track/all",

View File

@@ -1,4 +1,5 @@
import axios from "@/utils/request";
import idMeta from "@/assets/idMeta.json";
/**
* 推荐部分
@@ -34,6 +35,21 @@ export const getPersonalized = (type, limit = 12) => {
});
};
/**
* 雷达歌单
*/
export const getRadarPlaylist = async () => {
const allRadar = idMeta.radarPlaylist.map((playlist) => {
return axios({
method: "GET",
url: "/playlist/detail",
params: { id: playlist.id },
});
});
const result = await Promise.allSettled(allRadar);
return result.map((res) => res?.value.playlist);
};
/**
* 热门歌手列表
* @param {number} [limit=6] - 要返回的歌手数量,默认为 6 个

View File

@@ -33,6 +33,19 @@ export const getSearchSuggest = (keywords, mobile = false) => {
});
};
/**
* 默认搜索关键词
*/
export const getSearchDefault = () => {
return axios({
method: "GET",
url: "/search/default",
params: {
timestamp: new Date().getTime(),
},
});
};
/**
* 搜索结果
* @param {string} keywords - 搜索关键词

View File

@@ -119,6 +119,19 @@ export const getUserMv = () => {
});
};
/**
* 获取用户电台的订阅列表
*/
export const getUserDj = () => {
return axios({
method: "GET",
url: "/dj/sublist",
params: {
timestamp: new Date().getTime(),
},
});
};
/**
* 获取用户喜欢的音乐列表
* @param {string} uid 用户的id
@@ -151,80 +164,6 @@ export const setLikeSong = (id, like = true) => {
});
};
/**
* 获取用户云盘数据
* @param {number} [limit=30] - 返回数量默认30
* @param {number} [offset=0] - 偏移数量默认0
*/
export const getCloud = (limit = 30, offset = 0) => {
return axios({
method: "GET",
url: "/user/cloud",
params: {
limit,
offset,
timestamp: new Date().getTime(),
},
});
};
/**
* 用户云盘歌曲删除
* @param {string} id - 歌曲的id
*/
export const setCloudDel = (id) => {
return axios({
method: "GET",
url: "/user/cloud/del",
params: {
id,
timestamp: new Date().getTime(),
},
});
};
/**
* 云盘歌曲信息匹配纠正
* @param {string} uid - 用户id
* @param {string} sid - 原歌曲id
* @param {string} asid - 要匹配的歌曲id
*/
export const setCloudMatch = (uid, sid, asid) => {
return axios({
method: "GET",
url: "/cloud/match",
params: {
uid,
sid,
asid,
timestamp: new Date().getTime(),
},
});
};
/**
* 用户云盘上传
* @param {File} file - 要上传的文件
*/
export const upCloudSong = (file, onUploadProgress) => {
const formData = new FormData();
formData.append("songFile", file);
return axios({
url: "/cloud",
method: "POST",
hiddenBar: true,
params: {
timestamp: new Date().getTime(),
},
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
timeout: 200000,
onUploadProgress,
});
};
/**
* 退出登录
*/

View File

@@ -63,3 +63,59 @@ export const getSimiVideo = (mvid) => {
},
});
};
/**
* 收藏/取消收藏视频
* @param {number} t - 操作类型1为收藏2为取消收藏
* @param {number} id - 视频id
*/
export const likeVideo = (t, id) => {
return axios({
method: "GET",
url: "/video/sub",
params: {
t,
id,
timestamp: new Date().getTime(),
},
});
};
/**
* 收藏/取消收藏 MV
* @param {number} t - 操作类型1为收藏2为取消收藏
* @param {number} mvid - MV id
*/
export const likeMv = (t, mvid) => {
return axios({
method: "GET",
url: "/mv/sub",
params: {
t,
mvid,
timestamp: new Date().getTime(),
},
});
};
/**
* 全部 mv
* @param {string} area - 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部
* @param {string} type - 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部
* @param {string} order - 排序,可选值为上升最快,最热,最新,不填则为上升最快
* @param {number} [limit=12] - 返回数量默认12
* @param {number} [offset=0] - 偏移数量默认0
*/
export const allMv = (area, type, order, limit = 12, offset = 0) => {
return axios({
method: "GET",
url: "/mv/all",
params: {
area,
type,
order,
limit,
offset,
},
});
};

View File

@@ -1,8 +1,10 @@
{
"album": "M12 11a1 1 0 0 0-1 1a1 1 0 0 0 1 1a1 1 0 0 0 1-1a1 1 0 0 0-1-1m0 5.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5s4.5 2 4.5 4.5s-2 4.5-4.5 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"chevron-up": "M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6l1.41 1.41Z",
"chevron-down": "M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z",
"play": "M9.525 18.025q-.5.325-1.012.038T8 17.175V6.825q0-.6.513-.888t1.012.038l8.15 5.175q.45.3.45.85t-.45.85l-8.15 5.175Z",
"play-circle": "M10 16.5v-9l6 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"pause-circle": "M15 16h-2V8h2m-4 8H9V8h2m1-6A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2",
"play-next": "m10 16.5l6-4.5l-6-4.5M22 12c0-5.54-4.46-10-10-10c-1.17 0-2.3.19-3.38.56l.7 1.94c.85-.34 1.74-.53 2.68-.53c4.41 0 8.03 3.62 8.03 8.03c0 4.41-3.62 8.03-8.03 8.03c-4.41 0-8.03-3.62-8.03-8.03c0-.94.19-1.88.53-2.72l-1.94-.66C2.19 9.7 2 10.83 2 12c0 5.54 4.46 10 10 10s10-4.46 10-10M5.47 3.97c.85 0 1.53.71 1.53 1.5C7 6.32 6.32 7 5.47 7c-.79 0-1.5-.68-1.5-1.53c0-.79.71-1.5 1.5-1.5Z",
"playlist-add": "M4 16q-.425 0-.713-.288T3 15q0-.425.288-.713T4 14h5q.425 0 .713.288T10 15q0 .425-.288.713T9 16H4Zm0-4q-.425 0-.713-.288T3 11q0-.425.288-.713T4 10h9q.425 0 .713.288T14 11q0 .425-.288.713T13 12H4Zm0-4q-.425 0-.713-.288T3 7q0-.425.288-.713T4 6h9q.425 0 .713.288T14 7q0 .425-.288.713T13 8H4Zm13 12q-.425 0-.713-.288T16 19v-3h-3q-.425 0-.713-.288T12 15q0-.425.288-.713T13 14h3v-3q0-.425.288-.713T17 10q.425 0 .713.288T18 11v3h3q.425 0 .713.288T22 15q0 .425-.288.713T21 16h-3v3q0 .425-.288.713T17 20Z",
"account-music": "M11 14c1 0 2.05.16 3.2.44c-.81.87-1.2 1.89-1.2 3.06c0 .89.25 1.73.78 2.5H3v-2c0-1.19.91-2.15 2.74-2.88C7.57 14.38 9.33 14 11 14m0-2c-1.08 0-2-.39-2.82-1.17C7.38 10.05 7 9.11 7 8c0-1.08.38-2 1.18-2.82C9 4.38 9.92 4 11 4c1.11 0 2.05.38 2.83 1.18C14.61 6 15 6.92 15 8c0 1.11-.39 2.05-1.17 2.83c-.78.78-1.72 1.17-2.83 1.17m7.5-2H22v2h-2v5.5a2.5 2.5 0 0 1-2.5 2.5a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5c.36 0 .69.07 1 .21V10Z",
@@ -22,6 +24,7 @@
"chevron-right": "M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6l-1.41-1.42Z",
"account-circle": "M12 19.2c-2.5 0-4.71-1.28-6-3.2c.03-2 4-3.1 6-3.1s5.97 1.1 6 3.1a7.232 7.232 0 0 1-6 3.2M12 5a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-3A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-5.53-4.5-10-10-10Z",
"menu-down": "m7 10l5 5l5-5H7Z",
"menu": "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5s1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5S5.5 6.83 5.5 6S4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5s1.5-.68 1.5-1.5s-.67-1.5-1.5-1.5zM8 19h12c.55 0 1-.45 1-1s-.45-1-1-1H8c-.55 0-1 .45-1 1s.45 1 1 1zm0-6h12c.55 0 1-.45 1-1s-.45-1-1-1H8c-.55 0-1 .45-1 1s.45 1 1 1zM7 6c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H8c-.55 0-1 .45-1 1z",
"calendar-check": "M19 19H5V8h14m0-5h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-2.47 8.06L15.47 10l-4.88 4.88l-2.12-2.12l-1.06 1.06L10.59 17l5.94-5.94Z",
"calendar-badge": "M19.5 16c-1.9 0-3.5 1.6-3.5 3.5s1.6 3.5 3.5 3.5s3.5-1.6 3.5-3.5s-1.6-3.5-3.5-3.5m-5.29 5H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v9.21c-.5-.13-1-.21-1.5-.21c-.17 0-.33 0-.5.03V8H5v11h9.03c-.03.17-.03.33-.03.5c0 .5.08 1 .21 1.5Z",
"round-wb-sunny": "m6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z",
@@ -47,6 +50,7 @@
"favorite-rounded": "M12 20.325q-.35 0-.713-.125t-.637-.4l-1.725-1.575q-2.65-2.425-4.788-4.813T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.538t2.5-.562q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125Z",
"history": "M12 21q-3.15 0-5.575-1.913T3.275 14.2q-.1-.375.15-.687t.675-.363q.4-.05.725.15t.45.6q.6 2.25 2.475 3.675T12 19q2.925 0 4.963-2.038T19 12q0-2.925-2.038-4.963T12 5q-1.725 0-3.225.8T6.25 8H8q.425 0 .713.288T9 9q0 .425-.288.713T8 10H4q-.425 0-.713-.288T3 9V5q0-.425.288-.713T4 4q.425 0 .713.288T5 5v1.35q1.275-1.6 3.113-2.475T12 3q1.875 0 3.513.713t2.85 1.924q1.212 1.213 1.925 2.85T21 12q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 21Zm1-9.4l2.5 2.5q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.8-2.8q-.15-.15-.225-.337T11 11.975V8q0-.425.288-.713T12 7q.425 0 .713.288T13 8v3.6Z",
"delete": "M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12Z",
"delete-sweep": "M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3zM14 5h-3l-1-1H6L5 5H2v2h12z",
"fire": "M17.66 11.2c-.23-.3-.51-.56-.77-.82c-.67-.6-1.43-1.03-2.07-1.66C13.33 7.26 13 4.85 13.95 3c-.95.23-1.78.75-2.49 1.32c-2.59 2.08-3.61 5.75-2.39 8.9c.04.1.08.2.08.33c0 .22-.15.42-.35.5c-.23.1-.47.04-.66-.12a.58.58 0 0 1-.14-.17c-1.13-1.43-1.31-3.48-.55-5.12C5.78 10 4.87 12.3 5 14.47c.06.5.12 1 .29 1.5c.14.6.41 1.2.71 1.73c1.08 1.73 2.95 2.97 4.96 3.22c2.14.27 4.43-.12 6.07-1.6c1.83-1.66 2.47-4.32 1.53-6.6l-.13-.26c-.21-.46-.77-1.26-.77-1.26m-3.16 6.3c-.28.24-.74.5-1.1.6c-1.12.4-2.24-.16-2.9-.82c1.19-.28 1.9-1.16 2.11-2.05c.17-.8-.15-1.46-.28-2.23c-.12-.74-.1-1.37.17-2.06c.19.38.39.76.63 1.06c.77 1 1.98 1.44 2.24 2.8c.04.14.06.28.06.43c.03.82-.33 1.72-.93 2.27Z",
"search-rounded": "M9.5 16q-2.725 0-4.612-1.888T3 9.5q0-2.725 1.888-4.612T9.5 3q2.725 0 4.612 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16Zm0-2q1.875 0 3.188-1.313T14 9.5q0-1.875-1.313-3.188T9.5 5Q7.625 5 6.312 6.313T5 9.5q0 1.875 1.313 3.188T9.5 14Z",
"search-off": "m7 17.7l1.4 1.425q.15.15.35.15t.35-.15q.15-.15.15-.363T9.1 18.4L7.7 17l1.425-1.425q.15-.15.15-.35t-.15-.35q-.15-.15-.35-.15t-.35.15L7 16.3l-1.425-1.425q-.15-.15-.35-.15t-.35.15q-.15.15-.15.35t.15.35L6.3 17l-1.425 1.425q-.15.15-.15.35t.15.35q.15.15.35.15t.35-.15L7 17.7ZM7 22q-2.075 0-3.538-1.463T2 17q0-2.075 1.463-3.538T7 12q2.075 0 3.538 1.463T12 17q0 2.075-1.463 3.538T7 22Zm7.2-7.4q-.3-.325-.638-.663T12.9 13.3q.95-.6 1.525-1.6T15 9.5q0-1.875-1.313-3.188T10.5 5Q8.625 5 7.312 6.313T6 9.5q0 .15.013.288t.037.287q-.45.05-.987.2t-.963.35q-.05-.275-.075-.55T4 9.5q0-2.725 1.888-4.612T10.5 3q2.725 0 4.612 1.888T17 9.5q0 1.075-.338 2.038t-.937 1.762l5.575 5.6q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275l-5.7-5.7Z",
@@ -80,5 +84,12 @@
"download": "M16.59 9H15V4c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1v5H7.41c-.89 0-1.34 1.08-.71 1.71l4.59 4.59c.39.39 1.02.39 1.41 0l4.59-4.59c.63-.63.19-1.71-.7-1.71zM5 19c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1z",
"radio": "M3.24 6.15C2.51 6.43 2 7.17 2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.89-2-2-2H8.3l8.26-3.34L15.88 1L3.24 6.15zM7 20c-1.66 0-3-1.34-3-3s1.34-3 3-3s3 1.34 3 3s-1.34 3-3 3zm13-8h-2v-2h-2v2H4V8h16v4z",
"person-add": "M15.39 14.56C13.71 13.7 11.53 13 9 13s-4.71.7-6.39 1.56A2.97 2.97 0 0 0 1 17.22V20h16v-2.78c0-1.12-.61-2.15-1.61-2.66zM9 12c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4zm11-3V7c0-.55-.45-1-1-1s-1 .45-1 1v2h-2c-.55 0-1 .45-1 1s.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1h-2z",
"person-remove": "M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4s4-1.79 4-4zM2 18v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4s-8 1.34-8 4zm16-8h4c.55 0 1 .45 1 1s-.45 1-1 1h-4c-.55 0-1-.45-1-1s.45-1 1-1z"
"person-remove": "M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4s4-1.79 4-4zM2 18v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4s-8 1.34-8 4zm16-8h4c.55 0 1 .45 1 1s-.45 1-1 1h-4c-.55 0-1-.45-1-1s.45-1 1-1z",
"github": "M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33c.85 0 1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2Z",
"phone": "M16 1H8C6.34 1 5 2.34 5 4v16c0 1.66 1.34 3 3 3h8c1.66 0 3-1.34 3-3V4c0-1.66-1.34-3-3-3zm1 17H7V4h10v14zm-3 3h-4v-1h4v1z",
"password": "M2 17h20v2H2v-2zm1.15-4.05L4 11.47l.85 1.48l1.3-.75l-.85-1.48H7v-1.5H5.3l.85-1.47L4.85 7L4 8.47L3.15 7l-1.3.75l.85 1.47H1v1.5h1.7l-.85 1.48l1.3.75zm6.7-.75l1.3.75l.85-1.48l.85 1.48l1.3-.75l-.85-1.48H15v-1.5h-1.7l.85-1.47l-1.3-.75L12 8.47L11.15 7l-1.3.75l.85 1.47H9v1.5h1.7l-.85 1.48zM23 9.22h-1.7l.85-1.47l-1.3-.75L20 8.47L19.15 7l-1.3.75l.85 1.47H17v1.5h1.7l-.85 1.48l1.3.75l.85-1.48l.85 1.48l1.3-.75l-.85-1.48H23v-1.5z",
"star": "m12 17.27l4.15 2.51c.76.46 1.69-.22 1.49-1.08l-1.1-4.72l3.67-3.18c.67-.58.31-1.68-.57-1.75l-4.83-.41l-1.89-4.46c-.34-.81-1.5-.81-1.84 0L9.19 8.63l-4.83.41c-.88.07-1.24 1.17-.57 1.75l3.67 3.18l-1.1 4.72c-.2.86.73 1.54 1.49 1.08l4.15-2.5z",
"record": "M17 18.25v3.25H7v-3.25c0-1.38 2.24-2.5 5-2.5s5 1.12 5 2.5M12 5.5a6.5 6.5 0 0 1 6.5 6.5c0 1.25-.35 2.42-.96 3.41L16 14.04c.32-.61.5-1.31.5-2.04c0-2.5-2-4.5-4.5-4.5s-4.5 2-4.5 4.5c0 .73.18 1.43.5 2.04l-1.54 1.37c-.61-.99-.96-2.16-.96-3.41A6.5 6.5 0 0 1 12 5.5m0-4A10.5 10.5 0 0 1 22.5 12c0 2.28-.73 4.39-1.96 6.11l-1.5-1.35c.92-1.36 1.46-3 1.46-4.76A8.5 8.5 0 0 0 12 3.5A8.5 8.5 0 0 0 3.5 12c0 1.76.54 3.4 1.46 4.76l-1.5 1.35A10.473 10.473 0 0 1 1.5 12A10.5 10.5 0 0 1 12 1.5m0 8a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 12A2.5 2.5 0 0 1 12 9.5Z",
"storage": "M4 20h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2s.9 2 2 2m0-3h2v2H4zM2 6c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2m4 1H4V5h2zm-2 7h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2s.9 2 2 2m0-3h2v2H4z",
"lrc-text": "M13.8 22H5c-1.7 0-3-1.3-3-3v-1h11.1c-.1.3-.1.7-.1 1c0 1.1.3 2.1.8 3m0-6H5V5c0-1.7 1.3-3 3-3h11c1.7 0 3 1.3 3 3v1h-2V5c0-.6-.4-1-1-1s-1 .4-1 1v8.1c-1.8.3-3.3 1.4-4.2 2.9M8 8h7V6H8zm0 4h6v-2H8zm9 4v6l5-3z"
}

28
src/assets/idMeta.json Normal file
View File

@@ -0,0 +1,28 @@
{
"radarPlaylist": [
{
"id": 3136952023,
"name": "私人雷达"
},
{
"id": 5320167908,
"name": "时光雷达"
},
{
"id": 5327906368,
"name": "乐迷雷达"
},
{
"id": 5362359247,
"name": "宝藏雷达"
},
{
"id": 5300458264,
"name": "新歌雷达"
},
{
"id": 5341776086,
"name": "神秘雷达"
}
]
}

View File

@@ -0,0 +1,161 @@
<!-- 封面列表 - 播放按钮 -->
<template>
<div class="play-btn" @click.stop>
<n-button
:loading="playLoading"
color="#efefef"
tag="div"
type="primary"
class="play"
size="large"
strong
secondary
circle
@click.stop="playAllSongs"
>
<template #icon>
<Transition name="fade" mode="out-in">
<n-icon :key="`${isHasSongs}-${playState}`" size="50">
<SvgIcon :icon="isHasSongs !== -1 && playState ? 'pause-circle' : 'play-circle'" />
</n-icon>
</Transition>
</template>
</n-button>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getAllPlayList } from "@/api/playlist";
import { getAlbumDetail } from "@/api/album";
import { getDjProgram } from "@/api/dj";
import { musicData, siteStatus, siteData } from "@/stores";
import { playOrPause, initPlayer } from "@/utils/Player";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const { userLikeData } = storeToRefs(data);
const { playList, playSongData } = storeToRefs(music);
const { playIndex, playMode, playHeartbeatMode, playState } = storeToRefs(status);
const props = defineProps({
// id
id: {
type: Number,
required: true,
},
// 歌单类型
type: {
type: String,
default: "playlist",
},
});
// 播放按钮数据
const playLoading = ref(false);
const playListData = ref(null);
// 是否处于当前歌单
const isHasSongs = computed(() => {
if (!playListData.value || playListData.value === 400) return -1;
const songId = music.getPlaySongData?.id;
const existingIndex = playListData.value.findIndex((song) => song.id === songId);
return existingIndex;
});
// 获取歌单数据
const getPlaylistData = async () => {
// 为了播放速度,仅加载列表前 500 首
console.log(props.type, props.id);
// 按列表类别获取数据
switch (props.type) {
case "playlist": {
if (props.id === 1024) {
console.log("播放我喜欢的音乐");
const id = userLikeData.value.playlists?.[0]?.id || null;
if (!isLogin() || !id) return 400;
const result = await getAllPlayList(id, 500);
return formatData(result.songs, "song");
} else {
const result = await getAllPlayList(props.id, 500);
return formatData(result.songs, "song");
}
}
case "album": {
const result = await getAlbumDetail(props.id);
return formatData(result.songs, "song");
}
case "dj": {
const result = await getDjProgram(props.id, 500);
return formatData(result.programs, "dj");
}
default:
return null;
}
};
// 播放歌单
const playAllSongs = async () => {
try {
if (!props.id) return false;
// 开启加载状态
if (props.type !== "mv") {
// 若不处于歌单内
if (isHasSongs.value === -1) {
playLoading.value = true;
// 获取歌单数据
playListData.value = await getPlaylistData();
console.log(playListData.value);
if (!playListData.value) {
playLoading.value = false;
return $message.error("获取播放列表时出现错误");
}
if (playListData.value === 400) {
playLoading.value = false;
return $message.error("请登录后使用");
}
console.log("不在歌单内");
// 更改模式和歌单
playHeartbeatMode.value = false;
playMode.value = props.type === "dj" ? "dj" : "normal";
playList.value = playListData.value.slice();
playSongData.value = playListData.value[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
playLoading.value = false;
$message.info("已开始播放", { showIcon: false });
}
// 若处于歌单内
else {
console.log("处于歌单内");
playSongData.value = playListData.value[isHasSongs.value];
playIndex.value = isHasSongs.value;
// 播放
playOrPause();
}
} else {
// 跳转播放器
router.push({
path: "/videos-player",
query: {
id: props.id,
},
});
}
} catch (error) {
console.error("获取播放列表时出现错误:", error);
$message.error("获取播放列表时出现错误");
}
};
</script>
<style lang="scss" scoped>
.play-btn {
backdrop-filter: blur(20px);
}
</style>

View File

@@ -36,7 +36,17 @@
>
<template #placeholder>
<div :class="['cover-loading', type]">
<img class="loading-img" src="/images/pic/album.jpg?assest" alt="song" />
<img
class="loading-img"
:src="
type === 'mv'
? '/images/pic/video.png?assest'
: type === 'artist'
? '/images/pic/artist.jpg?assest'
: '/images/pic/album.jpg?assest'
"
alt="song"
/>
</div>
<!-- <div :class="['cover-loading', type]">
<n-spin size="small" />
@@ -56,21 +66,31 @@
<n-text class="add-desc">{{ item.desc }}</n-text>
</div>
<!-- 播放按钮 -->
<n-icon class="play">
<SvgIcon :icon="type !== 'artist' ? 'play-circle' : 'account-music'" />
<CoverPlayBtn v-if="type !== 'artist'" :id="item.id" :type="type" />
<n-icon v-else class="play-btn">
<SvgIcon icon="account-music" />
</n-icon>
</div>
<!-- 信息 -->
<div class="cover-content">
<n-text class="name">{{ item.name }}</n-text>
<!-- 创建者 -->
<n-text v-if="item.creator" class="creator" depth="3">
<n-text v-if="item?.creator" class="creator" depth="3">
{{ item.creator?.nickname || item.creator || "未知" }}
</n-text>
<!-- 电台简介 -->
<n-text v-if="item?.rcmdText" class="creator" depth="3">
{{ item.rcmdText || "未知简介" }}
</n-text>
<!-- 歌手 -->
<div v-if="item.artists" class="artists">
<n-text v-for="ar in item.artists" :key="ar.id" class="ar">
{{ ar.name }}
<div v-if="item.artists && type !== 'dj'" class="artists">
<n-text
v-for="ar in item.artists"
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
>
{{ ar.name || ar.userName }}
</n-text>
</div>
<!-- 歌曲数量 -->
@@ -193,6 +213,14 @@ const jumpLink = (data, type) => {
},
});
break;
case "dj":
router.push({
path: "/dj",
query: {
id: data?.id,
},
});
break;
default:
break;
}
@@ -255,7 +283,9 @@ const jumpLink = (data, type) => {
top: -80px;
left: 0;
z-index: 1;
width: 100%;
padding: 6px 10px;
box-sizing: border-box;
background: -webkit-linear-gradient(top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
opacity: 0;
transition:
@@ -267,14 +297,18 @@ const jumpLink = (data, type) => {
-webkit-line-clamp: 2;
}
}
.play {
.play-btn {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
right: 8px;
bottom: 8px;
opacity: 0;
color: #efefefde;
transform: translateY(6px);
font-size: 50px;
border-radius: 50%;
overflow: hidden;
z-index: 3;
transition:
opacity 0.3s,
@@ -297,8 +331,10 @@ const jumpLink = (data, type) => {
word-break: break-all;
.ar {
display: inline-flex;
color: var(--n-close-icon-color);
transition: color 0.3s;
opacity: 0.6;
transition:
color 0.3s,
opacity 0.3s;
cursor: pointer;
&::after {
content: "/";
@@ -311,10 +347,7 @@ const jumpLink = (data, type) => {
}
}
&:hover {
color: var(--n-code-text-color);
&::after {
color: var(--n-close-icon-color);
}
opacity: 0.8;
}
}
}
@@ -338,7 +371,7 @@ const jumpLink = (data, type) => {
top: 0;
opacity: 1;
}
.play {
.play-btn {
opacity: 1;
transform: translateY(0);
&:hover {
@@ -367,8 +400,8 @@ const jumpLink = (data, type) => {
border-radius: 50%;
overflow: hidden;
}
.play {
// display: none;
.play-btn {
font-size: 50px;
right: auto;
bottom: auto;
}

View File

@@ -33,9 +33,7 @@
</template>
</n-image>
<!-- 播放按钮 -->
<n-icon v-if="showIcon" class="play" @click.stop>
<SvgIcon icon="play-circle" />
</n-icon>
<CoverPlayBtn v-if="showIcon" :id="data.id" class="play" />
<!-- 日期 -->
<div v-if="showDate" class="cover-date">
<n-icon class="date-icon">
@@ -145,15 +143,24 @@ const props = defineProps({
.play {
position: absolute;
opacity: 0;
font-size: 60px;
color: #ffffffe6;
transform: scale(0.8);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition:
transform 0.3s,
opacity 0.3s;
cursor: pointer;
:deep(.play) {
--n-width: 50px;
--n-height: 50px;
--n-font-size: 20px;
.n-icon {
font-size: 60px !important;
}
}
&:hover {
transform: scale(1.2);
transform: scale(1.1);
}
&:active {
transform: scale(1);
@@ -212,7 +219,6 @@ const props = defineProps({
}
&:hover {
background-color: var(--n-close-color-hover);
transform: translate3d(-2px, 0, 0);
.cover-main-img {
filter: brightness(0.8);
}

View File

@@ -0,0 +1,50 @@
<template>
<main id="main-layout" :class="['main-layout', { 'no-sider': !showSider }]">
<!-- 回顶 -->
<n-back-top
:bottom="music.getPlaySongData?.id && showPlayBar ? 110 : 50"
style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
<n-icon size="26">
<SvgIcon icon="chevron-up" />
</n-icon>
</n-back-top>
<!-- 路由页面 -->
<router-view v-slot="{ Component }" class="main-router">
<keep-alive>
<Transition name="router" mode="out-in">
<component :is="Component" />
</Transition>
</keep-alive>
</router-view>
</main>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { musicData, siteStatus, siteSettings } from "@/stores";
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { showPlayBar } = storeToRefs(status);
const { showSider } = storeToRefs(settings);
</script>
<style lang="scss" scoped>
.main-layout {
padding: 24px;
&.no-sider {
padding: 0;
background-color: var(--n-color);
.main-router {
max-width: 1400px;
margin: 0 auto;
padding: 24px 10vw;
@media (max-width: 1200px) {
padding: 24px 5vw;
}
}
}
}
</style>

View File

@@ -3,15 +3,16 @@
<n-menu
ref="mainMenuRef"
v-model:value="menuActiveKey"
class="main-menu"
:root-indent="36"
:class="['main-menu', { cover: siderShowCover }]"
:root-indent="showSider ? 36 : 26"
:indent="0"
:collapsed="status.asideMenuCollapsed"
:collapsed="asideMenuCollapsed.value"
:defaultExpandedKeys="['user-playlists', 'favorite-playlists']"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
@contextmenu="openSideDropdown($event)"
@contextmenu.stop
@update:value="checkMenuItem"
/>
<!-- 右键菜单 -->
<CoverDropdown ref="coverDropdownRef" />
@@ -21,8 +22,8 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteStatus, siteData, musicData } from "@/stores";
import { NIcon, NText, NButton } from "naive-ui";
import { siteStatus, siteData, musicData, siteSettings } from "@/stores";
import { NIcon, NText, NButton, NAvatar } from "naive-ui";
import { useRouter, RouterLink } from "vue-router";
import { getHeartRateList } from "@/api/playlist";
import { checkPlatform } from "@/utils/helper";
@@ -36,9 +37,12 @@ const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const { userData, userLikeData } = storeToRefs(data);
const { playList, playListOld, playIndex, playSongData, playHeartbeatMode, playMode } =
storeToRefs(music);
const settings = siteSettings();
const { siderShowCover } = storeToRefs(settings);
const { asideMenuCollapsed, showSider, showFullPlayer, playIndex, playMode, playHeartbeatMode } =
storeToRefs(status);
const { userData, userLikeData, userLoginStatus } = storeToRefs(data);
const { playList, playListOld, playSongData, privateFmSong } = storeToRefs(music);
// 子组件
const coverDropdownRef = ref(null);
@@ -90,7 +94,7 @@ const menuOptions = computed(() => [
label: "在线音乐",
key: "online",
children: [],
show: !status.asideMenuCollapsed,
show: !asideMenuCollapsed.value,
},
{
label: () =>
@@ -120,19 +124,24 @@ const menuOptions = computed(() => [
key: "discover",
icon: renderIcon("discover-fill"),
},
{
label: "私人漫游",
key: "fm",
icon: renderIcon("radio"),
},
{
label: () =>
h(
RouterLink,
{
to: {
name: "videos",
name: "dj-hot",
},
},
() => ["视频"],
() => ["播客电台"],
),
key: "videos",
icon: renderIcon("video"),
key: "dj-hot",
icon: renderIcon("record"),
},
{
key: "divider-1",
@@ -143,7 +152,7 @@ const menuOptions = computed(() => [
label: "我的音乐",
key: "user",
children: [],
show: !status.asideMenuCollapsed,
show: !asideMenuCollapsed.value,
},
{
label: () =>
@@ -154,27 +163,53 @@ const menuOptions = computed(() => [
path: "/like-songs",
},
class: "user-playlist",
menuid: "like-songs",
},
() => [
h(NText, null, () => ["喜欢的音乐"]),
h(NButton, {
size: "small",
type: "tertiary",
round: true,
strong: true,
secondary: true,
renderIcon: renderIcon("heartbit", "26"),
onclick: (event) => {
event.stopPropagation();
startHeartRate();
h(
"div",
{
style: {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
},
}),
[
h("span", null, ["喜欢的音乐"]),
h(NButton, {
size: "small",
type: "tertiary",
round: true,
strong: true,
secondary: true,
class: asideMenuCollapsed.value ? "heart-rate-btn collapsed" : "heart-rate-btn",
renderIcon: renderIcon("heartbit", "26"),
onclick: () => {
startHeartRate();
},
}),
],
),
],
),
key: "like-songs",
icon: renderIcon("favorite-rounded"),
},
{
label: () =>
h(
RouterLink,
{
to: {
name: "like",
},
},
() => ["我的收藏"],
),
key: "like",
icon: renderIcon("star"),
},
{
label: () =>
h(
@@ -222,13 +257,18 @@ const menuOptions = computed(() => [
key: "divider-2",
type: "divider",
},
{ ...userPlaylists.value, show: !status.asideMenuCollapsed },
{ ...favoritePlaylists.value, show: !status.asideMenuCollapsed },
{ ...userPlaylists.value, show: !asideMenuCollapsed.value },
{ ...favoritePlaylists.value, show: !asideMenuCollapsed.value },
]);
// 更改用户的歌单
const changeUserPlaylists = (data) => {
if (!isLogin() || !data?.length) return false;
// 未登录
if (!isLogin() || !data?.length) {
userPlaylists.value.children = [];
favoritePlaylists.value.children = [];
return false;
}
// 用户 id
const userId = userData.value?.userId;
// 创建的歌单
@@ -239,52 +279,102 @@ const changeUserPlaylists = (data) => {
userPlaylists.value.children = userPlaylistsData.slice(1).map((v) => {
return {
label: () =>
h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
siderShowCover.value
? h(
"div",
{
class: "user-pl-cover",
onclick: () => {
router.push({
path: "/playlist",
query: {
id: v.id,
},
});
},
},
},
class: "user-playlist",
menuId: v.id,
},
() => [h(NText, null, () => [v.name])],
),
[
h(NAvatar, { src: v?.coverSize?.s, fallbackSrc: "/images/pic/album.jpg?assest" }),
h(NText, null, () => [v.name]),
],
)
: h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
},
},
class: "user-playlist",
},
() => [h(NText, null, () => [v.name])],
),
key: v.id,
icon: renderIcon("queue-music-rounded"),
icon: siderShowCover.value ? null : renderIcon("queue-music-rounded"),
};
});
favoritePlaylists.value.children = favoritePlaylistsData.map((v) => {
return {
label: () =>
h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
siderShowCover.value
? h(
"div",
{
class: "user-pl-cover",
onclick: () => {
router.push({
path: "/playlist",
query: {
id: v.id,
},
});
},
},
},
class: "user-playlist",
menuId: v.id,
},
() => [h(NText, null, () => [v.name])],
),
[
h(NAvatar, { src: v?.coverSize?.s, fallbackSrc: "/images/pic/album.jpg?assest" }),
h(NText, null, () => [v.name]),
],
)
: h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
},
},
class: "user-playlist",
},
() => [h(NText, null, () => [v.name])],
),
key: v.id,
icon: renderIcon("queue-music-rounded"),
icon: siderShowCover.value ? null : renderIcon("queue-music-rounded"),
};
});
};
// 选中菜单项
const checkMenuItem = (key) => {
console.log(key);
const checkMenuItem = async (key) => {
// 例外路由
const otherRouter = ["search", "videos-player", "playlist", "like-songs"];
// 私人漫游
if (key === "fm") {
if (!privateFmSong.value || !Object.keys(privateFmSong.value)?.length) {
return $message.error("开启私人漫游出错,请重试");
}
if (playMode.value === "fm") {
fadePlayOrPause();
} else {
// 更改播放模式
playMode.value = "fm";
await initPlayer(true);
}
showFullPlayer.value = true;
$message.info("已开启私人漫游", { icon: renderIcon("radio") });
}
// 特殊处理
if (!key) {
menuActiveKey.value = "home";
@@ -319,20 +409,11 @@ const checkMenuItem = (key) => {
mainMenuRef.value?.showOption(key);
};
// 开启右键菜单
const openSideDropdown = (e) => {
e.preventDefault();
if (!e.target.classList.contains("user-playlist")) return false;
// 获取 id
const menuId = e.target.getAttribute("menuid");
coverDropdownRef.value?.openDropdown(e, "playlist", menuId);
};
// 开启心动模式
const startHeartRate = debounce(async () => {
try {
if (!isLogin()) return false;
if (playHeartbeatMode.value) return await fadePlayOrPause();
if (playHeartbeatMode.value) return fadePlayOrPause();
// 基础数据
const likeSongs = userLikeData.value.songs;
const songId = playSongData.value?.id;
@@ -381,10 +462,8 @@ watch(
// 监听用户歌单变化
watch(
() => userLikeData.value.playlists,
(val) => {
changeUserPlaylists(val);
},
[() => userLikeData.value.playlists, () => userLoginStatus.value, () => siderShowCover.value],
() => changeUserPlaylists(userLikeData.value.playlists),
);
onMounted(() => {
@@ -394,6 +473,7 @@ onMounted(() => {
<style lang="scss" scoped>
.main-menu {
padding-bottom: 14px;
:deep(.n-menu-item) {
.n-menu-item-content {
&.n-menu-item-content--selected {
@@ -406,12 +486,25 @@ onMounted(() => {
text-overflow: ellipsis;
overflow: hidden;
}
// 我喜欢的音乐
// 普通歌单
.user-playlist {
display: flex;
align-items: center;
justify-content: space-between;
}
// 带封面歌单
.user-pl-cover {
display: flex;
flex-direction: row;
align-items: center;
.n-avatar {
border-radius: 8px;
width: 34px;
height: 34px;
min-width: 34px;
margin-right: 12px;
}
}
}
}
// 折叠菜单
@@ -444,5 +537,29 @@ onMounted(() => {
}
}
}
&.cover {
:deep(.n-submenu-children) {
--n-item-height: 50px;
}
}
}
</style>
<!-- 特殊样式 -->
<style lang="scss">
.heart-rate-btn {
&:hover {
background-color: var(--main-second-color) !important;
color: var(--main-color) !important;
}
&.collapsed {
margin-left: 12px;
background-color: #efefef40;
color: #efefef;
&:hover {
background-color: #efefef60 !important;
color: #efefef !important;
}
}
}
</style>

View File

@@ -1,15 +1,16 @@
<!-- 全局播放列表 -->
<template>
<n-drawer
v-model:show="status.playListShow"
:class="status.showFullPlayer ? 'main-playlist player' : 'main-playlist'"
v-model:show="playListShow"
:class="['main-playlist', { 'full-player': showFullPlayer }]"
:style="{
'--color': coverColor,
'--color-bg': coverColor + '14',
'--cover-main-color': `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
'--cover-second-color': `rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
}"
:auto-focus="false"
@after-enter="playlistOpen"
@after-leave="status.playListShow = false"
@mask-click="status.playListShow = false"
@after-leave="playListShow = false"
@mask-click="playListShow = false"
>
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
@@ -20,28 +21,100 @@
</n-text>
</div>
</template>
<!-- 提示 -->
<n-alert v-if="playList?.length >= 400" class="alert" :show-icon="false">
因歌单数量过大无法自动定位请手动查找
</n-alert>
<!-- 歌曲列表 -->
<div class="list">
<Transition name="fade" mode="out-in">
<n-virtual-list
v-if="playList?.length"
ref="playListRef"
:item-size="76"
:items="playListData"
:default-scroll-index="playIndex"
style="max-height: calc(100vh - 158px)"
>
<template #default="{ item, index }">
<div
:id="`songs-${index}`"
:key="item.id"
:class="[
'songs-item',
{ play: playSongData?.id === item?.id, player: showFullPlayer },
]"
@click.stop="playSong(item, index)"
@dblclick.stop="playSong(item, index)"
>
<!-- 序号 -->
<n-text v-if="playSongData?.id !== item?.id" class="num" depth="3">
{{ index + 1 }}
</n-text>
<n-icon v-else class="play" size="18">
<SvgIcon icon="music-note" />
</n-icon>
<!-- 信息 -->
<div class="info">
<!-- 歌曲名 -->
<n-text class="name" depth="2">{{ item?.name || "未知曲目" }}</n-text>
<!-- 歌手 -->
<div v-if="Array.isArray(item?.artists)" class="artist">
<n-text v-for="ar in item.artists" :key="ar.id" depth="3" class="ar">
{{ ar.name }}
</n-text>
</div>
<div v-else-if="playMode === 'dj'" class="artist">
<n-text class="ar"> 电台节目 </n-text>
</div>
<div v-else class="artist">
<n-text class="ar"> {{ item?.artists || "未知艺术家" }} </n-text>
</div>
</div>
<!-- 删除 -->
<n-icon class="delete" size="18" @click.stop="removeSong(index)">
<SvgIcon icon="delete" />
</n-icon>
</div>
</template>
</n-virtual-list>
<n-empty
v-else
description="播放列表暂无歌曲,快去添加吧"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
<!-- 操作 -->
<Transition name="fade" mode="out-in">
<n-data-table
v-if="playList?.length"
class="pl-list"
:columns="columns"
:data="playList"
:bordered="false"
:bottom-bordered="false"
:max-height="playList?.length >= 400 ? 'calc(100vh - 162px)' : '100%'"
virtual-scroll
/>
<n-empty
v-else
description="播放列表暂无歌曲快去添加吧"
class="tip"
style="margin-top: 60px"
size="large"
/>
<n-grid v-if="playList?.length" :cols="2" x-gap="16" class="controls">
<n-gi>
<!-- 定位歌曲 -->
<n-button
size="large"
tag="div"
strong
secondary
@click="playListRef?.scrollTo({ index: playIndex })"
>
<template #icon>
<n-icon>
<SvgIcon icon="location" />
</n-icon>
</template>
当前播放
</n-button>
</n-gi>
<n-gi>
<!-- 清空列表 -->
<n-button size="large" tag="div" strong secondary @click="cleanPlaylists">
<template #icon>
<n-icon>
<SvgIcon icon="delete-sweep" />
</n-icon>
</template>
清空列表
</n-button>
</n-gi>
</n-grid>
</Transition>
</n-drawer-content>
</n-drawer>
@@ -50,19 +123,30 @@
<script setup>
import { NText, NIcon } from "naive-ui";
import { storeToRefs } from "pinia";
import { musicData, siteStatus } from "@/stores";
import {
initPlayer,
fadePlayOrPause,
changePlayIndex,
soundStop,
checkPlayer,
} from "@/utils/Player";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { initPlayer, fadePlayOrPause, changePlayIndex, soundStop } from "@/utils/Player";
import SvgIcon from "@/components/Global/SvgIcon";
import debounce from "@/utils/debounce";
const music = musicData();
const status = siteStatus();
const { coverColor, playSongData, playList, playIndex, playMode } = storeToRefs(music);
const settings = siteSettings();
const { useMusicCache } = storeToRefs(settings);
const { playSongData, playList } = storeToRefs(music);
const { coverTheme, showFullPlayer, playListShow, playIndex, playMode, playLoading } =
storeToRefs(status);
const playListRef = ref(null);
// 播放列表数据
const playListData = computed(() => {
return playList.value?.[0]
? playList.value.slice().map((v, i) => {
v.key = `${i}`;
return v;
})
: [];
});
// 抽屉开启
const playlistOpen = () => {
@@ -75,75 +159,15 @@ const playlistOpen = () => {
});
};
// 表格数据
const columns = computed(() => [
{
key: "songs",
className: "songs-item",
render(song, index) {
return createSongs(song, index);
},
},
]);
// 列表歌曲模块
const createSongs = (song, index) => {
return h(
"div",
{
id: `pl-song-${index}`,
class: {
songs: true,
play: playSongData.value?.id === song?.id,
player: status.showFullPlayer,
},
onClick: () => {
playSong(song, index);
},
},
[
// 序号
playSongData.value?.id !== song?.id
? h(NText, { class: "num", depth: "3" }, () => [index + 1])
: h(NIcon, { class: "play", size: "18" }, () => [h(SvgIcon, { icon: "music-note" })]),
// 信息
h("div", { class: "info" }, [
// 名称
h(NText, { class: "name", depth: "2" }, () => [song?.name || "未知曲目"]),
// 歌手
Array.isArray(song.artists)
? h(
"div",
{ class: "artist" },
song.artists.map((ar) =>
h(NText, { class: "ar", depth: "3", key: ar.id }, () => [ar.name]),
),
)
: h("div", { class: "artist" }, [
h(NText, { class: "ar", depth: "3" }, () => [song.artists || "未知艺术家"]),
]),
]),
// 移除
h(
NIcon,
{
class: "delete",
size: "18",
onClick: (e) => {
e.stopPropagation();
removeSong(index);
},
},
() => [h(SvgIcon, { icon: "delete" })],
),
],
);
};
// 播放歌曲
const playSong = async (song, index) => {
const playSong = debounce(async (song, index) => {
// 若开启了缓存且正在加载
if (useMusicCache.value && playLoading.value) {
$message.warning("歌曲正在缓冲中,请稍后");
return false;
}
// 更改模式
playMode.value = "normal";
if (playMode.value !== "dj") playMode.value = "normal";
// 更改播放索引
playIndex.value = index;
// 是否为当前播放歌曲
@@ -153,28 +177,42 @@ const playSong = async (song, index) => {
} else {
console.log("与当前播放歌曲不一致");
playSongData.value = song;
// 渐出音乐
if (checkPlayer()) await fadePlayOrPause("pause");
// 初始化播放器
initPlayer(true);
await initPlayer(true);
}
}, 300);
// 清空列表
const cleanPlaylists = () => {
soundStop();
playIndex.value = 0;
playList.value = [];
playSongData.value = {};
playListShow.value = false;
showFullPlayer.value = false;
$message.success("已清空播放列表");
};
// 移除歌曲
const removeSong = async (index) => {
if (index < playIndex.value) {
playIndex.value--;
} else if (index === playIndex.value) {
// 如果删除的是当前播放歌曲,则下一曲
// 若删除时仅剩一首
if (playList.value.length === 1) {
cleanPlaylists();
return false;
}
// 若为当前播放
if (index === playIndex.value) {
playList.value.splice(index, 1);
changePlayIndex("next", true);
}
playList.value.splice(index, 1);
// 检查当前播放歌曲的索引是否超出了列表范围
if (playIndex.value >= playList.value.length) {
playIndex.value = 0;
playList.value = [];
playSongData.value = {};
soundStop();
// 若为当前播放之前
else if (index < playIndex.value) {
playIndex.value--;
playList.value.splice(index, 1);
}
// 若大于当前播放
else if (index > playIndex.value) {
playList.value.splice(index, 1);
}
};
</script>
@@ -186,146 +224,132 @@ const removeSong = async (index) => {
font-size: 12px;
}
}
.alert {
height: 48px;
.list {
border-radius: 8px;
margin-bottom: 12px;
}
.pl-list {
:deep(.n-data-table-thead) {
display: none;
}
:deep(.n-data-table-tbody) {
.songs-item {
padding: 0;
.songs {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-radius: 8px;
margin-bottom: 12px;
padding: 8px;
border: 1px solid transparent;
background-color: var(--n-border-color-modal);
transition:
transform 0.3s,
border-color 0.3s,
box-shadow 0.3s,
background-color 0.3s;
cursor: pointer;
.num,
.play {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
margin-right: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.info {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
.artist {
margin-top: 2px;
font-size: 13px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
word-break: break-all;
.ar {
font-size: 12px;
display: inline-flex;
&::after {
content: "/";
margin: 0 4px;
}
&:last-child {
&::after {
display: none;
}
}
overflow: hidden;
.songs-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 64px;
border-radius: 8px;
margin-bottom: 12px;
padding: 8px;
border: 1px solid transparent;
box-sizing: border-box;
background-color: var(--n-close-color-hover);
transition:
transform 0.3s,
border-color 0.3s,
box-shadow 0.3s,
background-color 0.3s;
cursor: pointer;
.num,
.play {
width: 34px;
height: 34px;
min-width: 34px;
border-radius: 8px;
margin-right: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.info {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
.artist {
margin-top: 2px;
font-size: 13px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
word-break: break-all;
.ar {
font-size: 12px;
display: inline-flex;
&::after {
content: "/";
margin: 0 4px;
}
&:last-child {
&::after {
display: none;
}
}
}
.delete {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
margin-right: 6px;
border-radius: 8px;
transition: background-color 0.3s;
cursor: pointer;
&:hover {
background-color: var(--n-close-color-hover);
}
}
&.play {
background-color: var(--main-second-color);
border-color: var(--main-color);
a,
span,
.n-icon {
color: var(--main-color) !important;
}
.artist {
.ar {
opacity: 0.8;
}
}
}
&.player {
background-color: var(--cover-second-color);
a,
span,
.n-icon {
color: var(--cover-main-color) !important;
opacity: 0.8;
}
&.play {
background-color: var(--cover-second-color);
border-color: var(--cover-main-color);
a,
span,
.n-icon {
color: var(--cover-main-color) !important;
opacity: 1;
}
}
&:hover {
border-color: var(--cover-main-color);
box-shadow: none;
}
}
&:hover {
border-color: var(--main-color);
box-shadow:
0 1px 2px -2px var(--main-boxshadow-color),
0 3px 6px 0 var(--main-boxshadow-color),
0 5px 12px 4px var(--main-boxshadow-hover-color);
}
&:active {
transform: scale(0.995);
}
}
}
.n-data-table-tr {
&:last-child {
.songs {
margin-bottom: 0;
.delete {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
margin-right: 6px;
border-radius: 8px;
transition: background-color 0.3s;
cursor: pointer;
&:hover {
background-color: var(--n-close-color-hover);
}
}
&.play {
background-color: var(--main-second-color);
border-color: var(--main-color);
a,
span,
.n-icon {
color: var(--main-color) !important;
}
.artist {
.ar {
opacity: 0.8;
}
}
}
&.player {
background-color: var(--cover-second-color);
a,
span,
.n-icon {
color: var(--cover-main-color) !important;
opacity: 0.8;
}
&.play {
background-color: var(--cover-second-color);
border-color: var(--cover-main-color);
a,
span,
.n-icon {
color: var(--cover-main-color) !important;
opacity: 1;
}
}
&:hover {
border-color: var(--cover-main-color);
box-shadow: none;
}
}
&:hover {
border-color: var(--main-color);
}
&:active {
transform: scale(0.995);
}
}
}
.tip {
border-radius: 8px;
.controls {
height: 40px;
margin-top: 16px;
.n-button {
width: 100%;
border-radius: 8px;
box-sizing: border-box;
}
}
</style>
@@ -336,14 +360,19 @@ const removeSong = async (index) => {
.main-playlist {
width: 400px !important;
border-radius: 12px 0 0 12px;
transition: width 0.3s;
.n-drawer-header {
height: 70px;
box-sizing: border-box;
}
.n-scrollbar-content {
padding: 16px !important;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&.player {
&.full-player {
background-color: transparent;
box-shadow: none;
.n-drawer-header {
@@ -360,5 +389,9 @@ const removeSong = async (index) => {
}
}
}
@media (max-width: 700px) {
width: 100% !important;
border-radius: 0;
}
}
</style>

View File

@@ -35,12 +35,13 @@ import {
useNotification,
} from "naive-ui";
import { storeToRefs } from "pinia";
import { siteSettings, siteData } from "@/stores";
import { siteSettings, siteStatus } from "@/stores";
import themeColorData from "@/assets/themeColor.json";
const data = siteData();
const osThemeRef = useOsTheme();
const status = siteStatus();
const settings = siteSettings();
const osThemeRef = useOsTheme();
const { coverTheme } = storeToRefs(status);
const { themeType, themeAuto, themeTypeName, themeTypeData, themeAutoCover } =
storeToRefs(settings);
@@ -63,62 +64,70 @@ const changeTheme = () => {
// 配置主题色
const changeThemeColor = (val, isCover = false) => {
const color =
isCover && Object.keys(val)?.length
try {
// 获取主色数据
const mainColorData =
isCover && Object.keys(val)?.length
? val[themeType.value]
: val !== "custom"
? themeColorData[val]
: themeTypeData.value;
// 微调主题色
const primaryColor = isCover ? `rgb(${mainColorData.bg})` : mainColorData.primaryColor;
const primaryColorHover = isCover ? `rgba(${mainColorData.bg}, 0.29)` : primaryColor + "29";
const primaryColorPressed = isCover ? `rgba(${mainColorData.bg}, 0.26)` : primaryColor + "26";
const primaryColorSuppl = isCover ? `rgba(${mainColorData.bg}, 0.05)` : primaryColor + "05";
// 自适应主题背景色
const coverAutobgCover = isCover
? themeType.value === "dark"
? val.light.bg
: val.dark.bg
: val !== "custom"
? themeColorData[val]
: themeTypeData.value;
// 微调主题色
const primaryColor = isCover ? `rgb(${color})` : color.primaryColor;
const primaryColorHover = isCover ? `rgba(${color}, 0.29)` : primaryColor + "29";
const primaryColorPressed = isCover ? `rgba(${color}, 0.26)` : primaryColor + "26";
const primaryColorSuppl = isCover ? `rgba(${color}, 0.05)` : primaryColor + "05";
// 自适应主题背景色
const coverAutobgCover = isCover
? themeType.value === "dark"
? val.dark.bg
: "239, 239, 239"
: null;
// 更新主题覆盖
themeOverrides.value = {
common:
isCover && Object.keys(val)?.length
? {
primaryColor,
primaryColorHover,
primaryColorPressed,
primaryColorSuppl,
textColor1: `rgba(${color}, 0.9)`,
textColor2: `rgba(${color}, 0.82)`,
textColor3: `rgba(${color}, 0.52)`,
bodyColor: `rgba(${val.dark.mainBg}, 0.52)`,
cardColor: `rgb(${coverAutobgCover})`,
tagColor: `rgb(${coverAutobgCover})`,
modalColor: `rgb(${coverAutobgCover})`,
popoverColor: `rgb(${coverAutobgCover})`,
}
: color,
Icon: { color: isCover ? primaryColor : null },
};
if (!isCover) themeTypeData.value = color;
// 更新全局颜色变量
setCssVariable("--main-color", primaryColor);
setCssVariable(
"--main-color-bg",
isCover ? `rgb(${val[themeType.value]?.bg})` : "rgb(16, 16, 20)",
);
setCssVariable("--main-second-color", primaryColorHover);
setCssVariable("--main-boxshadow-color", primaryColorPressed);
setCssVariable("--main-boxshadow-hover-color", primaryColorSuppl);
: "250, 250, 252"
: null;
// 更新主题覆盖
themeOverrides.value = {
common:
isCover && Object.keys(val)?.length
? themeType.value === "dark"
? {
primaryColor,
primaryColorHover,
primaryColorPressed,
primaryColorSuppl,
textColor1: `rgba(${mainColorData.bg}, 0.9)`,
textColor2: `rgba(${mainColorData.bg}, 0.82)`,
textColor3: `rgba(${mainColorData.bg}, 0.52)`,
bodyColor: `rgb(${val.dark.mainBg})`,
cardColor: `rgb(${coverAutobgCover})`,
tagColor: `rgb(${coverAutobgCover})`,
modalColor: `rgb(${coverAutobgCover})`,
popoverColor: `rgb(${coverAutobgCover})`,
}
: {
primaryColor,
primaryColorHover,
primaryColorPressed,
primaryColorSuppl,
}
: mainColorData,
Icon: { color: isCover ? primaryColor : null },
};
if (!isCover) themeTypeData.value = mainColorData;
// 更新全局颜色变量
setCssVariable("--main-color", primaryColor);
setCssVariable("--main-second-color", primaryColorHover);
setCssVariable("--main-boxshadow-color", primaryColorPressed);
setCssVariable("--main-boxshadow-hover-color", primaryColorSuppl);
} catch (error) {
themeOverrides.value = {};
console.error("切换主题色出现错误:", error);
$message.error("切换主题色出现错误,已使用默认配置");
}
};
// 修改全局颜色
const setCssVariable = (name, value) => {
document.documentElement.style.setProperty(name, value);
// document.body.style.setProperty(name, value);
// document.documentElement.style.setProperty(name, value);
document.body.style.setProperty(name, value);
};
// 挂载 naive 组件
@@ -148,8 +157,10 @@ watch(
() => {
changeTheme();
changeThemeColor(
themeAutoCover.value ? data.coverTheme : themeTypeName.value,
themeAutoCover.value,
themeAutoCover.value && Object.keys(coverTheme.value)?.length
? coverTheme.value
: themeTypeName.value,
themeAutoCover.value && Object.keys(coverTheme.value)?.length,
);
},
);
@@ -170,9 +181,8 @@ watch(
(val) => changeThemeColor(val.label),
);
watch(
() => data.coverTheme,
() => coverTheme.value,
(val) => {
console.log(val);
if (themeAutoCover.value) changeThemeColor(val, themeAutoCover.value);
},
);

View File

@@ -293,7 +293,7 @@ const toLikeComment = throttle(
width: 100%;
padding: 4px 8px;
border-radius: 8px;
background-color: var(--n-border-color);
background-color: var(--main-second-color);
font-size: 13px;
margin-top: 6px;
box-sizing: border-box;

View File

@@ -1,15 +1,22 @@
<!-- 歌曲列表 -->
<template>
<Transition name="fade" mode="out-in" @after-enter="checkHasPlaying">
<div v-if="data !== 'empty' && data?.length && data[0] !== 'empty'" class="song-list">
<div v-if="data?.[0]?.id" class="song-list">
<div v-if="showTitle" class="song-list-header">
<n-text class="num" depth="3"> # </n-text>
<n-text :class="{ info: true, 'has-cover': data[0].cover && showCover }" depth="3">
歌曲
<n-text :class="['info', { 'has-cover': data[0].cover && showCover }]" depth="3">
{{ type === "song" ? "歌曲" : "声音" }}
</n-text>
<n-text v-if="data[0].album && showAlbum" class="album" depth="3"> 专辑 </n-text>
<n-text v-if="data[0].duration" class="duration" depth="3"> 时长 </n-text>
<n-text v-if="data[0].size" class="size" depth="3"> 大小 </n-text>
<n-text v-if="data[0].album && showAlbum" class="album hidden" depth="3"> 专辑 </n-text>
<n-text v-if="data[0].updateTime && type === 'dj'" class="update hidden" depth="3">
更新日期
</n-text>
<n-text v-if="type !== 'dj'" class="control" />
<n-text v-if="data[0].playCount && type === 'dj'" class="count hidden" depth="3">
播放量
</n-text>
<n-text v-if="data[0].duration" class="duration hidden" depth="3"> 时长 </n-text>
<n-text v-if="data[0].size" class="size hidden" depth="3"> 大小 </n-text>
</div>
<n-card
v-for="(item, index) in data.slice(
@@ -27,16 +34,17 @@
}"
:class="music.getPlaySongData?.id === item?.id ? 'songs play' : 'songs'"
hoverable
@click="checkCanClick(data, item, songsIndex + index)"
@dblclick.stop="playSong(data, item, songsIndex + index)"
@contextmenu="
songListDropdownRef?.openDropdown($event, data, item, songsIndex + index, sourceId)
songListDropdownRef?.openDropdown($event, data, item, songsIndex + index, sourceId, type)
"
>
<!-- 序号 -->
<n-text v-if="music.getPlaySongData?.id !== item?.id" class="num" depth="3">
{{ songsIndex + index + 1 }}
</n-text>
<n-icon v-else class="play" size="22">
<n-icon v-else class="num" size="22">
<SvgIcon icon="music-note" />
</n-icon>
<!-- 封面 -->
@@ -63,7 +71,10 @@
<div class="info">
<div class="title">
<!-- 名称 -->
<n-text class="name" depth="2">{{ item?.name || "未知曲目" }}</n-text>
<!-- @click.stop="type !== 'dj' && !item.path ? router.push(`/song?id=${item.id}`) : null" -->
<n-text class="name" depth="2">
{{ item?.name || "未知曲目" }}
</n-text>
<!-- 特权 -->
<n-tag
v-if="showPrivilege && item.fee === 1 && userData.detail?.profile?.vipType !== 11"
@@ -118,32 +129,78 @@
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
@dblclick.stop
>
{{ ar.name }}
</n-text>
</div>
<div v-else-if="type === 'dj'" class="artist">
<n-text class="ar" @dblclick.stop> 电台节目 </n-text>
</div>
<div v-else class="artist">
<n-text class="ar"> {{ item.artists || "未知艺术家" }} </n-text>
<n-text class="ar" @dblclick.stop> {{ item.artists || "未知艺术家" }} </n-text>
</div>
<!-- 别名 -->
<n-text v-if="item.alia" class="alia" depth="3">{{ item.alia }}</n-text>
</div>
<!-- 专辑 -->
<template v-if="showAlbum">
<template v-if="showAlbum && type !== 'dj'">
<n-text
v-if="item.album"
class="album"
@click.stop="item.album !== 'string' ? router.push(`/album?id=${item.album.id}`) : null"
class="album hidden"
@click.stop="
typeof item.album === 'object' ? router.push(`/album?id=${item.album.id}`) : null
"
@dblclick.stop
>
{{ typeof item.album === "string" ? item.album : item.album.name }}
{{ typeof item.album === "object" ? item.album?.name || "未知专辑" : item.album }}
</n-text>
<n-text v-else class="album">未知专辑</n-text>
<n-text v-else class="album hidden">未知专辑</n-text>
</template>
<!-- 操作 -->
<div v-if="type !== 'dj'" class="action">
<!-- 喜欢歌曲 -->
<n-icon
:depth="dataStore.getSongIsLike(item?.id) ? 0 : 3"
class="favorite"
size="20"
@click.stop="
dataStore.changeLikeList(item?.id, !dataStore.getSongIsLike(item?.id), item?.path)
"
@dblclick.stop
>
<SvgIcon
:icon="
dataStore.getSongIsLike(item?.id) ? 'favorite-rounded' : 'favorite-outline-rounded'
"
/>
</n-icon>
<!-- 更多操作 -->
<n-icon
class="more mobile"
depth="3"
size="20"
@click.stop="
songListDrawerRef?.drawerOpen(data, item, songsIndex + index, sourceId, type)
"
@dblclick.stop
>
<SvgIcon icon="more" />
</n-icon>
</div>
<!-- 更新日期 -->
<n-text v-if="type === 'dj' && item.updateTime" class="update hidden" depth="3">
{{ getTimestampTime(item.updateTime, false) }}
</n-text>
<!-- 播放量 -->
<n-text v-if="type === 'dj' && item.playCount" class="count hidden" depth="3">
{{ item.playCount }}次
</n-text>
<!-- 时长 -->
<n-text v-if="item.duration" class="duration" depth="3">{{ item.duration }}</n-text>
<n-text v-if="item.duration" class="duration hidden" depth="3">{{ item.duration }}</n-text>
<n-text v-else class="duration"> -- </n-text>
<!-- 大小 -->
<n-text v-if="item.size" class="size" depth="3">{{ item.size }}M</n-text>
<n-text v-if="item.size" class="size hidden" depth="3">{{ item.size }}M</n-text>
</n-card>
<!-- 分页 -->
<Pagination
@@ -153,7 +210,21 @@
@pageNumberChange="pageNumberChange"
/>
<!-- 右键菜单 -->
<SongListDropdown ref="songListDropdownRef" @playSong="playSong" />
<SongListDropdown
ref="songListDropdownRef"
@playSong="playSong"
@delCloudSong="delCloudSong"
@deletePlaylistSong="deletePlaylistSong"
@delLocalSong="delLocalSong"
/>
<!-- 移动端菜单 -->
<SongListDrawer
ref="songListDrawerRef"
@playSong="playSong"
@delCloudSong="delCloudSong"
@deletePlaylistSong="deletePlaylistSong"
@delLocalSong="delLocalSong"
/>
<!-- 定位歌曲 -->
<Transition name="shrink" mode="out-in">
<n-card
@@ -181,30 +252,46 @@
style="margin-top: 60px"
size="large"
/>
<!-- 错误 -->
<n-empty
v-else-if="data === 'error' || data?.[0] === 'error'"
description="列表获取出错,请重试"
style="margin-top: 60px"
size="large"
/>
<!-- 加载动画 -->
<n-spin v-else class="loading" size="small">
<template #description> 加载中 </template>
</n-spin>
<div v-else class="loading">
<n-skeleton :repeat="10" text />
</div>
</Transition>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { siteData, siteSettings, musicData } from "@/stores";
import { setCloudDel } from "@/api/cloud";
import { addSongToPlayList } from "@/api/playlist";
import { siteData, siteSettings, musicData, siteStatus } from "@/stores";
import { initPlayer, fadePlayOrPause, addSongToNext } from "@/utils/Player";
import { getTimestampTime } from "@/utils/timeTools";
const router = useRouter();
const music = musicData();
const dataStore = siteData();
const status = siteStatus();
const settings = siteSettings();
const { userData } = storeToRefs(dataStore);
const { loadSize } = storeToRefs(settings);
const { playList, playIndex, playSongData, playSongSource, playHeartbeatMode, playMode } =
storeToRefs(music);
const { loadSize, playSearch, useMusicCache } = storeToRefs(settings);
const { playList, playSongData, playSongSource } = storeToRefs(music);
const { playIndex, playMode, playHeartbeatMode, playLoading } = storeToRefs(status);
// eslint-disable-next-line no-unused-vars
const props = defineProps({
// 列表类型
type: {
type: String,
default: "song",
},
// 列表数据
data: {
type: [Array, String],
@@ -245,7 +332,8 @@ const props = defineProps({
// 分页数据
const pageNumber = ref(1);
// 右键菜单
// 子组件
const songListDrawerRef = ref(null);
const songListDropdownRef = ref(null);
// 当前索引
@@ -272,8 +360,13 @@ const checkHasPlaying = (isScoll = null) => {
// 播放歌曲
const playSong = async (data, song, index) => {
console.log(data, song, index);
// 若开启了缓存且正在加载
if (useMusicCache.value && playLoading.value) {
$message.warning("歌曲正在缓冲中,请稍后");
return false;
}
// 更改模式
playMode.value = "normal";
playMode.value = props.type === "song" ? "normal" : "dj";
// 检查当前页面
const isPage = router.currentRoute.value.matched?.[0].path || null;
// 是否关闭心动模式
@@ -284,7 +377,12 @@ const playSong = async (data, song, index) => {
fadePlayOrPause();
} else {
// 若为特殊状态
if (isPage === "/search" || isPage === "/history" || playHeartbeatMode.value) {
if (
(isPage === "/search" && !playSearch.value) ||
isPage === "/history" ||
playHeartbeatMode.value
) {
console.log("仅播放当前歌曲");
addSongToNext(song, true);
} else {
// 添加播放列表
@@ -295,7 +393,7 @@ const playSong = async (data, song, index) => {
console.log("与当前播放歌曲不一致");
playSongData.value = song;
// 初始化播放器
initPlayer(true);
await initPlayer(true);
}
// 附加来源
playSongSource.value = Number(props.sourceId);
@@ -313,6 +411,71 @@ const pageNumberChange = (page) => {
});
};
// 检查是否可执行双击
const checkCanClick = (data, item, index) => {
if (window.innerWidth > 700) return false;
playSong(data, item, index);
};
// 云盘歌曲删除
const delCloudSong = (data, song, index) => {
console.log(data, song, index);
$dialog.warning({
title: "确认删除",
content: `确认从云盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await setCloudDel(song.id);
if (result.code == 200) {
data.splice(index, 1);
$message.success("删除成功");
} else {
$message.error("删除失败,请重试");
}
},
});
};
// 歌单歌曲删除
const deletePlaylistSong = (pid, song, data, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从歌单中移除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await addSongToPlayList(pid, song?.id, "del");
if (result.status === 200) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
// 本地歌曲删除
const delLocalSong = (data, song, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从本地磁盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
console.log(data, song, index);
const result = await electron.ipcRenderer.invoke("deleteFile", song?.path);
if (result) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
// 监听歌曲变化
watch(
() => music.getPlaySongData?.id,
@@ -352,8 +515,20 @@ onBeforeUnmount(() => {
.has-cover {
margin-right: 66px;
}
.duration {
.control {
width: 40px;
}
.update {
width: 80px;
text-align: center;
margin-right: auto;
}
.count {
width: 120px;
text-align: center;
}
.duration {
width: 50px;
text-align: center;
}
.size {
@@ -394,8 +569,7 @@ onBeforeUnmount(() => {
}
}
}
.num,
.play {
.num {
width: 30px;
height: 30px;
min-width: 30px;
@@ -483,8 +657,36 @@ onBeforeUnmount(() => {
color: var(--main-color);
}
}
.duration {
.action {
width: 40px;
display: flex;
align-items: center;
justify-content: space-evenly;
.favorite {
padding-top: 1px;
transition: transform 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.15);
}
&:active {
transform: scale(1);
}
}
.more {
display: none;
}
}
.update {
width: 80px;
text-align: center;
}
.count {
width: 120px;
text-align: center;
}
.duration {
width: 50px;
text-align: center;
}
.size {
@@ -496,7 +698,7 @@ onBeforeUnmount(() => {
border-color: var(--main-color);
a,
span,
.play {
.num {
color: var(--main-color) !important;
}
.artist {
@@ -546,12 +748,49 @@ onBeforeUnmount(() => {
transform: scale(0.9);
}
}
@media (max-width: 700px) {
.song-list-header,
.songs {
.hidden {
display: none;
}
}
.songs {
.num {
font-size: 12px;
width: 28px;
height: 28px;
min-width: 28px;
}
.info {
.title {
.name {
font-size: 15px;
}
}
.artist {
font-size: 12px;
}
}
.action {
width: 60px;
justify-content: flex-end;
.more {
display: inline-block;
margin-left: 12px;
}
}
}
}
}
.loading {
margin: 60px 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
:deep(.n-skeleton) {
&:nth-of-type(1) {
margin-top: 0;
}
height: 80px;
margin-top: 12px;
border-radius: 8px;
}
}
</style>

View File

@@ -0,0 +1,323 @@
<!-- 歌曲列表 - 移动端菜单 -->
<template>
<n-drawer
v-model:show="drawerShow"
:auto-focus="false"
height="calc(100vh - 200px)"
placement="bottom"
class="song-list-drawer"
@after-leave="drawerShow = false"
@mask-click="drawerShow = false"
>
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: 0 }" closable>
<template #header>
<div v-if="!songData?.path" class="song-data">
<n-image
:src="songData?.coverSize?.s || songData?.cover"
class="cover"
preview-disabled
/>
<div class="song-detail">
<n-text class="name">{{ songData?.name || "未知曲目" }}</n-text>
<template v-if="songType === 'song'">
<div v-if="songData?.artists && Array.isArray(songData.artists)" class="all-ar">
<n-text v-for="ar in songData.artists" :key="ar.id" class="ar" depth="3">
{{ ar.name }}
</n-text>
</div>
<div v-else class="all-ar">
<n-text class="ar" depth="3">
{{ songData.artists || "未知艺术家" }}
</n-text>
</div>
</template>
<n-text v-else class="ar">
{{ songData?.artists || "未知艺术家" }}
</n-text>
</div>
</div>
<n-text v-else>更多操作</n-text>
</template>
<div class="all-menu">
<div
class="menu-item"
@click="
() => {
drawerShow = false;
emit('playSong', playlistData, songData, songIndex);
}
"
>
<n-icon size="22">
<SvgIcon icon="play" />
</n-icon>
<n-text class="name"> 立即播放 </n-text>
</div>
<div
v-if="isSong && playMode !== 'dj' && music.getPlaySongData?.id !== songData.id && !isFm"
class="menu-item"
@click="
() => {
drawerShow = false;
playMode = 'normal';
addSongToNext(songData);
}
"
>
<n-icon size="22">
<SvgIcon icon="play-next" />
</n-icon>
<n-text class="name"> 下一首播放 </n-text>
</div>
<div
v-if="isSong && !isLocalSong"
class="menu-item"
@click="
() => {
drawerShow = false;
addPlaylistRef?.openAddToPlaylist(songData?.id);
}
"
>
<n-icon size="22">
<SvgIcon icon="playlist-add" />
</n-icon>
<n-text class="name"> 添加到歌单 </n-text>
</div>
<div
v-if="isSong && !isLocalSong"
class="menu-item"
@click="
() => {
drawerShow = false;
router.push({
path: '/comment',
query: {
id: songData.id,
},
});
}
"
>
<n-icon size="20">
<SvgIcon icon="comment-text" />
</n-icon>
<n-text class="name"> 查看评论 </n-text>
</div>
<div
v-if="isSong && isHasMv"
class="menu-item"
@click="
() => {
drawerShow = false;
router.push({
path: '/videos-player',
query: {
id: songData.mv,
},
});
}
"
>
<n-icon size="22">
<SvgIcon icon="video" />
</n-icon>
<n-text class="name"> 观看 MV </n-text>
</div>
<div
v-if="!isCloud && isUserPlaylist"
class="menu-item"
@click="
() => {
drawerShow = false;
emit('deletePlaylistSong', playlistData, songData, songIndex);
}
"
>
<n-icon size="22">
<SvgIcon icon="delete" />
</n-icon>
<n-text class="name"> 从歌单中删除 </n-text>
</div>
<div
v-if="isCloud"
class="menu-item"
@click="
() => {
drawerShow = false;
emit('delCloudSong', playlistData, songData, songIndex);
}
"
>
<n-icon size="22">
<SvgIcon icon="delete" />
</n-icon>
<n-text class="name"> 从云盘中删除 </n-text>
</div>
<div
v-if="isCloud"
class="menu-item"
@click="
() => {
drawerShow = false;
cloudSongMatchRef?.openCloudSongMatch(songData, songIndex);
}
"
>
<n-icon size="22">
<SvgIcon icon="edit" />
</n-icon>
<n-text class="name"> 云盘歌曲纠正 </n-text>
</div>
<div
v-if="isSong && !isLocalSong"
class="menu-item"
@click="
() => {
drawerShow = false;
downloadSongRef?.openDownloadModal(songData);
}
"
>
<n-icon size="22">
<SvgIcon icon="download" />
</n-icon>
<n-text class="name"> 下载歌曲 </n-text>
</div>
</div>
</n-drawer-content>
</n-drawer>
<!-- 添加到歌单 -->
<AddPlaylist ref="addPlaylistRef" />
<!-- 下载歌曲 -->
<DownloadSong ref="downloadSongRef" />
<!-- 云盘歌曲纠正 -->
<CloudSongMatch ref="cloudSongMatchRef" />
</template>
<script setup>
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { addSongToNext } from "@/utils/Player";
import { musicData, siteData, siteStatus } from "@/stores";
const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const { playMode } = storeToRefs(status);
const { userData, userLikeData } = storeToRefs(data);
const emit = defineEmits(["playSong", "delCloudSong", "deletePlaylistSong", "delLocalSong"]);
// 子组件
const addPlaylistRef = ref(null);
const downloadSongRef = ref(null);
const cloudSongMatchRef = ref(null);
// 菜单数据
const drawerShow = ref(false);
const songType = ref("song");
const songData = ref(null);
const songIndex = ref(null);
const songSourceId = ref(null);
const playlistData = ref(null);
// 歌曲状态
const isFm = computed(() => playMode.value === "fm");
const isSong = computed(() => songType.value === "song");
const isLocalSong = computed(() => !!songData.value?.path);
const isHasMv = computed(() => !!songData.value?.mv && songData.value.mv !== 0);
const isCloud = computed(() => router.currentRoute.value.name === "cloud");
const isUserPlaylist = computed(() => {
// 用户 id
const userId = userData.value?.userId;
// 用户歌单
const userPlaylistsData = userLikeData.value.playlists?.filter(
(playlist) => playlist.userId === userId,
);
return songSourceId.value !== 0 && userPlaylistsData.some((pl) => pl.id == songSourceId.value);
});
// 开启菜单
const drawerOpen = (data, song, index, sourceId, type) => {
console.log(song, type);
drawerShow.value = true;
songData.value = song;
songType.value = type;
songIndex.value = index;
songSourceId.value = sourceId;
playlistData.value = data;
};
defineExpose({
drawerOpen,
});
</script>
<style lang="scss" scoped>
.song-data {
display: flex;
flex-direction: row;
align-items: center;
.cover {
margin-right: 12px;
width: 50px;
height: 50px;
border-radius: 8px;
}
.song-detail {
.name {
font-size: 16px;
margin-bottom: 8px;
}
.all-ar {
margin-top: 4px;
font-size: 13px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
word-break: break-all;
.ar {
display: inline-flex;
&::after {
content: "/";
margin: 0 4px;
}
&:last-child {
&::after {
display: none;
}
}
}
}
}
}
.all-menu {
.menu-item {
padding: 12px 24px;
display: flex;
align-items: center;
flex-direction: row;
transition: background-color 0.3s;
cursor: pointer;
.n-icon {
margin-right: 8px;
}
.name {
transform: translateY(1px);
display: flex;
flex-direction: row;
}
&:hover {
background-color: var(--n-close-color-hover);
}
}
}
</style>
<style lang="scss">
.song-list-drawer {
border-radius: 8px 8px 0 0;
}
</style>

View File

@@ -17,23 +17,25 @@
<AddPlaylist ref="addPlaylistRef" />
<!-- 下载歌曲 -->
<DownloadSong ref="downloadSongRef" />
<!-- 云盘歌曲纠正 -->
<CloudSongMatch ref="cloudSongMatchRef" />
</template>
<script setup>
import { NIcon, NImage, NText } from "naive-ui";
import { storeToRefs } from "pinia";
import { musicData, siteData } from "@/stores";
import { musicData, siteData, siteStatus } from "@/stores";
import { useRouter } from "vue-router";
import { addSongToNext } from "@/utils/Player";
import { setCloudDel } from "@/api/cloud";
import { addSongToPlayList } from "@/api/playlist";
import { copyData } from "@/utils/helper";
import SvgIcon from "@/components/Global/SvgIcon";
const emit = defineEmits(["playSong"]);
const router = useRouter();
const music = musicData();
const emit = defineEmits(["playSong", "delCloudSong", "deletePlaylistSong", "delLocalSong"]);
const data = siteData();
const { playSongData, playMode } = storeToRefs(music);
const music = musicData();
const router = useRouter();
const status = siteStatus();
const { playMode } = storeToRefs(status);
const { userData, userLikeData } = storeToRefs(data);
// 右键菜单数据
@@ -45,6 +47,7 @@ const dropdownOptions = ref(null);
// 子组件
const addPlaylistRef = ref(null);
const downloadSongRef = ref(null);
const cloudSongMatchRef = ref(null);
// 图标渲染
const renderIcon = (icon, size, translate = 0) => {
@@ -60,7 +63,7 @@ const renderIcon = (icon, size, translate = 0) => {
};
// 歌曲信息
const renderSong = (song) => {
const renderSong = (song, isSong) => {
return () =>
h(
"div",
@@ -68,29 +71,35 @@ const renderSong = (song) => {
className: "song-data",
},
[
h(NImage, { src: song?.coverSize?.s || song?.cover, class: "cover" }),
h(NImage, {
src: song?.coverSize?.s || song?.cover,
class: "cover",
previewDisabled: true,
}),
h("div", { class: "song-detail" }, [
h(NText, { class: "name" }, () => [song?.name || "未知曲目"]),
song.artists && Array.isArray(song.artists)
? h(
"div",
{ class: "all-ar" },
song.artists.map((ar) =>
h(NText, { key: ar.id, class: "ar", depth: 3 }, () => [ar.name]),
),
)
: h(
"div",
{ class: "all-ar" },
h(NText, { class: "ar", depth: 3 }, () => [song.artists || "未知艺术家"]),
),
isSong
? song.artists && Array.isArray(song.artists)
? h(
"div",
{ class: "all-ar" },
song.artists.map((ar) =>
h(NText, { key: ar.id, class: "ar", depth: 3 }, () => [ar.name]),
),
)
: h(
"div",
{ class: "all-ar" },
h(NText, { class: "ar", depth: 3 }, () => [song.artists || "未知艺术家"]),
)
: h(NText, { class: "ar", depth: 3 }, () => ["电台节目"]),
]),
],
);
};
// 打开右键菜单
const openDropdown = (e, data, song, index, sourceId) => {
const openDropdown = (e, data, song, index, sourceId, type) => {
try {
e.preventDefault();
dropdownShow.value = false;
@@ -101,7 +110,10 @@ const openDropdown = (e, data, song, index, sourceId) => {
(playlist) => playlist.userId === userId,
);
// 当前状态
const isLocalSong = song?.path ? true : false;
const isFm = playMode.value === "fm";
const isSong = type === "song";
const isLocalSong = !!song?.path;
const isHasMv = !!song?.mv && song.mv !== 0;
const isCloud = router.currentRoute.value.name === "cloud";
const isUserPlaylist = sourceId !== 0 && userPlaylistsData.some((pl) => pl.id == sourceId);
// 生成菜单
@@ -112,7 +124,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
key: "song-data",
type: "render",
show: !isLocalSong,
render: renderSong(song),
render: renderSong(song, isSong),
},
{
key: "line-song",
@@ -132,9 +144,10 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "next-play",
label: "下一首播放",
show: playSongData.value?.id === song.id || playMode.value === "fm" ? false : true,
show: isSong && playMode.value !== "dj" && music.getPlaySongData?.id !== song.id && !isFm,
props: {
onClick: () => {
playMode.value = "normal";
addSongToNext(song);
},
},
@@ -143,7 +156,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "add-pl",
label: "添加到歌单",
show: song?.path ? false : true,
show: isSong && !isLocalSong,
props: {
onClick: () => {
addPlaylistRef.value?.openAddToPlaylist(song?.id);
@@ -154,7 +167,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "comment",
label: "查看评论",
show: song?.path ? false : true,
show: isSong && !isLocalSong,
props: {
onClick: () => {
router.push({
@@ -170,7 +183,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "mv",
label: "观看 MV",
show: song.mv && song.mv !== 0 ? true : false,
show: isSong && isHasMv,
props: {
onClick: () => {
router.push({
@@ -183,6 +196,39 @@ const openDropdown = (e, data, song, index, sourceId) => {
},
icon: renderIcon("video"),
},
{
label: "更多操作",
key: "others",
show: !isLocalSong,
icon: renderIcon("more"),
children: [
{
key: "copy",
label: `复制${isSong ? "歌曲" : "节目"} ID`,
props: {
onClick: () => {
const songId = song?.id?.toString();
copyData(songId);
},
},
icon: renderIcon("content-copy"),
},
{
key: "share",
label: `分享${isSong ? "歌曲" : "节目"}链接`,
props: {
onClick: () => {
const shareUrl = isSong
? `https://music.163.com/song?id=${song?.id?.toString()}`
: `https://music.163.com/#/dj?id=${song?.id?.toString()}`;
copyData(shareUrl, `复制${isSong ? "歌曲" : "节目"}链接`);
},
},
icon: renderIcon("share"),
},
],
},
{
key: "line-cloud",
type: "divider",
@@ -194,18 +240,29 @@ const openDropdown = (e, data, song, index, sourceId) => {
show: !isCloud && isUserPlaylist,
props: {
onClick: () => {
deletePlaylistSong(sourceId, song, data, index);
emit("deletePlaylistSong", data, song, index);
},
},
icon: renderIcon("delete"),
},
{
key: "edit",
label: "云盘歌曲纠正",
show: isCloud,
props: {
onClick: () => {
cloudSongMatchRef.value?.openCloudSongMatch(song, index);
},
},
icon: renderIcon("edit"),
},
{
key: "delete",
label: "从云盘中删除",
show: isCloud,
props: {
onClick: () => {
delCloudSong(data, song, index);
emit("delCloudSong", data, song, index);
},
},
icon: renderIcon("delete"),
@@ -224,10 +281,10 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "delete",
label: "从本地磁盘中删除",
show: isLocalSong,
show: isLocalSong && music.getPlaySongData?.id !== song.id,
props: {
onClick: () => {
delLocalSong(data, song, index);
emit("delLocalSong", data, song, index);
},
},
icon: renderIcon("delete"),
@@ -254,7 +311,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "download",
label: "下载歌曲",
show: !isLocalSong,
show: isSong && !isLocalSong,
props: {
onClick: () => {
downloadSongRef.value?.openDownloadModal(song);
@@ -274,65 +331,6 @@ const openDropdown = (e, data, song, index, sourceId) => {
}
};
// 云盘歌曲删除
const delCloudSong = (data, song, index) => {
console.log(data, song, index);
$dialog.warning({
title: "确认删除",
content: `确认从云盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await setCloudDel(song.id);
if (result.code == 200) {
data.splice(index, 1);
$message.success("删除成功");
} else {
$message.error("删除失败,请重试");
}
},
});
};
// 歌单歌曲删除
const deletePlaylistSong = (pid, song, data, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从歌单中移除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await addSongToPlayList(pid, song?.id, "del");
if (result.status === 200) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
// 本地歌曲删除
const delLocalSong = (data, song, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从本地磁盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
console.log(data, song, index);
const result = await electron.ipcRenderer.invoke("deleteFile", song?.path);
if (result) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
defineExpose({
openDropdown,
});

View File

@@ -32,7 +32,7 @@
>
<template #prefix>
<n-image
:src="item.coverImgUrl.replace(/^http:/, 'https:') + '?param=100y100'"
:src="item?.coverSize?.s || '/images/pic/album.jpg?assest'"
class="cover"
preview-disabled
lazy
@@ -51,7 +51,7 @@
</template>
<n-thing :title="item.name">
<template #description>
<n-text depth="3" class="size">{{ item.trackCount }} 首音乐</n-text>
<n-text depth="3" class="size">{{ item.count }} 首音乐</n-text>
</template>
</n-thing>
</n-list-item>
@@ -87,7 +87,7 @@ const addToPlayList = async (pid, tracks, index) => {
$message.success("添加歌曲至歌单成功");
if (index === 0) await data.setUserLikeSongs();
} else {
$message.error("添加失败,请重试");
$message.error(result?.message || "添加失败,请重试");
}
};

View File

@@ -0,0 +1,248 @@
<!-- 云盘歌曲纠正 -->
<template>
<n-modal
v-model:show="cloudSongMatchShow"
:bordered="false"
:on-after-leave="closeCloudSongMatch"
title="歌曲纠正"
preset="card"
>
<n-form class="cloud-match" :label-width="80" :model="cloudMatchValue">
<n-form-item label="原歌曲 ID" path="asid">
<n-input-number v-model:value="cloudMatchValue.sid" :show-button="false" disabled />
</n-form-item>
<n-form-item label="匹配的 ID" path="asid">
<n-input-number
v-model:value="cloudMatchValue.asid"
:show-button="false"
placeholder="请输入要匹配的歌曲 ID"
/>
<n-button
:disabled="!cloudMatchValue.asid"
style="margin-left: 12px"
@click="checkMatchSong(cloudMatchValue.asid)"
>
检查
</n-button>
</n-form-item>
</n-form>
<!-- 纠正歌曲数据 -->
<Transition name="fade" mode="out-in">
<n-card
v-if="cloudMatchSongData"
:key="cloudMatchSongData"
:content-style="{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '16px',
height: '100%',
}"
class="song-detail"
>
<n-image
:src="cloudMatchSongData?.coverSize?.s || cloudMatchSongData?.cover"
class="cover-img"
preview-disabled
@load="
(e) => {
e.target.style.opacity = 1;
}
"
>
<template #placeholder>
<div class="cover-loading">
<img class="loading-img" src="/images/pic/song.jpg?assest" alt="loading-img" />
</div>
</template>
</n-image>
<div class="content">
<div class="name">{{ cloudMatchSongData?.name || "未知曲目" }}</div>
<div class="artist">
<n-icon depth="3" size="20">
<SvgIcon icon="account-music" />
</n-icon>
<div
v-if="cloudMatchSongData?.artists && Array.isArray(cloudMatchSongData?.artists)"
class="all-ar"
>
<span v-for="ar in cloudMatchSongData.artists" :key="ar.id" class="ar">
{{ ar.name }}
</span>
</div>
<div v-else class="all-ar">
<span class="ar"> {{ cloudMatchSongData?.artists || "未知艺术家" }} </span>
</div>
</div>
</div>
</n-card>
</Transition>
<template #footer>
<n-flex justify="end">
<n-button @click="closeCloudSongMatch"> 取消 </n-button>
<n-button
:disabled="!cloudMatchValue.asid"
type="primary"
@click="setCloudSongMatchBtn(cloudMatchValue)"
>
纠正
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData, indexedDBData } from "@/stores";
import { setCloudMatch } from "@/api/cloud";
import { getSongDetail } from "@/api/song";
import formatData from "@/utils/formatData";
const data = siteData();
const indexedDB = indexedDBData();
const { userData } = storeToRefs(data);
// 歌曲信息纠正数据
const cloudMatchId = ref(null);
const cloudMatchIndex = ref(null);
const cloudSongMatchShow = ref(false);
const cloudMatchSongData = ref(null);
const cloudMatchValue = ref({
uid: userData.value?.userId,
sid: null,
asid: null,
});
// 检查纠正歌曲id
const checkMatchSong = async (id) => {
try {
if (!id) return false;
const songId = id.toString();
const result = await getSongDetail(songId);
if (result.code === 200 && result.songs.length > 0) {
$message.success("匹配的歌曲检查通过");
cloudMatchSongData.value = formatData(result.songs[0], "song")[0];
} else {
$message.warning("请检查要匹配的歌曲 ID 是否正确");
}
} catch (error) {
console.error("检查纠正歌曲失败:", error);
$message.error("检查纠正歌曲失败,请重试");
}
};
// 歌曲纠正
const setCloudSongMatchBtn = async (data) => {
if (Number(data.sid) === Number(data.asid)) {
return $message.warning("与原歌曲 ID 一致,无需修改");
}
if (!cloudMatchSongData.value) {
return $message.warning("未检测到正确的匹配检查结果");
}
const result = await setCloudMatch(data.uid, data.sid, data.asid);
console.log(result);
if (result.code === 200) {
// 更改歌曲信息
try {
cloudMatchSongData.value.pc = undefined;
const allCloudSongs = await indexedDB.getfilesDB("userCloudList");
allCloudSongs[cloudMatchIndex.value] = JSON.parse(JSON.stringify(cloudMatchSongData.value));
await indexedDB.setfilesDB("userCloudList", allCloudSongs.slice());
// 刷新列表
if (typeof $refreshCloudCatch !== "undefined") $refreshCloudCatch();
} catch (error) {
console.error("更改云盘列表时出错:", error);
$message.error("更改云盘列表时出错,请刷新后重试");
}
closeCloudSongMatch();
$message.success("歌曲信息纠正成功");
} else {
$message.error("歌曲信息纠正失败,请重试");
}
};
// 开启歌曲纠正
const openCloudSongMatch = (data, index) => {
cloudMatchIndex.value = index;
cloudMatchValue.value.sid = data.id;
cloudSongMatchShow.value = true;
};
// 关闭歌曲纠正
const closeCloudSongMatch = () => {
cloudSongMatchShow.value = false;
cloudMatchId.value = null;
cloudMatchValue.value.asid = null;
cloudMatchSongData.value = null;
};
// 暴露方法
defineExpose({
openCloudSongMatch,
});
</script>
<style lang="scss" scoped>
.cloud-match {
:deep(.n-input-number) {
width: 100%;
}
}
</style>
<style lang="scss" scoped>
.song-detail {
height: 100px;
border-radius: 8px;
.cover-img {
width: 66px;
height: 66px;
margin-right: 16px;
border-radius: 8px;
overflow: hidden;
z-index: 1;
box-shadow: 0 0 10px 6px #00000008;
transition: opacity 0.1s ease-in-out;
:deep(img) {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
}
.content {
.name {
font-size: 18px;
font-weight: bold;
margin-bottom: 4px;
}
.artist {
display: flex;
align-items: center;
.n-icon {
margin-right: 4px;
}
.all-ar {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
word-break: break-all;
.ar {
display: inline-flex;
&::after {
content: "/";
margin: 0 4px;
}
&:last-child {
&::after {
display: none;
}
}
}
}
}
}
}
</style>

View File

@@ -21,7 +21,7 @@
<!-- 隐私歌单 -->
<n-checkbox v-model:checked="createPrivacy"> 设为隐私歌单 </n-checkbox>
<template #footer>
<n-space justify="end">
<n-flex justify="end">
<n-button @click="closeCreatePlaylist"> 取消 </n-button>
<n-button
:disabled="!createName"
@@ -30,7 +30,7 @@
>
新建
</n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -17,7 +17,7 @@
当前为云盘歌曲下载的文件均为最高音质
</n-alert>
<n-radio-group v-model:value="downloadChoose" class="download-group" name="downloadGroup">
<n-space vertical>
<n-flex vertical>
<n-radio
v-for="item in downloadLevel"
:key="item"
@@ -32,23 +32,24 @@
</n-text>
</div>
</n-radio>
</n-space>
</n-flex>
</n-radio-group>
</div>
<n-text v-else>歌曲信息获取中</n-text>
</Transition>
<template #footer>
<n-space justify="end">
<n-flex justify="end">
<n-button @click="closeDownloadModal"> 关闭 </n-button>
<n-button
:disabled="!downloadChoose"
:loading="downloadStatus"
:focusable="false"
type="primary"
@click="toSongDownload(songData, downloadChoose)"
@click="toSongDownload(songData, lyricData, downloadChoose)"
>
下载
</n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</template>
@@ -58,19 +59,20 @@ import { storeToRefs } from "pinia";
import { isLogin } from "@/utils/auth";
import { useRouter } from "vue-router";
import { siteData, siteSettings } from "@/stores";
import { getSongDetail, getSongDownload } from "@/api/song";
import { downloadFile } from "@/utils/helper";
import { getSongDetail, getSongDownload, getSongLyric } from "@/api/song";
import { downloadFile, checkPlatform } from "@/utils/helper";
import formatData from "@/utils/formatData";
const router = useRouter();
const data = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(data);
const { downloadPath } = storeToRefs(settings);
const { downloadPath, downloadMeta, downloadCover, downloadLyrics } = storeToRefs(settings);
// 歌曲下载数据
const songId = ref(null);
const songData = ref(null);
const lyricData = ref(null);
const downloadStatus = ref(false);
const downloadSongShow = ref(false);
const downloadChoose = ref(null);
@@ -79,11 +81,13 @@ const downloadLevel = ref(null);
// 获取歌曲详情
const getMusicDetailData = async (id) => {
try {
const result = await getSongDetail(id);
const songResult = await getSongDetail(id);
const lyricResult = await getSongLyric(id);
// 获取歌曲详情
songData.value = formatData(result?.songs?.[0], "song")[0];
songData.value = formatData(songResult?.songs?.[0], "song")[0];
lyricData.value = lyricResult?.lrc?.lyric || null;
// 生成音质列表
generateLists(result);
generateLists(songResult);
} catch (error) {
closeDownloadModal();
console.error("歌曲信息获取失败:", error);
@@ -91,26 +95,42 @@ const getMusicDetailData = async (id) => {
};
// 歌曲下载
const toSongDownload = async (song, br) => {
console.log(song, br);
downloadStatus.value = true;
// 获取下载数据
const result = await getSongDownload(song?.id, br);
// 开始下载
if (!downloadPath.value) {
$notification["warning"]({
content: "缺少配置",
meta: "请前往设置页配置默认下载目录",
duration: 3000,
const toSongDownload = async (song, lyric, br) => {
try {
console.log(song, lyric, br);
downloadStatus.value = true;
// 获取下载数据
const result = await getSongDownload(song?.id, br);
// 开始下载
if (!downloadPath.value && checkPlatform.electron()) {
$notification["warning"]({
content: "缺少配置",
meta: "请前往设置页配置默认下载目录",
duration: 3000,
});
}
if (!result.data?.url) {
downloadStatus.value = false;
return $message.error("下载失败,请重试");
}
// 获取下载结果
const isDownloaded = await downloadFile(result.data, song, lyric, {
path: downloadPath.value,
downloadMeta: downloadMeta.value,
downloadCover: downloadCover.value,
downloadLyrics: downloadLyrics.value,
});
}
const isDownloaded = await downloadFile(result.data, song, downloadPath.value);
if (isDownloaded) {
$message.success("下载完成");
closeDownloadModal();
} else {
downloadStatus.value = false;
$message.error("下载失败,请重试");
console.log(lyric);
if (isDownloaded) {
$message.success("下载完成");
closeDownloadModal();
} else {
downloadStatus.value = false;
$message.error("下载失败,请重试");
}
} catch (error) {
console.error("歌曲下载出错:", error);
$message.error("歌曲下载失败,请重试");
}
};
@@ -189,12 +209,12 @@ const openDownloadModal = (data) => {
router.currentRoute.value.name === "cloud" ||
data?.fee === 0 ||
data?.pc ||
userData.detail?.profile?.vipType !== 0
userData.value.detail?.profile?.vipType !== 0
) {
return toDownload();
}
// 权限不足
if (data?.fee !== 0 && userData.detail?.profile?.vipType !== 11 && !data?.pc) {
if (data?.fee !== 0 && userData.value.detail?.profile?.vipType !== 11 && !data?.pc) {
return $message.warning("账号会员等级不足,请提升权限");
}
$message.warning("账号会员等级不足,请提升权限");

View File

@@ -2,29 +2,31 @@
<template>
<n-modal
v-model:show="loginModalShow"
style="width: 400px"
class="login"
:auto-focus="false"
:mask-closable="false"
:bordered="false"
:close-on-esc="false"
:closable="false"
style="width: 400px"
preset="card"
transform-origin="center"
>
<div class="login-content">
<div class="title">
<img class="logo" src="/images/logo/favicon.png?asset" alt="logo" />
<img class="logo" src="/images/icons/favicon.png?asset" alt="logo" />
</div>
<!-- 登录方式 -->
<n-tabs class="login-tabs" default-value="login-qr" type="segment" animated>
<n-tab-pane name="login-qr" tab="扫码登录">
<loginQRCode @setLoginData="setLoginData" />
</n-tab-pane>
<n-tab-pane name="login-phone" tab="验证码登录"> 444 </n-tab-pane>
<n-tab-pane name="login-phone" tab="验证码登录">
<loginPhone @setLoginData="setLoginData" />
</n-tab-pane>
</n-tabs>
<!-- 关闭登录弹窗 -->
<n-button
:focusable="false"
class="close"
strong
secondary
@@ -43,12 +45,15 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { siteData, siteSettings } from "@/stores";
import { getLoginState, refreshLogin } from "@/api/login";
import { setCookies, toLogout, isLogin } from "@/utils/auth";
import userSignIn from "@/utils/userSignIn";
const data = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(data);
const { autoSignIn } = storeToRefs(settings);
// 登录数据
const loginModalShow = ref(false);
@@ -72,6 +77,7 @@ const openLoginModal = () => {
// 储存登录信息
const setLoginData = async (loginData) => {
console.log(loginData);
if (!loginData) return false;
if (loginData.code === 200) {
// 关闭登录弹窗
@@ -80,6 +86,9 @@ const setLoginData = async (loginData) => {
setCookies(loginData.cookie);
// 获取用户信息
await data.setUserProfile();
await data.setDailySongsData();
// 签到
if (autoSignIn.value) await userSignIn();
// 更改状态
data.userLoginStatus = true;
$message.success("登录成功");
@@ -168,7 +177,7 @@ onBeforeMount(() => {
.close {
position: absolute;
bottom: -58px;
background-color: var(--n-color-embedded);
background-color: var(--n-color-modal);
&:hover {
background-color: var(--n-color-embedded);
}

View File

@@ -0,0 +1,146 @@
<!-- 登录 - 手机号 -->
<template>
<div class="login-phone">
<n-form
ref="phoneFormRef"
:model="phoneFormData"
:rules="phoneFormRules"
:show-label="false"
class="phone-form"
>
<n-form-item path="phone">
<n-input v-model:value="phoneFormData.phone" placeholder="请输入手机号">
<template #prefix>
<n-icon>
<SvgIcon icon="phone" />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="captcha">
<n-input-number
v-model:value="phoneFormData.captcha"
:show-button="false"
style="width: 100%"
placeholder="请输入短信验证码"
>
<template #prefix>
<n-icon>
<SvgIcon icon="password" />
</n-icon>
</template>
</n-input-number>
<n-button
:disabled="captchaDisabled"
type="primary"
style="margin-left: 12px"
@click="getCaptcha(phoneFormData.phone)"
>
{{ captchaText }}
</n-button>
</n-form-item>
<n-form-item>
<n-button style="width: 100%" type="primary" @click="phoneLogin"> 登录 </n-button>
</n-form-item>
</n-form>
</div>
</template>
<script setup>
import { sentCaptcha, verifyCaptcha, toLogin } from "@/api/login";
import { formRules } from "@/utils/formRules";
const emit = defineEmits(["setLoginData"]);
const { numberRule, mobileRule } = formRules();
// 手机号数据
const phoneFormRef = ref(null);
const phoneFormData = ref({
phone: null,
captcha: null,
});
const phoneFormRules = {
phone: mobileRule,
captcha: numberRule,
};
const captchaTimeOut = ref(null);
const captchaText = ref("获取验证码");
const captchaDisabled = ref(false);
// 获取验证码
const getCaptcha = (phone) => {
clearInterval(captchaTimeOut.value);
phoneFormRef.value?.validate(
async (errors) => {
if (!errors) {
console.log(phone + "发送验证码");
const result = await sentCaptcha(phone);
console.log(result);
if (result.code == 200) {
$message.success("验证码发送成功");
let countDown = 60;
captchaDisabled.value = true;
captchaTimeOut.value = setInterval(() => {
countDown--;
captchaText.value = countDown + "s";
if (countDown === 0) {
clearInterval(captchaTimeOut.value);
captchaText.value = "重新获取";
captchaDisabled.value = false;
}
}, 1000);
} else {
$message.error("验证码发送失败,请重试");
}
} else {
$message.error("请检查你的输入");
}
},
(rule) => {
return rule?.key === "phone";
},
);
};
// 手机号登录
const phoneLogin = (e) => {
e.preventDefault();
phoneFormRef.value?.validate(async (errors) => {
if (!errors) {
const verifyRes = await verifyCaptcha(
phoneFormData._value.phone,
phoneFormData._value.captcha,
);
console.log(verifyRes);
if (verifyRes.code == 200) {
const result = await toLogin(phoneFormData._value.phone, phoneFormData._value.captcha);
console.log(result);
if (result.code === 200) {
// 去除 HTTPOnly
result.cookie = result.cookie.replaceAll(" HTTPOnly", "");
// 是否含有 MUSIC_U
if (result.cookie && result.cookie.includes("MUSIC_U")) {
// 储存登录信息
emit("setLoginData", result);
} else {
$message.error("登录出错,请重试");
}
} else {
phoneFormData.value.captcha = null;
$message.error("登录出错,请重试");
}
}
} else {
$message.error("请检查你的输入");
}
});
};
</script>
<style lang="scss" scoped>
.login-phone {
.phone-form {
margin-top: 20px;
}
}
</style>

View File

@@ -2,17 +2,18 @@
<template>
<div class="login-qr">
<div class="qr-img">
<n-skeleton v-if="!qrImg" class="qr" />
<QrcodeVue
v-else
:class="['qr', qrStatusCode === 802 ? 'hidden' : null]"
:value="qrImg"
:size="180"
:margin="4"
level="L"
foreground="#000"
background="#fff"
/>
<Transition name="fade" mode="out-in">
<n-qr-code
v-if="qrImg"
:value="qrImg"
:class="['qr', qrStatusCode === 802 ? 'hidden' : null]"
:size="156"
:icon-size="30"
icon-src="/images/icons/favicon.png?asset"
error-correction-level="H"
/>
<n-skeleton v-else class="qr" />
</Transition>
<Transition name="fade" mode="out-in">
<div v-if="qrStatusCode === 802" class="refresh" @click="getQrData">
<n-icon size="22">
@@ -28,7 +29,6 @@
<script setup>
import { getQrKey, checkQr } from "@/api/login";
import QrcodeVue from "qrcode.vue";
const emit = defineEmits(["setLoginData"]);
@@ -90,7 +90,7 @@ const checkQrStatus = (key) => {
// 是否含有 MUSIC_U
if (res.cookie && res.cookie.includes("MUSIC_U")) {
// 储存登录信息
emit("setLoginData", res, "qrcode");
emit("setLoginData", res);
} else {
$message.error("登录出错,请重试");
getQrData();
@@ -133,6 +133,7 @@ onBeforeUnmount(() => {
min-width: 180px;
height: 180px;
width: 180px;
box-sizing: border-box;
transition: opacity 0.3s;
&.hidden {
opacity: 0.2;

View File

@@ -40,10 +40,10 @@
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-flex justify="end">
<n-button @click="closeUpdateModal"> 取消 </n-button>
<n-button type="primary" @click="toUpdatePlayList"> 编辑 </n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -1,47 +1,143 @@
<!-- 主导航栏 -->
<template>
<nav class="main-nav">
<div
:class="['logo', status.asideMenuCollapsed ? 'collapsed' : null]"
@click="router.push('/')"
>
<n-avatar class="logo-img" src="/images/logo/favicon.png?asset" />
<nav :class="{ 'main-nav': true, 'no-sider': !showSider }">
<div class="left">
<div :class="['logo', asideMenuCollapsed ? 'collapsed' : null]" @click="router.push('/')">
<!-- <n-avatar class="logo-img" src="/images/icons/favicon.png?asset" /> -->
<n-icon class="logo-img" size="30">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 1024 1024"
>
<path
d="M511.764091 131.708086a446.145957 446.145957 0 1 0 446.145957 446.145957 446.145957 446.145957 0 0 0-446.145957-446.145957z m0 519.76004A71.829499 71.829499 0 1 1 583.59359 580.530919 72.275645 72.275645 0 0 1 511.764091 651.468126z"
:fill="themeAutoCover ? 'var(--main-second-color)' : '#F55E55'"
/>
<path
d="M802.205109 0.541175l-168.197026 37.030114a67.814185 67.814185 0 0 0-53.091369 66.029602V223.614153l3.569168 349.778431h114.213365V223.614153h108.859613a26.322611 26.322611 0 0 0 26.768758-26.322611V26.863786a26.768757 26.768757 0 0 0-32.122509-26.322611z"
:fill="themeAutoCover ? 'var(--main-color)' : '#F9BBB8'"
/>
<path
d="M511.764091 386.457428a186.935156 186.935156 0 1 0 186.935156 186.48901A186.935156 186.935156 0 0 0 511.764091 386.457428z m0 264.564552a71.383353 71.383353 0 1 1 71.383353-71.383353 71.383353 71.383353 0 0 1-71.383353 71.383353z"
:fill="themeAutoCover ? 'var(--main-color)' : '#F9BBB8'"
/>
</svg>
</n-icon>
<Transition name="fade" mode="out-in">
<n-text v-if="!asideMenuCollapsed && showSider" class="site-name">
{{ siteName }}
</n-text>
</Transition>
</div>
<n-flex :class="['navigation', { hidden: searchInputFocus }]" :size="6">
<n-button :focusable="false" class="nav-icon" quaternary @click="router.go(-1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-left" />
</n-icon>
</template>
</n-button>
<n-button :focusable="false" class="nav-icon" quaternary @click="router.go(1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-right" />
</n-icon>
</template>
</n-button>
</n-flex>
<!-- 搜索框 -->
<SearchInp />
<!-- GitHub -->
<Transition name="fade" mode="out-in">
<n-text v-if="!status.asideMenuCollapsed" class="site-name">{{ siteName }}</n-text>
<n-button
v-if="showGithub"
:focusable="false"
class="github"
circle
quaternary
@click="openGithub"
>
<template #icon>
<n-icon size="20">
<SvgIcon icon="github" />
</n-icon>
</template>
</n-button>
</Transition>
</div>
<div class="navigation">
<n-button class="nav-icon" quaternary @click="router.go(-1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-left" />
</n-icon>
</template>
</n-button>
<n-button class="nav-icon" quaternary @click="router.go(1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-right" />
</n-icon>
</template>
</n-button>
<div class="right">
<!-- 全局菜单 -->
<n-dropdown
:show="mainMenuShow"
:show-arrow="true"
:options="mainMenuOptions"
placement="bottom-end"
@clickoutside="mainMenuShow = false"
>
<n-button
:style="{ pointerEvents: mainMenuShow ? 'none' : 'auto' }"
:class="['main-menu', { show: !showSider }]"
secondary
strong
round
@click="mainMenuShow = !mainMenuShow"
>
<template #icon>
<n-icon>
<SvgIcon icon="menu" />
</n-icon>
</template>
</n-button>
</n-dropdown>
<!-- 用户信息 -->
<userData />
<!-- TitleBar -->
<TitleBar v-if="checkPlatform.electron()" />
</div>
<!-- 搜索框 -->
<SearchInp />
<!-- 用户信息 -->
<userData />
</nav>
</template>
<script setup>
import { siteStatus } from "@/stores";
import { NScrollbar } from "naive-ui";
import { storeToRefs } from "pinia";
import { siteStatus, siteSettings } from "@/stores";
import { checkPlatform } from "@/utils/helper";
import { useRouter } from "vue-router";
import Menu from "@/components/Global/Menu";
import packageJson from "@/../package.json";
const router = useRouter();
const status = siteStatus();
const settings = siteSettings();
const { asideMenuCollapsed, searchInputFocus } = storeToRefs(status);
const { showGithub, showSider, themeAutoCover } = storeToRefs(settings);
// 站点信息
const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
// 打开 GitHub
const openGithub = () => {
console.log(packageJson.github);
window.open(packageJson.github);
};
// 主菜单渲染
const mainMenuShow = ref(false);
const mainMenuOptions = computed(() => [
{
key: "menu",
type: "render",
props: {
onClick: () => (mainMenuShow.value = false),
},
render: () => {
return h(NScrollbar, { style: { maxHeight: "calc(100vh - 200px)", minWidth: "280px" } }, () =>
h(Menu),
);
},
},
]);
</script>
<style lang="scss" scoped>
@@ -50,7 +146,14 @@ const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 16px;
.left,
.right {
display: flex;
flex-direction: row;
align-items: center;
}
.logo {
width: 224px;
display: flex;
@@ -63,13 +166,13 @@ const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
width 0.3s,
padding-left 0.3s;
-webkit-app-region: no-drag;
cursor: pointer;
.logo-img {
width: 30px;
height: 30px;
min-width: 30px;
background-color: transparent;
transition: transform 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.15);
}
@@ -88,18 +191,81 @@ const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
}
}
.navigation {
margin-right: 12px;
display: flex;
flex-direction: row;
justify-content: flex-start;
height: 34px;
width: 86px;
min-width: 86px;
transition:
width 0.3s,
min-width 0.3s,
opacity 0.3s;
overflow: hidden;
-webkit-app-region: no-drag;
.nav-icon {
border-radius: 8px;
padding: 0 8px;
&:first-child {
margin-right: 6px;
}
.n-icon {
font-size: 24px;
}
}
@media (max-width: 700px) {
&.hidden {
opacity: 0;
width: 0px;
min-width: 0px;
}
}
}
.github {
margin-left: 12px;
-webkit-app-region: no-drag;
}
.main-menu {
-webkit-app-region: no-drag;
margin-right: 12px;
display: none;
&.show {
display: flex;
}
@media (max-width: 900px) {
display: flex;
}
}
&.no-sider {
max-width: 1400px;
margin: 0 auto;
padding: 0 10vw;
@media (max-width: 1200px) {
padding: 0 5vw;
}
.logo {
width: auto;
padding-left: 0;
margin-right: 12px;
}
}
@media (max-width: 900px) {
.left {
.logo {
width: auto;
padding-left: 0;
margin-right: 12px;
.site-name {
display: none;
}
}
}
}
@media (max-width: 700px) {
.left {
width: 100%;
}
.github {
display: none;
}
}
}
</style>

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