Compare commits

...

29 Commits

Author SHA1 Message Date
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
90 changed files with 6265 additions and 1272 deletions

View File

@@ -27,3 +27,14 @@ 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

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

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

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/

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/>.

131
README.md
View File

@@ -1,10 +1,12 @@
> [!IMPORTANT]
> ## 🎉 当前项目正在重构中 🎉
>
> - 目前版本进入维护模式,仅在遇到重大问题时会进行修复
> - 支持客户端与网页端
> - 支持现有版本所有功能
> - 新增支持播放与管理本地歌曲
> ## 严肃警告
>
> - 请务必遵守 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 许可协议
> - 在您的修改、演绎、分发或派生项目中,必须同样采用 **AGPL-3.0** 许可协议,**并在适当的位置包含本项目的许可和版权信息**
> - **禁止用于售卖或其他商业用途**,如若发现,作者保留追究法律责任的权利
> - 若发现未遵守 **AGPL-3.0** 许可协议的行为,**本项目将永久停更**
> - 感谢您的尊重与理解
<div align="center">
<img alt="logo" height="80" src="./public/images/logo/favicon.png" />
@@ -118,68 +120,76 @@
[Dev Workflow](https://github.com/imsyy/SPlayer/actions/workflows/build.yml)
## ⚙️ 部署
## ⚙️ Vercel 部署
> Vercel 等托管平台可在 Fork 后一键导入并自动部署
> 其他部署平台大致相同,在此不做说明
### API 服务(客户端无需理会,如果需要网页端,则必需部署)
1. 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目,并成功取得在线访问地址
2. 点击本仓库右上角的 `Fork`,复制本仓库到你的 `GitHub` 账号
3. 复制 `/.env.example` 文件并重命名为 `/.env`
4.`.env` 文件中的 `RENDERER_VITE_SERVER_URL` 改为第一步得到的 API 地址
> 本程序依赖 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 运行,请确保您已成功部署该项目
```js
RENDERER_VITE_SERVER_URL = "https://example.com";
```
5. 将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
- 请在根目录下的 `.env` 文件中的 `RENDERER_VITE_SERVER_URL` 中填入 API 地址(必需)
![build](/screenshots/build.png)
```js
RENDERER_VITE_SERVER_URL = "your api url";
```
6. 点击 `Deploy`,即可成功部署
### 安装依赖
## ⚙️ 服务器部署
```bash
pnpm install
# 或者
yarn install
# 或者
npm install
```
1. 重复 `⚙️ Vercel 部署` 中的 1 - 4 步骤
2. 克隆仓库
### 开发
> 将链接中的 example/repository.git 替换为你要克隆的实际仓库的地址
```bash
pnpm dev
# 或者
yarn dev
# 或者
npm dev
```
```bash
git clone https://github.com/example/repository.git
```
### 构建网页端
3. 安装依赖
```bash
pnpm build
# 或者
yarn build
# 或者
npm build
```
```bash
pnpm install
# 或者
yarn install
# 或者
npm install
```
构建完成后可将生成的 `out/renderer` 文件夹内的文件上传至服务器
4. 编译打包
若使用的为第三方部署平台,比如 `Vercel`,请将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
```bash
pnpm build
# 或者
yarn build
# 或者
npm build
```
![build](/screenshots/build.png)
5. 将站点运行目录设置为 `out/renderer` 目录
### 构建客户端
## ⚙️ 本地部署
```bash
# win
pnpm build:win
# linux
pnpm build:linux
# mac
pnpm build:mac
```
1. 本地部署需要用到 `Node.js`。可前往 [Node.js 官网](https://nodejs.org/zh-cn/) 下载安装包,请下载最新稳定版
2. 安装 pnpm
构建完成后可在 `dist` 文件夹中打开可执行文件来完成安装操作
```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 |
## 😘 鸣谢
@@ -191,16 +201,6 @@ pnpm build:mac
- [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 +210,14 @@ 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) 了解更多详情

7
components.d.ts vendored
View File

@@ -8,6 +8,7 @@ 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']
@@ -15,10 +16,12 @@ declare module 'vue' {
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']
@@ -48,7 +51,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,6 +63,7 @@ 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']
@@ -79,6 +85,7 @@ declare module 'vue' {
NThing: typeof import('naive-ui')['NThing']
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']

View File

@@ -23,9 +23,7 @@ win:
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
# 构建类型
target:
- nsis
- portable
target: nsis
# 管理员权限
requestedExecutionLevel: highestAvailable
# NSIS 安装器配置
@@ -75,7 +73,6 @@ linux:
# 构建类型
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,52 @@ 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: loadEnv(mode, process.cwd()).RENDERER_VITE_SITE_TITLE,
short_name: loadEnv(mode, process.cwd()).RENDERER_VITE_SITE_TITLE,
description: loadEnv(mode, process.cwd()).RENDERER_VITE_SITE_DES,
display: "standalone",
start_url: "/",
theme_color: "#fff",
background_color: "#efefef",
icons: [
{
src: "/images/logo/favicon.png",
sizes: "200x200",
type: "image/png",
},
{
src: "/images/logo/favicon_512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
// 服务器配置
server: {
@@ -103,6 +150,9 @@ export default defineConfig(({ mode }) => {
win: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
linux: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
},
},
};

View File

@@ -1,154 +1,251 @@
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 } 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 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);
// 设置日志文件的最大大小为 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({
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: join(__dirname, "../../public/images/logo/favicon.png"),
// 预加载
webPreferences: {
// devTools: is.dev, //是否开启 DevTools
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());
// 创建系统信息
createSystemInfo(this.mainWindow);
});
// 在 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);
// 注册快捷键
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

@@ -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();

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,30 @@
const { autoUpdater } = require("electron-updater");
import { dialog, shell } from "electron";
import { is } from "@electron-toolkit/utils";
import { autoUpdater } from "electron-updater";
const checkForUpdates = () => {
autoUpdater.checkForUpdates();
// 更新弹窗
const hasNewVersion = (info) => {
dialog
.showMessageBox({
title: "发现新版本 v" + info.version,
message: "发现新版本 v" + info.version,
detail: "是否前往 GitHub 下载新版本安装包?",
buttons: ["前往", "取消"],
type: "question",
noLink: true,
})
.then((result) => {
if (result.response === 0) {
shell.openExternal("https://github.com/imsyy/SPlayer/releases");
}
});
};
export const configureAutoUpdater = () => {
checkForUpdates();
// 监听检查更新的事件
autoUpdater.on("checking-for-update", () => {
console.log("Checking for update...");
});
if (is.dev) return false;
autoUpdater.checkForUpdatesAndNotify();
// 若有更新
autoUpdater.on("update-available", (info) => {
console.log("Update available:", info);
});
autoUpdater.on("update-not-available", () => {
console.log("Update not available.");
});
autoUpdater.on("update-downloaded", () => {
console.log("Update downloaded. Ready to install.");
// 在需要的时候,触发安装更新
autoUpdater.quitAndInstall();
hasNewVersion(info);
});
};

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,4 +1,5 @@
import { join } from "path";
import { platform } from "@electron-toolkit/utils";
import { Tray, Menu, app, ipcMain, nativeImage, nativeTheme } from "electron";
// 当前播放歌曲数据
@@ -14,8 +15,12 @@ const createSystemInfo = (win) => {
app.setUserTasks([]);
// 系统托盘
const mainTray = new Tray(join(__dirname, "../../public/images/logo/favicon.png"));
// 默认托盘菜单
Menu.setApplicationMenu(Menu.buildFromTemplate(createTrayMenu(win)));
// 给托盘图标设置气球提示
mainTray.setToolTip(app.getName());
// 自定义任务栏缩略图
createThumbar(win);
// 歌曲数据改变时
ipcMain.on("songNameChange", (_, val) => {
playSongName = val;
@@ -26,6 +31,11 @@ const createSystemInfo = (win) => {
});
ipcMain.on("songStateChange", (_, val) => {
playSongState = val;
createThumbar(win);
});
// 监听系统主题改变
nativeTheme.on("updated", () => {
createThumbar(win);
});
// 左键事件
mainTray.on("click", () => {
@@ -36,22 +46,28 @@ const createSystemInfo = (win) => {
mainTray.on("right-click", () => {
mainTray.popUpContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
});
// linux 右键菜单
if (platform.isLinux) {
mainTray.setContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
}
};
// 生成图标
const createIcon = (name) => {
// 系统是否为暗色
const isDarkMode = nativeTheme.shouldUseDarkColors;
// 返回图标
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 });
};
// 生成右键菜单
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 [
{
@@ -69,6 +85,7 @@ const createTrayMenu = (win) => {
{
label: "上一曲",
icon: createIcon("prev"),
accelerator: "CmdOrCtrl+Left",
click: () => {
win.webContents.send("playNextOrPrev", "prev");
},
@@ -76,6 +93,7 @@ const createTrayMenu = (win) => {
{
label: playSongState ? "暂停" : "播放",
icon: createIcon(playSongState ? "pause" : "play"),
accelerator: "CmdOrCtrl+Space",
click: () => {
win.webContents.send("playOrPause");
},
@@ -83,6 +101,7 @@ const createTrayMenu = (win) => {
{
label: "下一曲",
icon: createIcon("next"),
accelerator: "CmdOrCtrl+Right",
click: () => {
win.webContents.send("playNextOrPrev", "next");
},
@@ -112,4 +131,32 @@ const createTrayMenu = (win) => {
];
};
// 自定义任务栏缩略图 - Win
const createThumbar = (win) => {
win.setThumbarButtons([]);
win.setThumbarButtons([
{
tooltip: "上一曲",
icon: createIcon("prev"),
click: () => {
win.webContents.send("playNextOrPrev", "prev");
},
},
{
tooltip: playSongState ? "暂停" : "播放",
icon: createIcon(playSongState ? "pause" : "play"),
click() {
win.webContents.send("playOrPause");
},
},
{
tooltip: "下一曲",
icon: createIcon("next"),
click: () => {
win.webContents.send("playNextOrPrev", "next");
},
},
]);
};
export default createSystemInfo;

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,14 +1,16 @@
{
"name": "splayer",
"version": "2.0.0-beta.2",
"version": "2.0.0-beta.4",
"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",
"engines": {
"node": ">=16.16.0"
},
"packageManager": "pnpm@8.12.0",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
@@ -24,10 +26,11 @@
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^2.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.13.5",
"NeteaseCloudMusicApi": "git+https://github.com/imsyy/NeteaseCloudMusicApi.git",
"axios": "^1.4.0",
"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",
@@ -49,13 +52,14 @@
"@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",
"ajv": "^8.12.0",
"electron": "^27.0.0",
"electron-builder": "^24.9.1",
"electron-log": "^5.0.1",
"electron-vite": "^1.0.27",
"electron-vite": "^1.0.29",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"naive-ui": "^2.34.4",
"naive-ui": "^2.35.0",
"prettier": "^3.0.2",
"sass": "^1.66.1",
"terser": "^5.19.2",
@@ -63,6 +67,7 @@
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^0.17.4",
"vue": "^3.3.4"
}
}

2473
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -1,33 +1,33 @@
<template>
<Provider>
<!-- 主框架 -->
<n-layout :class="['all-layout', status.showFullPlayer ? 'full-player' : null]">
<n-layout class="all-layout">
<!-- 导航栏 -->
<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': Object.keys(music.getPlaySongData)?.length && 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': Object.keys(music.getPlaySongData)?.length && 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,82 @@
</template>
<script setup>
import { darkTheme } from "naive-ui";
import { storeToRefs } from "pinia";
import { darkTheme, NButton } from "naive-ui";
import { useRouter } from "vue-router";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { initPlayer } from "@/utils/Player";
import { checkPlatform } from "@/utils/helper";
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 } = 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,29 +163,38 @@ const canNotConnect = (error) => {
title: "网络连接错误",
content: "网络连接错误,请检查您当前的网络状态",
positiveText: "重试",
negativeText: "前往本地歌曲",
negativeText: checkPlatform.electron() ? "前往本地歌曲" : "取消",
onPositiveClick: () => {
location.reload();
},
onNegativeClick: () => {
router.push("/local");
if (checkPlatform.electron()) router.push("/local");
},
});
};
// 网页端键盘事件
const handleKeyUp = (event) => {
globalShortcut(event, router);
};
onMounted(() => {
// 挂载方法
window.$canNotConnect = canNotConnect;
// 主播放器
initPlayer(settings.autoPlay);
initPlayer(autoPlay.value);
// 全局事件
globalEvents(router);
// 键盘监听
window.addEventListener("keyup", globalShortcut);
if (!checkPlatform.electron()) {
window.addEventListener("keyup", handleKeyUp);
}
// 显示公告
showAnnouncements();
});
onUnmounted(() => {
window.removeEventListener("keyup", globalShortcut);
if (!checkPlatform.electron()) window.removeEventListener("keyup", handleKeyUp);
});
</script>
@@ -142,9 +212,6 @@ onUnmounted(() => {
.body-layout {
top: 60px;
transition: bottom 0.3s;
&.player-bar {
bottom: 80px;
}
.main-sider {
:deep(.n-scrollbar-content) {
height: 100%;
@@ -156,12 +223,9 @@ onUnmounted(() => {
display: none;
}
}
.main-layout {
padding: 24px;
&.player-bar {
bottom: 80px;
}
}
&.full-player {
transform: scale(0.95);
}
}
</style>

View File

@@ -151,80 +151,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,37 @@ 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(),
},
});
};

View File

@@ -22,6 +22,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",
@@ -80,5 +81,11 @@
"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"
}

View File

@@ -69,8 +69,13 @@
</n-text>
<!-- 歌手 -->
<div v-if="item.artists" class="artists">
<n-text v-for="ar in item.artists" :key="ar.id" class="ar">
{{ ar.name }}
<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>
<!-- 歌曲数量 -->
@@ -297,8 +302,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 +318,7 @@ const jumpLink = (data, type) => {
}
}
&:hover {
color: var(--n-code-text-color);
&::after {
color: var(--n-close-icon-color);
}
opacity: 0.8;
}
}
}

View File

@@ -0,0 +1,50 @@
<template>
<main id="main-layout" :class="['main-layout', { 'no-sider': !showSider }]">
<!-- 回顶 -->
<n-back-top
:bottom="Object.keys(music.getPlaySongData)?.length && 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

@@ -4,14 +4,15 @@
ref="mainMenuRef"
v-model:value="menuActiveKey"
class="main-menu"
:root-indent="36"
:root-indent="showSider ? 36 : 28"
: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)"
@update:value="checkMenuItem"
/>
<!-- 右键菜单 -->
<CoverDropdown ref="coverDropdownRef" />
@@ -36,9 +37,17 @@ 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 { asideMenuCollapsed, showSider, showFullPlayer } = storeToRefs(status);
const { userData, userLikeData, userLoginStatus } = storeToRefs(data);
const {
playList,
playListOld,
playIndex,
playSongData,
playHeartbeatMode,
playMode,
privateFmSong,
} = storeToRefs(music);
// 子组件
const coverDropdownRef = ref(null);
@@ -90,7 +99,7 @@ const menuOptions = computed(() => [
label: "在线音乐",
key: "online",
children: [],
show: !status.asideMenuCollapsed,
show: !asideMenuCollapsed.value,
},
{
label: () =>
@@ -120,19 +129,24 @@ const menuOptions = computed(() => [
key: "discover",
icon: renderIcon("discover-fill"),
},
{
label: "私人漫游",
key: "fm",
icon: renderIcon("radio"),
},
{
label: () =>
h(
RouterLink,
{
to: {
name: "videos",
name: "record",
},
},
() => ["视频"],
() => ["播客电台"],
),
key: "videos",
icon: renderIcon("video"),
key: "record",
icon: renderIcon("record"),
},
{
key: "divider-1",
@@ -143,7 +157,7 @@ const menuOptions = computed(() => [
label: "我的音乐",
key: "user",
children: [],
show: !status.asideMenuCollapsed,
show: !asideMenuCollapsed.value,
},
{
label: () =>
@@ -157,24 +171,51 @@ const menuOptions = computed(() => [
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 +263,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;
// 创建的歌单
@@ -281,10 +327,24 @@ const changeUserPlaylists = (data) => {
};
// 选中菜单项
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";
@@ -332,7 +392,7 @@ const openSideDropdown = (e) => {
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;
@@ -386,6 +446,10 @@ watch(
changeUserPlaylists(val);
},
);
watch(
() => userLoginStatus.value,
() => changeUserPlaylists(userLikeData.value.playlists),
);
onMounted(() => {
changeUserPlaylists(userLikeData.value.playlists);
@@ -446,3 +510,22 @@ onMounted(() => {
}
}
</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="showFullPlayer ? 'main-playlist full-player' : 'main-playlist'"
: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>
@@ -22,7 +23,7 @@
</template>
<!-- 提示 -->
<n-alert v-if="playList?.length >= 400" class="alert" :show-icon="false">
因歌单数量过大无法自动定位请手动查找
当前歌曲过多无法自动定位请手动查找
</n-alert>
<Transition name="fade" mode="out-in">
<n-data-table
@@ -51,18 +52,13 @@
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 { initPlayer, fadePlayOrPause, changePlayIndex, soundStop } from "@/utils/Player";
import SvgIcon from "@/components/Global/SvgIcon";
const music = musicData();
const status = siteStatus();
const { coverColor, playSongData, playList, playIndex, playMode } = storeToRefs(music);
const { playSongData, playList, playIndex, playMode } = storeToRefs(music);
const { coverTheme, showFullPlayer, playListShow } = storeToRefs(status);
// 抽屉开启
const playlistOpen = () => {
@@ -95,7 +91,7 @@ const createSongs = (song, index) => {
class: {
songs: true,
play: playSongData.value?.id === song?.id,
player: status.showFullPlayer,
player: showFullPlayer.value,
},
onClick: () => {
playSong(song, index);
@@ -153,8 +149,6 @@ const playSong = async (song, index) => {
} else {
console.log("与当前播放歌曲不一致");
playSongData.value = song;
// 渐出音乐
if (checkPlayer()) await fadePlayOrPause("pause");
// 初始化播放器
initPlayer(true);
}
@@ -343,7 +337,7 @@ const removeSong = async (index) => {
.n-scrollbar-content {
padding: 16px !important;
}
&.player {
&.full-player {
background-color: transparent;
box-shadow: none;
.n-drawer-header {

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

@@ -139,6 +139,25 @@
</n-text>
<n-text v-else class="album">未知专辑</n-text>
</template>
<!-- 操作 -->
<div 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>
</div>
<!-- 时长 -->
<n-text v-if="item.duration" class="duration" depth="3">{{ item.duration }}</n-text>
<n-text v-else class="duration"> -- </n-text>
@@ -483,6 +502,23 @@ onBeforeUnmount(() => {
color: var(--main-color);
}
}
.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);
}
}
}
.duration {
width: 40px;
text-align: center;

View File

@@ -17,6 +17,8 @@
<AddPlaylist ref="addPlaylistRef" />
<!-- 下载歌曲 -->
<DownloadSong ref="downloadSongRef" />
<!-- 云盘歌曲纠正 -->
<CloudSongMatch ref="cloudSongMatchRef" />
</template>
<script setup>
@@ -27,6 +29,7 @@ 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"]);
@@ -45,6 +48,7 @@ const dropdownOptions = ref(null);
// 子组件
const addPlaylistRef = ref(null);
const downloadSongRef = ref(null);
const cloudSongMatchRef = ref(null);
// 图标渲染
const renderIcon = (icon, size, translate = 0) => {
@@ -101,6 +105,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
(playlist) => playlist.userId === userId,
);
// 当前状态
const isFm = playMode.value === "fm";
const isLocalSong = song?.path ? true : false;
const isCloud = router.currentRoute.value.name === "cloud";
const isUserPlaylist = sourceId !== 0 && userPlaylistsData.some((pl) => pl.id == sourceId);
@@ -132,7 +137,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "next-play",
label: "下一首播放",
show: playSongData.value?.id === song.id || playMode.value === "fm" ? false : true,
show: playSongData.value?.id !== song.id && !isFm,
props: {
onClick: () => {
addSongToNext(song);
@@ -183,6 +188,37 @@ const openDropdown = (e, data, song, index, sourceId) => {
},
icon: renderIcon("video"),
},
{
label: "更多操作",
key: "others",
show: !isLocalSong,
icon: renderIcon("more"),
children: [
{
key: "copy",
label: "复制歌曲 ID",
props: {
onClick: () => {
const songId = song?.id?.toString();
copyData(songId);
},
},
icon: renderIcon("content-copy"),
},
{
key: "share",
label: "分享歌曲链接",
props: {
onClick: () => {
const shareUrl = `https://music.163.com/song?id=${song?.id?.toString()}`;
copyData(shareUrl, "复制歌曲链接");
},
},
icon: renderIcon("share"),
},
],
},
{
key: "line-cloud",
type: "divider",
@@ -199,6 +235,17 @@ const openDropdown = (e, data, song, index, sourceId) => {
},
icon: renderIcon("delete"),
},
{
key: "edit",
label: "云盘歌曲纠正",
show: isCloud,
props: {
onClick: () => {
cloudSongMatchRef.value?.openCloudSongMatch(song, index);
},
},
icon: renderIcon("edit"),
},
{
key: "delete",
label: "从云盘中删除",

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-space justify="end">
<n-button @click="closeCloudSongMatch"> 取消 </n-button>
<n-button
:disabled="!cloudMatchValue.asid"
type="primary"
@click="setCloudSongMatchBtn(cloudMatchValue)"
>
纠正
</n-button>
</n-space>
</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 $refreshCloudList !== "undefined") $refreshCloudList();
} 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

@@ -189,12 +189,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

@@ -21,7 +21,9 @@
<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
@@ -72,6 +74,7 @@ const openLoginModal = () => {
// 储存登录信息
const setLoginData = async (loginData) => {
console.log(loginData);
if (!loginData) return false;
if (loginData.code === 200) {
// 关闭登录弹窗
@@ -168,7 +171,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

@@ -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();

View File

@@ -1,47 +1,119 @@
<!-- 主导航栏 -->
<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', status.asideMenuCollapsed ? 'collapsed' : null]"
@click="router.push('/')"
>
<n-avatar class="logo-img" src="/images/logo/favicon.png?asset" />
<Transition name="fade" mode="out-in">
<n-text v-if="!status.asideMenuCollapsed && showSider" class="site-name">
{{ siteName }}
</n-text>
</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>
<!-- 搜索框 -->
<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" 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
v-if="!showSider"
:show="mainMenuShow"
:show-arrow="true"
:options="mainMenuOptions"
placement="bottom-end"
@clickoutside="mainMenuShow = false"
>
<n-button
:style="{ pointerEvents: mainMenuShow ? 'none' : 'auto' }"
class="main-menu"
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 { showGithub, showSider } = 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 +122,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;
@@ -101,5 +180,26 @@ const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
}
}
}
.github {
margin-left: 12px;
-webkit-app-region: no-drag;
}
.main-menu {
-webkit-app-region: no-drag;
margin-right: 12px;
}
&.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;
}
}
}
</style>

View File

@@ -53,6 +53,7 @@ const data = siteData();
const router = useRouter();
const settings = siteSettings();
const { userLoginStatus, userData, userLikeData } = storeToRefs(data);
const { themeType } = storeToRefs(settings);
// 菜单数据
const userMenuShow = ref(false);
@@ -120,9 +121,9 @@ const userMenuOptions = computed(() => [
key: "d1",
},
{
label: settings.themeType === "dark" ? "浅色模式" : "深色模式",
label: themeType.value === "dark" ? "浅色模式" : "深色模式",
key: "darkOrlight",
icon: renderIcon(settings.themeType === "dark" ? "round-wb-sunny" : "round-dark-mode"),
icon: renderIcon(themeType.value === "dark" ? "round-wb-sunny" : "round-dark-mode"),
},
{
label: "全局设置",
@@ -143,7 +144,7 @@ const userMenuSelect = (key) => {
switch (key) {
// 明暗切换
case "darkOrlight":
settings.setThemeType(settings.themeType === "light" ? "dark" : "light");
settings.setThemeType(themeType.value === "light" ? "dark" : "light");
break;
// 登录登出
case "logoutOrlogin":

View File

@@ -4,23 +4,20 @@
<div
v-if="showFullPlayer"
:style="{
'--cover-main-color': `rgb(${coverTheme?.dark?.shadeTwo})` || '#efefef',
'--cover-second-color': `rgba(${coverTheme?.dark?.shadeTwo}, 0.14)` || '#efefef14',
'--cover-main-color': `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
'--cover-second-color': `rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
'--cover-bg': coverBackground,
cursor: playerControlShow ? 'auto' : 'none',
}"
class="full-player"
@mousemove="controlShowChange"
@mouseleave="playerControlShow = false"
>
<!-- 遮罩 -->
<Transition name="fade" mode="out-in">
<div
:key="
playerBackgroundType === 'gradient'
? coverBackground
: music.getPlaySongData?.coverSize?.s || music.getPlaySongData?.localCover
"
:key="`${playerBackgroundType}-${coverBackground}-${
music.getPlaySongData?.coverSize?.s || music.getPlaySongData?.localCover
}`"
:class="['overlay', playerBackgroundType]"
>
<!-- 模糊背景 -->
@@ -49,7 +46,12 @@
<!-- 按钮 -->
<Transition name="fade" mode="out-in">
<div v-show="playerControlShow" class="menu">
<div class="left"></div>
<div class="left">
<!-- 歌词模式 -->
<div v-if="isHasLrc" class="n-icon" @click="pureLyricMode = !pureLyricMode">
<n-text></n-text>
</div>
</div>
<div class="right">
<!-- 全屏切换 -->
<n-icon @click.stop="screenfullChange">
@@ -64,116 +66,164 @@
</div>
</div>
</Transition>
<!-- 主内容 -->
<div
:class="{
content: true,
'no-lrc': !playSongLyric.lrc?.[0] && playSongLyric.lrc?.length <= 4,
}"
>
<!-- 封面 -->
<Transition name="fade" mode="out-in">
<div :key="music.getPlaySongData.id" class="cover">
<n-image
:src="
music.getPlaySongData?.coverSize?.l ||
music.getPlaySongData?.cover ||
music.getPlaySongData?.localCover
"
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" />
<!-- 播放器内容 -->
<Transition name="fade" mode="out-in">
<div
:key="`${pureLyricMode}-${playCoverType}-${isHasLrc}-${music.getPlaySongData?.id}`"
class="main-player"
>
<div v-show="!(pureLyricMode && isHasLrc)" :class="['content', { 'no-lrc': !isHasLrc }]">
<!-- 封面 -->
<PlayerCover />
<!-- 信息 -->
<div v-if="playCoverType === 'cover' || !isHasLrc" :class="['data', playCoverType]">
<div class="desc">
<div class="title">
<span class="name">{{ music.getPlaySongData.name || "未知曲目" }}</span>
<n-popover :show-arrow="false" placement="right-start" trigger="hover" raw>
<template #trigger>
<n-tag
v-show="playUseOtherSource"
:style="{
color: `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
backgroundColor:
`rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
}"
round
>
其他音源
</n-tag>
</template>
<div
:style="{
color: `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
backgroundColor:
`rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
}"
class="title-tip"
>
<span>该歌曲暂时无法播放为您采用其他音源可能会与原曲存在差别</span>
</div>
</n-popover>
</div>
</template>
</n-image>
<!-- 封面背板 -->
<n-image
class="cover-shadow"
preview-disabled
:src="
music.getPlaySongData?.coverSize?.l ||
music.getPlaySongData?.cover ||
music.getPlaySongData?.localCover
"
/>
</div>
</Transition>
<!-- 信息 -->
<div class="data">
<div class="desc">
<div class="title">
<n-text>{{ music.getPlaySongData.name || "未知曲目" }}</n-text>
<n-popover :show-arrow="false" placement="right-start" trigger="hover" raw>
<template #trigger>
<n-tag v-show="playUseOtherSource" round> 其他音源 </n-tag>
</template>
<div class="title-tip">
<n-text>该歌曲暂时无法播放为您采用其他音源可能会与原曲存在差别</n-text>
<span v-if="music.getPlaySongData.alia" class="alia">
{{ music.getPlaySongData.alia }}
</span>
<div class="artist">
<n-icon depth="3" size="20">
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="Array.isArray(music.getPlaySongData.artists)" class="all-ar">
<span
v-for="ar in music.getPlaySongData.artists"
:key="ar.id"
class="ar"
@click.stop="
() => {
showFullPlayer = false;
router.push(`/artist?id=${ar.id}`);
}
"
>
{{ ar.name }}
</span>
</div>
<div v-else class="all-ar">
<span class="ar"> {{ music.getPlaySongData.artists || "未知艺术家" }} </span>
</div>
</div>
</n-popover>
</div>
<span v-if="music.getPlaySongData.alia" class="alia">
{{ music.getPlaySongData.alia }}
</span>
<div class="artist">
<n-icon depth="3" size="20">
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="Array.isArray(music.getPlaySongData.artists)" class="all-ar">
<span
v-for="ar in music.getPlaySongData.artists"
:key="ar.id"
class="ar"
<div
class="album"
@click.stop="
() => {
showFullPlayer = false;
router.push(`/artist?id=${ar.id}`);
if (typeof music.getPlaySongData.album !== 'string') {
showFullPlayer = false;
router.push(`/album?id=${music.getPlaySongData?.album.id}`);
}
}
"
>
{{ ar.name }}
</span>
<n-icon depth="3" size="20">
<SvgIcon icon="album" />
</n-icon>
<span v-if="music.getPlaySongData.album" class="al">
{{
typeof music.getPlaySongData.album === "string"
? music.getPlaySongData.album
: music.getPlaySongData.album.name
}}
</span>
<span v-else class="album">未知专辑</span>
</div>
</div>
<div v-else class="all-ar">
<span class="ar"> {{ music.getPlaySongData.artists || "未知艺术家" }} </span>
</div>
</div>
<div
class="album"
@click.stop="
() => {
if (typeof music.getPlaySongData.album !== 'string') {
showFullPlayer = false;
router.push(`/album?id=${music.getPlaySongData?.album.id}`);
}
}
"
>
<n-icon depth="3" size="20">
<SvgIcon icon="album" />
</n-icon>
<span v-if="music.getPlaySongData.album" class="al">
{{
typeof music.getPlaySongData.album === "string"
? music.getPlaySongData.album
: music.getPlaySongData.album.name
}}
</span>
<span v-else class="album">未知专辑</span>
</div>
</div>
<div :class="['right', { pure: pureLyricMode && isHasLrc }]">
<!-- 唱片模式下信息 -->
<div
v-show="(pureLyricMode && isHasLrc) || (playCoverType === 'record' && isHasLrc)"
class="data"
>
<div class="name">
<span class="name-text">{{ music.getPlaySongData.name || "未知曲目" }}</span>
<span v-if="music.getPlaySongData.alia" class="name-alias">
{{ music.getPlaySongData.alia }}
</span>
</div>
<div class="other">
<div class="artist">
<n-icon depth="3" size="20">
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="Array.isArray(music.getPlaySongData.artists)" class="all-ar">
<span
v-for="ar in music.getPlaySongData.artists"
:key="ar.id"
class="ar"
@click.stop="
() => {
showFullPlayer = false;
router.push(`/artist?id=${ar.id}`);
}
"
>
{{ ar.name }}
</span>
</div>
<div v-else class="all-ar">
<span class="ar"> {{ music.getPlaySongData.artists || "未知艺术家" }} </span>
</div>
</div>
<div
class="album"
@click.stop="
() => {
if (typeof music.getPlaySongData.album !== 'string') {
showFullPlayer = false;
router.push(`/album?id=${music.getPlaySongData?.album.id}`);
}
}
"
>
<n-icon depth="3" size="20">
<SvgIcon icon="album" />
</n-icon>
<span v-if="music.getPlaySongData.album" class="al">
{{
typeof music.getPlaySongData.album === "string"
? music.getPlaySongData.album
: music.getPlaySongData.album.name
}}
</span>
<span v-else class="album">未知专辑</span>
</div>
</div>
</div>
<!-- 歌词 -->
<Lyric :cursorShow="playerControlShow" />
</div>
</div>
</div>
<!-- 歌词 -->
<Lyric :cursorShow="playerControlShow" />
</Transition>
<!-- 控制中心 -->
<PlayerControl v-show="playerControlShow" />
</div>
@@ -183,20 +233,30 @@
<script setup>
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { musicData, siteStatus, siteSettings, siteData } from "@/stores";
import { musicData, siteStatus, siteSettings } from "@/stores";
import screenfull from "screenfull";
import throttle from "@/utils/throttle";
const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playList, playSongLyric } = storeToRefs(music);
const { playerBackgroundType, showYrc } = storeToRefs(settings);
const { coverTheme, coverBackground } = storeToRefs(data);
const { playerControlShow, controlTimeOut, showFullPlayer, playUseOtherSource } =
storeToRefs(status);
const { playerBackgroundType, showYrc, playCoverType } = storeToRefs(settings);
const {
playerControlShow,
controlTimeOut,
showFullPlayer,
playUseOtherSource,
coverTheme,
coverBackground,
pureLyricMode,
} = storeToRefs(status);
// 是否有歌词
const isHasLrc = computed(() => {
return playSongLyric.value.lrc?.[0] && playSongLyric.value.lrc?.length > 4;
});
// 全屏状态
const screenfullStatus = ref(false);
@@ -327,6 +387,7 @@ onUnmounted(() => {
justify-content: space-between;
z-index: 2;
box-sizing: border-box;
-webkit-app-region: no-drag;
.left,
.right {
display: flex;
@@ -335,6 +396,17 @@ onUnmounted(() => {
justify-content: flex-end;
flex: 1;
}
.left {
justify-content: flex-start;
.n-icon {
margin-left: 0;
margin-right: 12px;
.n-text {
font-size: 26px;
font-weight: bold;
}
}
}
.n-icon {
margin-left: 12px;
width: 40px;
@@ -360,138 +432,214 @@ onUnmounted(() => {
}
}
}
// 内容
.content {
width: 50%;
.main-player {
display: flex;
flex-direction: column;
justify-content: center;
flex-direction: row;
align-items: center;
transition: transform 0.3s;
&.no-lrc {
transform: translateX(50%);
}
.cover {
position: relative;
width: 100%;
height: 100%;
// 内容
.content {
width: 50%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
width: 70%;
max-width: 55vh;
height: auto;
aspect-ratio: 1 / 1;
.cover-img {
width: 100%;
height: 100%;
border-radius: 32px;
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;
}
}
.cover-shadow {
position: absolute;
top: 12px;
height: 100%;
width: 100%;
filter: blur(20px) opacity(0.6);
transform: scale(0.95);
z-index: 0;
:deep(img) {
width: 100%;
height: 100%;
}
}
}
.data {
width: 70%;
max-width: 55vh;
margin-top: 24px;
padding: 0 2px;
box-sizing: border-box;
.desc {
display: flex;
flex-direction: column;
.title {
align-items: center;
transition:
transform 0.3s,
opacity 0.3s;
.data {
width: 70%;
max-width: 55vh;
margin-top: 24px;
padding: 0 2px;
box-sizing: border-box;
.desc {
display: flex;
align-items: center;
margin-left: 4px;
.n-text {
font-size: 26px;
font-weight: bold;
color: var(--cover-main-color);
-webkit-line-clamp: 2;
}
.n-tag {
margin-left: 6px;
cursor: pointer;
}
}
.alia {
margin: 6px 0 6px 2px;
opacity: 0.6;
font-size: 18px;
}
.artist {
margin-top: 2px;
display: flex;
align-items: center;
.n-icon {
margin-right: 4px;
color: var(--cover-main-color);
}
.all-ar {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
.ar {
font-size: 16px;
opacity: 0.7;
display: inline-flex;
transition: opacity 0.3s;
flex-direction: column;
.title {
display: flex;
align-items: center;
margin-left: 4px;
.name {
font-size: 26px;
font-weight: bold;
color: var(--cover-main-color);
-webkit-line-clamp: 2;
}
.n-tag {
margin-left: 12px;
cursor: pointer;
&::after {
content: "/";
margin: 0 4px;
transition: none;
}
&:last-child {
}
}
.alia {
margin: 6px 0 6px 2px;
opacity: 0.6;
font-size: 18px;
}
.artist {
margin-top: 2px;
display: flex;
align-items: center;
.n-icon {
margin-right: 4px;
color: var(--cover-main-color);
}
.all-ar {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
.ar {
font-size: 16px;
opacity: 0.7;
display: inline-flex;
transition: opacity 0.3s;
cursor: pointer;
&::after {
display: none;
content: "/";
margin: 0 4px;
transition: none;
}
&:last-child {
&::after {
display: none;
}
}
&:hover {
opacity: 1;
}
}
}
}
.album {
margin-top: 2px;
font-size: 16px;
display: flex;
align-items: center;
.n-icon {
margin-right: 4px;
color: var(--cover-main-color);
}
.al {
opacity: 0.7;
transition: opacity 0.3s;
// -webkit-line-clamp: 2;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
}
.album {
margin-top: 2px;
font-size: 16px;
&.record {
width: 100%;
margin-top: 20px;
.desc {
align-items: center;
.title {
.name {
text-align: center;
}
}
}
}
}
&.no-lrc {
transform: translateX(50%);
}
}
.right {
width: 50%;
transition: width 0.3s;
.data {
padding: 0 80px 0 24px;
margin-bottom: 26px;
.name {
display: flex;
align-items: center;
flex-direction: column;
font-size: 30px;
font-weight: bold;
.name-text {
-webkit-line-clamp: 2;
}
.name-alias {
margin-top: 6px;
font-size: 18px;
font-weight: normal;
opacity: 0.6;
}
}
.other {
display: flex;
flex-direction: column;
margin-top: 8px;
font-size: 16px;
.n-icon {
margin-right: 4px;
color: var(--cover-main-color);
}
.al {
opacity: 0.7;
transition: opacity 0.3s;
// -webkit-line-clamp: 2;
cursor: pointer;
&:hover {
opacity: 1;
.artist {
display: flex;
align-items: center;
margin-right: 12px;
.all-ar {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
.ar {
opacity: 0.7;
display: inline-flex;
transition: opacity 0.3s;
cursor: pointer;
&::after {
content: "/";
margin: 0 4px;
transition: none;
}
&:last-child {
&::after {
display: none;
}
}
&:hover {
opacity: 1;
}
}
}
}
.album {
display: flex;
align-items: center;
margin-top: 4px;
.al {
opacity: 0.7;
transition: opacity 0.3s;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
}
}
&.pure {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.data {
padding: 0 80px;
display: flex;
flex-direction: column;
align-items: center;
.other,
.name {
align-items: center;
}
}
}
}
@@ -509,7 +657,6 @@ onUnmounted(() => {
.title-tip {
width: 200px;
padding: 12px 20px;
background-color: var(--main-second-color);
border-radius: 12px;
.n-text {
display: initial;

View File

@@ -4,7 +4,7 @@
:style="{
cursor: cursorShow ? 'pointer' : 'none',
}"
:class="['lyric', `lyric-${lyricsPosition}`]"
:class="['lyric', `lyric-${lyricsPosition}`, { pure: pureLyricMode }, playCoverType]"
@mouseenter="lrcMouseStatus = lrcMousePause ? true : false"
@mouseleave="lrcAllLeave"
>
@@ -13,6 +13,8 @@
v-if="playSongLyric.lrc?.[0] && playSongLyric.lrc?.length > 4"
:key="playSongLyric.lrc?.[0]"
class="lyric-all"
@after-enter="lyricsScroll(playSongLyricIndex)"
@after-leave="lyricsScroll(playSongLyricIndex)"
>
<n-scrollbar ref="lyricScroll" style="width: 100%">
<!-- 普通歌词 -->
@@ -32,6 +34,11 @@
:id="'lrc' + index"
:key="index"
:class="{ 'lrc-line': true, on: Number(playSongLyricIndex) === index, islrc: true }"
:style="{
filter: lyricsBlur
? `blur(${Math.min(Math.abs(Number(playSongLyricIndex) - index) * 1.5, 10)}px)`
: 'blur(0)',
}"
@click.stop="jumpSeek(item?.time)"
>
<!-- 歌词 -->
@@ -141,7 +148,7 @@ const props = defineProps({
const music = musicData();
const settings = siteSettings();
const status = siteStatus();
const { playSeek } = storeToRefs(status);
const { playSeek, pureLyricMode } = storeToRefs(status);
const { playSongLyric, playSongLyricIndex } = storeToRefs(music);
const {
showYrc,
@@ -154,6 +161,7 @@ const {
lyricsBlur,
showTransl,
showRoma,
playCoverType,
} = storeToRefs(settings);
// 歌词滚动数据
@@ -188,14 +196,14 @@ const getYrcStyle = (wordData, lyricIndex) => {
// 如果当前歌词索引与播放歌曲的歌词索引不匹配
if (playSongLyricIndex.value !== lyricIndex) {
return {
transitionDuration: `0ms, 0ms, 0.5s`,
transitionDuration: `0ms, 0ms, 0.35s`,
transitionDelay: `0ms`,
};
}
// 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0
if (status.playLoading === false && wordData.time + wordData.duration - playSeek.value > 0) {
return {
transitionDuration: `0s, 0s, 0.5s`,
transitionDuration: `0s, 0s, 0.35s`,
transitionDelay: `0ms`,
WebkitMaskPositionX: `${
100 - Math.max(((playSeek.value - wordData.time) / wordData.duration) * 100, 0)
@@ -204,7 +212,7 @@ const getYrcStyle = (wordData, lyricIndex) => {
}
// 如果以上条件都不满足
return {
transitionDuration: `${wordData.duration}ms, ${wordData.duration * 0.8}ms, 0.5s`,
transitionDuration: `${wordData.duration}ms, ${wordData.duration * 0.8}ms, 0.35s`,
transitionDelay: `${wordData.time - playSeek.value}ms, ${
wordData.time - playSeek.value + wordData.duration * 0.5
}ms, 0ms`,
@@ -229,11 +237,22 @@ const jumpSeek = (time) => {
fadePlayOrPause();
};
// 主进程调用歌词滚动
if (typeof electron !== "undefined") {
electron.ipcRenderer.on("lyricsScroll", () => {
lyricsScroll(playSongLyricIndex.value);
});
}
// 监听歌词滚动
watch(
() => playSongLyricIndex.value,
(val) => lyricsScroll(val),
);
watch(
() => pureLyricMode.value,
() => lyricsScroll(playSongLyricIndex.value),
);
onMounted(() => {
nextTick().then(() => {
@@ -244,7 +263,7 @@ onMounted(() => {
<style lang="scss" scoped>
.lyric {
width: 50%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
@@ -272,11 +291,13 @@ onMounted(() => {
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0)
);
:deep(.n-scrollbar-rail) {
display: none;
}
:deep(.n-scrollbar-content) {
padding-left: 10px;
padding-right: 80px;
}
.fade-enter-active {
transition-delay: 0.3s;
}
@@ -305,9 +326,9 @@ onMounted(() => {
transform: scale(0.86);
transform-origin: left center;
transition:
filter 0.3s,
opacity 0.3s,
transform 0.3s ease-in-out;
filter 0.35s,
opacity 0.35s,
transform 0.35s ease-in-out;
.lrc-content {
font-size: 46px;
font-weight: bold;
@@ -340,7 +361,7 @@ onMounted(() => {
);
-webkit-mask-size: 220% 100%;
-webkit-mask-repeat: no-repeat;
transition: all 0.3s;
transition: all 0.35s;
}
&.end-with-space {
margin-right: 12px;
@@ -354,11 +375,13 @@ onMounted(() => {
margin-top: 8px;
font-size: 30px;
opacity: 0.6;
transition: opacity 0.35s;
}
.lrc-roma {
margin-top: 4px;
font-size: 20px;
opacity: 0.6;
transition: opacity 0.35s;
}
&.islrc {
opacity: 0.3;
@@ -367,10 +390,10 @@ onMounted(() => {
.lrc-content {
display: flex;
flex-wrap: wrap;
.lrc-fy,
.lrc-roma {
opacity: 0.3;
}
}
.lrc-fy,
.lrc-roma {
opacity: 0.3;
}
}
&.on {
@@ -413,8 +436,8 @@ onMounted(() => {
z-index: 0;
transform: scale(1.05);
transition:
transform 0.3s ease,
opacity 0.3s ease;
transform 0.35s ease,
opacity 0.35s ease;
pointer-events: none;
}
&:hover {
@@ -432,18 +455,19 @@ onMounted(() => {
}
}
}
&.lyric-center {
&.lyric-center,
&.pure {
span {
text-align: center;
text-align: center !important;
}
.placeholder {
justify-content: center;
justify-content: center !important;
}
.lrc-line {
transform-origin: center;
align-items: center;
transform-origin: center !important;
align-items: center !important;
.lrc-content {
justify-content: center;
justify-content: center !important;
}
}
}
@@ -462,5 +486,22 @@ onMounted(() => {
}
}
}
&.pure {
:deep(.n-scrollbar-content) {
padding: 0 80px;
}
}
&.record,
&.pure {
height: calc(100vh - 340px);
margin-bottom: 20px;
.lrc-line {
margin-bottom: -12px;
transform: scale(0.76);
&.on {
transform: scale(0.9);
}
}
}
}
</style>

View File

@@ -4,6 +4,7 @@
:class="{
'main-player': true,
'show-bar': Object.keys(music.getPlaySongData)?.length && showPlayBar,
'no-sider': !showSider,
}"
content-style="padding: 0"
@dblclick.stop="showFullPlayer = true"
@@ -171,6 +172,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playLoading"
tag="div"
type="primary"
class="play-control"
strong
@@ -343,7 +345,7 @@ const {
playSongLyric,
} = storeToRefs(music);
const { playLoading, playState, playListShow, showPlayBar, showFullPlayer } = storeToRefs(status);
const { showYrc, bottomLyricShow } = storeToRefs(settings);
const { showYrc, bottomLyricShow, showSider } = storeToRefs(settings);
// 子组件
const addPlaylistRef = ref(null);
@@ -507,9 +509,6 @@ watch(
padding: 0 15px;
z-index: 2;
transition: bottom 0.3s;
&.show-bar {
bottom: 0;
}
.slider {
position: absolute;
top: -11px;
@@ -777,6 +776,21 @@ watch(
}
}
}
&.show-bar {
bottom: 0;
}
&.no-sider {
padding: 0;
.player {
width: auto;
max-width: 1400px;
margin: 0 auto;
padding: 0 10vw;
@media (max-width: 1200px) {
padding: 0 5vw;
}
}
}
}
// 音量控制
.slider-content {

View File

@@ -68,6 +68,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playLoading"
:keyboard="false"
class="play-control"
strong
secondary

View File

@@ -0,0 +1,210 @@
<!-- 播放器 - 专辑封面 -->
<template>
<div :class="['cover', playCoverType]">
<!-- 指针 -->
<img
v-if="playCoverType === 'record'"
:class="{ pointer: true, play: playState }"
src="/images/pic/pointer.png?assest"
alt="pointer"
/>
<!-- 专辑图片 -->
<n-image
:src="
music.getPlaySongData?.coverSize?.l ||
music.getPlaySongData?.cover ||
music.getPlaySongData?.localCover
"
:style="{
animationPlayState: playState ? 'running' : 'paused',
}"
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>
<!-- 封面背板 -->
<n-image
class="cover-shadow"
preview-disabled
:src="
music.getPlaySongData?.coverSize?.l ||
music.getPlaySongData?.cover ||
music.getPlaySongData?.localCover
"
/>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { musicData, siteStatus, siteSettings } from "@/stores";
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playCoverType } = storeToRefs(settings);
const { playState } = storeToRefs(status);
</script>
<style lang="scss" scoped>
.cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 70%;
max-width: 55vh;
height: auto;
aspect-ratio: 1 / 1;
.cover-img {
width: 100%;
height: 100%;
border-radius: 32px;
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;
}
}
.cover-shadow {
position: absolute;
top: 12px;
height: 100%;
width: 100%;
filter: blur(20px) opacity(0.6);
transform: scale(0.95);
z-index: 0;
:deep(img) {
width: 100%;
height: 100%;
}
}
&.record {
position: relative;
width: 55vh;
.pointer {
position: absolute;
width: 14vh;
left: calc(50% - 1.8vh);
top: -11vh;
transform: rotate(-20deg);
transform-origin: 1.8vh 1.8vh;
z-index: 2;
transition: transform 0.3s;
&.play {
transform: rotate(0);
}
}
.cover-img {
animation: playerCoverRotate 18s linear infinite;
border-radius: 50%;
border: 1vh solid #ffffff30;
background: linear-gradient(black 0%, transparent, black 98%),
radial-gradient(
#000 52%,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555,
#000,
#555
);
background-clip: content-box;
width: 46vh;
height: 46vh;
min-width: 46vh;
display: flex;
justify-content: center;
align-items: center;
:deep(img) {
border: 1vh solid #ffffff40;
border-radius: 50%;
width: 70%;
height: 70%;
object-fit: cover;
}
.cover-loading {
position: absolute;
height: 100%;
padding-bottom: 0;
.loading-img {
top: auto;
}
}
}
.cover-shadow {
display: none;
}
}
}
</style>

View File

@@ -3,7 +3,10 @@
<div
class="private-fm"
:style="{
color: playMode === 'fm' && settings.themeAutoCover ? 'var(--main-color)' : '#efefef',
'--color':
playMode === 'fm' && settings.themeAutoCover && Object.keys(coverTheme)?.length
? `rgb(${coverTheme.dark.bg})`
: '#efefef',
}"
>
<!-- 背景 -->
@@ -67,8 +70,8 @@
<div class="control">
<!-- 播放暂停 -->
<n-button
:loading="playLoading"
:class="{ 'play-control': true, isFm: playMode === 'fm' && settings.themeAutoCover }"
:loading="playMode === 'fm' && playLoading"
class="play-control"
color="#efefef"
type="primary"
strong
@@ -134,7 +137,7 @@ const status = siteStatus();
const settings = siteSettings();
const router = useRouter();
const { privateFmSong, playMode } = storeToRefs(music);
const { playLoading, playState } = storeToRefs(status);
const { playLoading, playState, coverTheme } = storeToRefs(status);
// 播放暂停
const fmPlayOrPause = () => {
@@ -240,6 +243,7 @@ onBeforeMount(async () => {
display: flex;
flex-direction: column;
width: 100%;
color: var(--color);
.info {
.name {
display: -webkit-box;
@@ -256,6 +260,7 @@ onBeforeMount(async () => {
align-items: center;
.n-icon {
margin-right: 4px;
color: var(--color);
}
.all-ar {
display: -webkit-box;
@@ -292,6 +297,7 @@ onBeforeMount(async () => {
align-items: center;
.n-icon {
margin-right: 4px;
color: var(--color);
}
.al {
display: -webkit-box;
@@ -315,9 +321,11 @@ onBeforeMount(async () => {
align-items: center;
height: 46px;
margin-top: auto;
color: var(--color);
.play-control {
--n-width: 46px;
--n-height: 46px;
color: var(--color);
margin-right: 12px;
transition:
background-color 0.3s,
@@ -331,9 +339,6 @@ onBeforeMount(async () => {
&:active {
transform: scale(1);
}
&.isFm {
color: var(--main-color);
}
}
.play-other {
margin-right: 12px;
@@ -355,6 +360,7 @@ onBeforeMount(async () => {
position: absolute;
right: 0;
bottom: 0;
color: var(--color);
display: flex;
flex-direction: column;
align-items: flex-end;

View File

@@ -180,6 +180,7 @@ onBeforeMount(() => {
margin-top: 8px;
.n-tag {
font-size: 13px;
background-color: var(--n-action-color);
transition:
background-color 0.3s,
transform 0.3s,

View File

@@ -6,6 +6,7 @@
v-model:value="searchInputValue"
:class="status.searchInputFocus ? 'input focus' : 'input'"
:input-props="{ autoComplete: false }"
:allow-input="noSideSpace"
placeholder="搜索音乐 / 视频"
round
clearable
@@ -28,22 +29,31 @@
/>
</Transition>
<!-- 热搜榜及历史 -->
<SearchHot :searchValue="searchInputValue" @toSearch="toSearch" />
<SearchHot :searchValue="searchInputValue?.trim()" @toSearch="toSearch" />
<!-- 搜索建议 -->
<SearchSuggestions :searchValue="searchInputValue" @toSearch="toSearch" />
<SearchSuggestions :searchValue="searchInputValue?.trim()" @toSearch="toSearch" />
</div>
</template>
<script setup>
import { siteData, siteStatus } from "@/stores";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getSongDetail } from "@/api/song";
import { siteData, siteStatus, musicData } from "@/stores";
import { addSongToNext, initPlayer } from "@/utils/Player";
import formatData from "@/utils/formatData";
const router = useRouter();
const music = musicData();
const status = siteStatus();
const data = siteData();
const { playSongData } = storeToRefs(music);
const searchInpRef = ref(null);
const searchInputValue = ref(null);
const searchInputValue = ref("");
// 搜索框输入限制
const noSideSpace = (value) => !value.startsWith(" ") && !value.endsWith(" ");
// 搜索框 focus
const searchInputFocus = () => {
@@ -63,6 +73,24 @@ const setSearchHistory = (name) => {
}
};
// 直接播放单曲
const toPlaySong = async (id) => {
try {
if (!id) return false;
// 获取歌曲信息
const result = await getSongDetail(id.toString());
const songData = formatData(result?.songs?.[0], "song")?.[0];
// 添加至下一曲
addSongToNext(songData, true);
playSongData.value = songData;
// 初始化播放器
initPlayer(true);
} catch (error) {
console.error("获取歌曲信息失败:", error);
$message.error("获取歌曲信息失败");
}
};
// 前往搜索
const toSearch = (val, type = "song") => {
if (!val) return false;
@@ -94,12 +122,7 @@ const toSearch = (val, type = "song") => {
break;
// 单曲
case "songs":
router.push({
path: "/song",
query: {
id: val,
},
});
toPlaySong(val);
break;
// 专辑
case "albums":

View File

@@ -42,8 +42,13 @@
</n-icon>
<n-text class="type-name">{{ searchSuggestionsType[item].name }}</n-text>
</div>
<div v-for="suggs in suggestionsData[item]" :key="suggs.id" class="suggestions-item">
<n-text class="item-name" @click="toSearch(suggs.id, item)">
<div
v-for="suggs in suggestionsData[item]"
:key="suggs.id"
class="suggestions-item"
@click="toSearch(suggs.id, item)"
>
<n-text class="item-name">
{{ suggs.name }}
</n-text>
</div>

View File

@@ -2,21 +2,28 @@
<template>
<div class="title-bar">
<n-divider vertical />
<n-button class="bar-icon" quaternary circle @click="windowMin">
<n-button
class="bar-icon"
tag="div"
style="margin-left: 0"
quaternary
circle
@click="windowMin"
>
<template #icon>
<n-icon :depth="2">
<SvgIcon icon="window-minimize" />
</n-icon>
</template>
</n-button>
<n-button class="bar-icon" quaternary circle @click="maxOrRestore">
<n-button class="bar-icon" tag="div" quaternary circle @click="maxOrRestore">
<template #icon>
<n-icon :depth="2">
<SvgIcon :icon="defaultWindowState ? 'window-restore' : 'window-maximize'" />
</n-icon>
</template>
</n-button>
<n-button class="bar-icon" quaternary circle @click="openCloseTip">
<n-button class="bar-icon" tag="div" quaternary circle @click="openCloseTip">
<template #icon>
<n-icon :depth="2">
<SvgIcon icon="window-close" />
@@ -36,7 +43,7 @@
title="关闭软件"
transform-origin="center"
>
<n-text>确认关闭软件吗</n-text>
<n-text class="close-tip">确认关闭软件吗</n-text>
<n-checkbox v-model:checked="closeTipCheckbox"> 记住且不再询问 </n-checkbox>
<template #footer>
<n-space justify="space-between">
@@ -127,7 +134,7 @@ const closeCloseTip = (type) => {
};
// 窗口状态响应
electron.ipcRenderer.on("window-state", (_, val) => {
electron.ipcRenderer.on("windowState", (_, val) => {
defaultWindowState.value = val;
});
@@ -138,16 +145,19 @@ onBeforeUnmount(() => {
<style lang="scss" scoped>
.title-bar {
padding-right: 16px;
display: flex;
flex-direction: row;
align-items: center;
-webkit-app-region: no-drag;
.n-divider {
margin-left: 16px;
}
.bar-icon {
margin-left: 8px;
&:first-child {
margin-left: 0;
}
}
}
.close-tip {
font-size: 16px;
margin-bottom: 8px;
}
</style>

View File

@@ -4,13 +4,16 @@ import { checkPlatform } from "@/utils/helper";
import App from "@/App.vue";
import router from "@/router";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import packageJson from "@/../package.json";
// 全局样式
import "@/style/main.scss";
import "@/style/animate.scss";
// 根据设备类型动态添加
// 是否为 Electron
const isElectron = checkPlatform.electron();
// 根据设备类型动态添加
const linkElement = document.createElement("link");
linkElement.rel = "stylesheet";
linkElement.href = isElectron
@@ -19,6 +22,45 @@ linkElement.href = isElectron
document.head.appendChild(linkElement);
document.body.classList.add(isElectron ? "electron" : null);
// 程序重置
window.$cleanAll = (tip = true) => {
if (tip) {
const isConfirmed = window.confirm(`确认要重置${isElectron ? "应用程序" : "该站点"}吗?`);
if (!isConfirmed) return false;
}
// 清除 localStorage
localStorage.clear();
// 清除 IndexedDB 数据库
indexedDB.deleteDatabase("filesDB");
// 清除所有 Cookie
document.cookie.split(";").forEach((cookie) => {
var eqPos = cookie.indexOf("=");
var name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
});
// 清除缓存
if (caches) {
caches.keys().then((names) => {
for (let name of names) caches.delete(name);
});
}
return "已重置应用,请" + (isElectron ? "重启应用" : "刷新页面");
};
// 版权声明
const logoText = import.meta.env.RENDERER_VITE_SITE_TITLE;
const copyrightNotice = `\n\n版本: ${packageJson.version}\n作者: ${packageJson.author}\n作者主页: ${packageJson.home}\nGitHub: ${packageJson.github}`;
console.info(
`%c${logoText} %c ${copyrightNotice}`,
"color:#f55e55;font-size:26px;font-weight:bold;",
"font-size:16px",
);
console.info(
"若站点出现异常,可尝试在下方输入 %c$cleanAll()%c 然后按回车来重置",
"background: #eaeffd;color:#f55e55;padding: 4px 6px;border-radius:8px;",
"background:unset;color:unset;",
);
// 挂载
const app = createApp(App);
// pinia

View File

@@ -80,23 +80,6 @@ const routes = [
},
],
},
// 视频
{
path: "/videos",
name: "videos",
meta: {
title: "视频",
},
component: () => import("@/views/Videos/index.vue"),
redirect: "/videos/list",
children: [
{
path: "list",
name: "video-list",
component: () => import("@/views/Videos/list.vue"),
},
],
},
// 视频播放
{
path: "/videos-player",
@@ -104,7 +87,7 @@ const routes = [
meta: {
title: "视频播放器",
},
component: () => import("@/views/Videos/player.vue"),
component: () => import("@/views/player.vue"),
},
// 评论
{
@@ -210,6 +193,47 @@ const routes = [
},
],
},
// 我的收藏
{
path: "/like",
name: "like",
meta: {
title: "我的收藏",
},
component: () => import("@/views/Like/index.vue"),
beforeEnter: (_, __, next) => {
if (isLogin()) {
next();
} else {
if (typeof $changeLogin !== "undefined") $changeLogin();
$message.error("请登录后使用");
$loadingBar.error();
}
},
redirect: "/like/albums",
children: [
{
path: "albums",
name: "like-albums",
component: () => import("@/views/Like/albums.vue"),
},
{
path: "artists",
name: "like-artists",
component: () => import("@/views/Like/artists.vue"),
},
{
path: "videos",
name: "like-videos",
component: () => import("@/views/Like/videos.vue"),
},
{
path: "playlists",
name: "like-playlists",
component: () => import("@/views/Like/playlists.vue"),
},
],
},
// 本地歌曲
{
path: "/local",
@@ -245,6 +269,23 @@ const routes = [
},
],
},
// 播客
{
path: "/record",
name: "record",
meta: {
title: "播客",
},
component: () => import("@/views/Record/index.vue"),
redirect: "/record/hot",
children: [
{
path: "hot",
name: "record-hot",
component: () => import("@/views/Record/hot.vue"),
},
],
},
// 全局设置
{
path: "/setting",

View File

@@ -2,7 +2,7 @@
import { defineStore } from "pinia";
import { getPersonalFm, setFmToTrash } from "@/api/recommend";
import { changePlayIndex } from "@/utils/Player";
// import { isLogin } from "@/utils/auth";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
const useMusicDataStore = defineStore("musicData", {
@@ -197,6 +197,7 @@ const useMusicDataStore = defineStore("musicData", {
// 私人FM垃圾桶
async setPersonalFmToTrash(id) {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const result = await setFmToTrash(id);
if (result.code === 200) {
$message.success("已移至垃圾桶");

View File

@@ -46,9 +46,6 @@ const useSiteDataStore = defineStore("siteData", {
catList: [], // 普通分类
hqCatList: [], // 精品分类
},
// 封面主题
coverTheme: {},
coverBackground: null,
};
},
getters: {
@@ -205,7 +202,7 @@ const useSiteDataStore = defineStore("siteData", {
$message.error("用户喜欢专辑加载失败");
}
},
// 更改用户喜欢歌手
// 更改用户喜欢视频
async setUserLikeMvs() {
try {
if (!isLogin()) return false;

View File

@@ -10,13 +10,17 @@ const useSiteSettingsStore = defineStore("siteSettings", {
showTaskbarProgress: false, // 显示歌曲任务栏进度
searchHistory: true, // 搜索历史
autoSignIn: true, // 自动签到
showGithub: true,
showSider: true, // 显示侧边栏
// 主题部分
themeType: "dark",
themeAuto: false,
themeTypeName: "red",
themeTypeData: {},
themeAutoCover: true, // 主题色跟随封面
themeAutoCoverType: "secondary",
// 播放部分
playCoverType: "cover", // 播放器样式
songLevel: "exhigh", // 歌曲音质
autoPlay: false, // 程序启动时自动播放
songVolumeFade: true, // 歌曲渐入渐出

View File

@@ -25,6 +25,11 @@ const useSiteStatusStore = defineStore("siteStatus", {
playSeek: 0,
// 是否下一首
hasNextSong: false,
// 封面主题
coverTheme: {},
coverBackground: null,
// 纯净歌词模式
pureLyricMode: false,
};
},
getters: {},
@@ -34,7 +39,7 @@ const useSiteStatusStore = defineStore("siteStatus", {
{
key: "siteStatus",
storage: localStorage,
paths: ["asideMenuCollapsed"],
paths: ["asideMenuCollapsed", "pureLyricMode"],
},
],
});

View File

@@ -1 +0,0 @@
.fade-enter-active,.fade-leave-active{transition:opacity .3s ease-in-out}.fade-enter-from,.fade-leave-to{opacity:0}.fadeDown-enter-active,.fadeDown-leave-active{transition:opacity .3s ease,transform .3s ease}.fadeDown-enter-from,.fadeDown-leave-to{opacity:0;transform:translateY(-10px)}.router-enter-active,.router-leave-active{transition:all .2s ease}.router-enter-from,.router-leave-to{opacity:0;transform:translateX(10px)}.shrink-enter-active,.shrink-leave-active{opacity:1;transform:scale(1);transition:transform .3s,opacity .3s}.shrink-enter-from,.shrink-leave-to{opacity:0;transform:scale(0.4)}.up-enter-active,.up-leave-active{transform:translateY(0);transition:transform .5s cubic-bezier(0.65, 0.05, 0.36, 1)}.up-enter-from,.up-leave-to{transform:translateY(100%)}@keyframes skeleton-loading{0%{background-position:100% 50%}100%{background-position:0 50%}}@keyframes coverRotate{0%{transform:rotate(0deg) scale(1) translateX(0)}50%{transform:rotate(180deg) scale(2) translateX(40%)}100%{transform:rotate(360deg) scale(1) translateX(0)}}

View File

@@ -62,6 +62,20 @@
transform: translateY(100%);
}
// left
.left-enter-active,
.left-leave-active {
opacity: 1;
transform: translateX(0);
transition:
transform 0.3s,
opacity 0.3s;
}
.left-enter-from,
.left-leave-to {
transform: translateX(-100%);
}
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
@@ -83,3 +97,12 @@
transform: rotate(360deg) scale(1) translateX(0);
}
}
@keyframes playerCoverRotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1 +0,0 @@
*{margin:0;padding:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-user-drag:none}html,body,#app{width:100vw;height:100vh;font-family:"HarmonyOS_Regular",sans-serif !important;overflow:hidden;background-color:var(--main-color-bg) !important}.n-text{display:-webkit-box;overflow:hidden;word-break:break-all;-webkit-box-orient:vertical;-webkit-line-clamp:1}.n-image .cover-loading{position:relative;display:flex;align-items:center;justify-content:center;width:100%;height:0;padding-bottom:100%;background-color:var(--n-color);border-radius:8px}.n-image .cover-loading .n-spin-body{position:absolute;height:100%;top:0}.n-image .cover-loading.mv{padding-bottom:56%}.n-image .cover-loading .loading-img{position:absolute;top:0;width:100%;height:100%;opacity:1 !important}.n-scrollbar .n-scrollbar-rail{right:0 !important;z-index:20}.n-message-container{bottom:90px !important}.n-message-container .n-message-wrapper .n-message{border-radius:12px}.n-dropdown{--n-border-radius: 8px !important}.n-skeleton{background:linear-gradient(90deg, var(--n-color-end) 25%, var(--n-color-start) 37%, var(--n-color-end) 63%);background-size:400% 100%;animation:skeleton-loading 1.4s ease infinite}.n-tabs{--n-tab-border-radius: 6px !important}.n-tabs .n-tabs-rail .n-tabs-tab-wrapper .n-tabs-tab:hover{transition:color .3s,background-color .5s ease-in-out,box-shadow .3s}.n-modal{width:60vw;max-width:700px;min-width:min(24rem,100vw);border-radius:8px}.n-modal-mask{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.n-result{min-height:calc(100vh - 178px);display:flex;flex-direction:column;justify-content:center}.n-popover{border-radius:8px !important;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.n-back-top{transition:color .3s cubic-bezier(0.4, 0, 0.2, 1),box-shadow .3s cubic-bezier(0.4, 0, 0.2, 1),background-color .3s cubic-bezier(0.4, 0, 0.2, 1),transform .3s cubic-bezier(0.4, 0, 0.2, 1)}.n-back-top:active{transform:scale(0.9)}.n-data-table{--n-merged-th-color: transparent !important;--n-merged-td-color: transparent !important;--n-merged-border-color: transparent !important;--n-merged-th-color-hover: transparent !important;--n-merged-td-color-hover: transparent !important;--n-merged-td-color-striped: transparent !important}.n-data-table .n-data-table__pagination{margin-top:40px;margin-bottom:30px;justify-content:center}.n-image-preview-container .n-image-preview-overlay{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.n-switch.n-switch--active .n-switch__rail{background-color:var(--main-second-color)}

View File

@@ -12,7 +12,6 @@ body,
height: 100vh;
font-family: "HarmonyOS_Regular", sans-serif !important;
overflow: hidden;
background-color: var(--main-color-bg) !important;
}
// n-text
@@ -77,6 +76,11 @@ body,
--n-border-radius: 8px !important;
}
// n-notification
.n-notification {
--n-border-radius: 8px !important;
}
// n-skeleton
.n-skeleton {
background: linear-gradient(
@@ -164,12 +168,3 @@ body,
backdrop-filter: blur(16px);
}
}
// n-switch
.n-switch {
&.n-switch--active {
.n-switch__rail {
background-color: var(--main-second-color);
}
}
}

View File

@@ -1,5 +1,5 @@
import { Howl, Howler } from "howler";
import { musicData, siteStatus, siteSettings, siteData } from "@/stores";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { getSongUrl, getSongLyric, songScrobble } from "@/api/song";
import { checkPlatform, getLocalCoverData } from "@/utils/helper";
import { decode as base642Buffer } from "@/utils/base64";
@@ -63,8 +63,16 @@ export const initPlayer = async (playNow = false) => {
status.playUseOtherSource = true;
createPlayer(url);
} else {
isPlayEnd = true;
status.playUseOtherSource = false;
changePlayIndex("next", true);
// 是否为最后一首
if (playIndex === playList.length - 1) {
status.playState = false;
$message.warning("当前列表歌曲无法播放,请更换歌曲");
} else {
$message.error("该歌曲暂无音源,跳至下一首");
changePlayIndex("next", true);
}
}
}
// 下一曲
@@ -153,7 +161,6 @@ const getFromUnblockMusic = async (data, status, playNow) => {
// 调用解灰
let musicUrl = await electron.ipcRenderer.invoke("getMusicNumUrl", JSON.stringify(data));
if (!musicUrl) {
$message.error("该歌曲暂无音源");
status.playLoading = false;
return null;
}
@@ -331,7 +338,7 @@ export const changePlayIndex = async (type = "next", play = false) => {
if (playMode === "fm") {
await music.setPersonalFm(true);
// 渐出音乐
if (!isPlayEnd) await fadePlayOrPause("pause");
if (!isPlayEnd) fadePlayOrPause("pause");
// 初始化播放器
initPlayer(play);
return true;
@@ -366,9 +373,8 @@ export const changePlayIndex = async (type = "next", play = false) => {
const songData = playList?.[music.playIndex];
if (songData) {
music.playSongData = songData;
console.log(songData);
// 渐出音乐
if (!isPlayEnd) await fadePlayOrPause("pause");
if (!isPlayEnd) fadePlayOrPause("pause");
// 初始化播放器
initPlayer(play);
} else {
@@ -424,34 +430,25 @@ export const addSongToNext = (data, play = false) => {
export const fadePlayOrPause = (type = "play") => {
const settings = siteSettings();
const duration = settings.songVolumeFade ? 300 : 0;
return new Promise((resolve) => {
const music = musicData();
// 渐入
if (type === "play") {
if (player?.playing()) {
resolve();
return;
}
player?.play();
// 更新播放进度
setAllInterval();
player?.once("play", () => {
player?.fade(0, music.playVolume, duration);
player?.once("fade", () => {
resolve();
});
});
}
// 渐出
else if (type === "pause") {
player?.fade(music.playVolume, 0, duration);
player?.once("fade", () => {
player?.pause();
cleanAllInterval();
resolve();
});
}
});
const music = musicData();
// 渐入
if (type === "play") {
if (player?.playing()) return;
player?.play();
// 更新播放进度
setAllInterval();
player?.once("play", () => {
player?.fade(0, music.playVolume, duration);
});
}
// 渐出
else if (type === "pause") {
player?.fade(music.playVolume, 0, duration);
player?.once("fade", () => {
player?.pause();
cleanAllInterval();
});
}
};
/**
@@ -459,7 +456,7 @@ export const fadePlayOrPause = (type = "play") => {
*/
export const playOrPause = async () => {
const status = player?.playing();
await fadePlayOrPause(status ? "pause" : "play");
fadePlayOrPause(status ? "pause" : "play");
};
/**
@@ -489,7 +486,7 @@ export const checkPlayer = () => {
* 停止播放器
*/
export const soundStop = () => {
player?.stop();
// player?.stop();
// setSeek();
Howler.unload();
};
@@ -646,10 +643,10 @@ const initMediaSession = async (data, islocal, cover) => {
});
// 按键关联
navigator.mediaSession.setActionHandler("play", async () => {
await fadePlayOrPause("play");
fadePlayOrPause("play");
});
navigator.mediaSession.setActionHandler("pause", async () => {
await fadePlayOrPause("pause");
fadePlayOrPause("pause");
});
navigator.mediaSession.setActionHandler("previoustrack", () => {
changePlayIndex("prev", true);
@@ -667,17 +664,17 @@ const initMediaSession = async (data, islocal, cover) => {
* @returns {string} - 主要颜色的RGB十六进制表示
*/
const getColorMainColor = async (islocal, cover) => {
const data = siteData();
const status = siteStatus();
try {
// 获取封面图像的URL
if (!cover) return (data.coverColor = {});
if (!cover) return (status.coverTheme = {});
const colorUrl = islocal ? cover : cover.s;
// 获取渐变色背景
const gradientColor = await getCoverGradient(colorUrl);
data.coverBackground = gradientColor;
status.coverBackground = gradientColor;
} catch (error) {
console.error("封面颜色获取失败:", error);
data.coverColor = {};
status.coverTheme = {};
}
};

View File

@@ -4,7 +4,7 @@ import {
Hct,
Score,
} from "@material/material-color-utilities";
import { siteData, siteSettings } from "@/stores";
import { siteSettings, siteStatus } from "@/stores";
import { getGradientFromPalette, argb2Rgb, rgb2Argb } from "@/utils/color-utils";
import { chunk } from "@/utils/helper";
import ColorThief from "colorthief";
@@ -45,7 +45,7 @@ export const getCoverGradient = (coverSrc) => {
*/
const calcAccentColor = (dom) => {
// pinia
const data = siteData();
const status = siteStatus();
const settings = siteSettings();
// 创建一个用于提取颜色的 canvas
const canvas = document.createElement("canvas");
@@ -77,11 +77,11 @@ const calcAccentColor = (dom) => {
const top = ranked[0];
const theme = themeFromSourceColor(top);
// 错误 error, 中性 neutral, 中性的变体 neutralVariant, 主要的 primary, 二次 secondary, 三级 tertiary
const variant = window.accentColorVariant ?? "secondary";
const variant = settings.themeAutoCoverType;
// 更新主题色
data.coverTheme = {
light: {
light: getAccentColor(theme.schemes.dark[variant]),
status.coverTheme = {
dark: {
dark: getAccentColor(theme.schemes.dark[variant]),
primary: getAccentColor(
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 100).toInt(),
),
@@ -92,11 +92,14 @@ const calcAccentColor = (dom) => {
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 15).toInt(),
),
bg: getAccentColor(
Hct.from(theme.palettes.secondary.hue, theme.palettes.secondary.chroma, 90).toInt(),
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 90).toInt(),
),
mainBg: getAccentColor(
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 10).toInt(),
),
},
dark: {
dark: getAccentColor(theme.schemes.dark[variant]),
light: {
light: getAccentColor(theme.schemes.light[variant]),
primary: getAccentColor(
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 20).toInt(),
),
@@ -107,16 +110,16 @@ const calcAccentColor = (dom) => {
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 90).toInt(),
),
bg: getAccentColor(
Hct.from(theme.palettes.secondary.hue, theme.palettes.secondary.chroma, 20).toInt(),
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 20).toInt(),
),
mainBg: getAccentColor(
Hct.from(theme.palettes.secondary.hue, theme.palettes.secondary.chroma, 10).toInt(),
Hct.from(theme.palettes[variant].hue, theme.palettes[variant].chroma, 100).toInt(),
),
},
};
// 尝试更新主题色
if (typeof $changeThemeColor !== "undefined" && settings.themeAutoCover) {
$changeThemeColor(data.coverTheme, settings.themeAutoCover);
$changeThemeColor(status.coverTheme, settings.themeAutoCover);
}
};
@@ -125,21 +128,22 @@ const calcAccentColor = (dom) => {
*/
const useGreyAccentColor = () => {
// pinia
const data = siteData();
data.coverTheme = {
light: {
light: getAccentColor(rgb2Argb(120, 120, 120)),
const status = siteStatus();
status.coverTheme = {
dark: {
dark: getAccentColor(rgb2Argb(120, 120, 120)),
primary: getAccentColor(rgb2Argb(250, 250, 250)),
shade: getAccentColor(rgb2Argb(40, 40, 40)),
shadeTwo: getAccentColor(rgb2Argb(20, 20, 20)),
bg: getAccentColor(rgb2Argb(190, 190, 190)),
mainBg: getAccentColor(rgb2Argb(16, 16, 20)),
},
dark: {
dark: getAccentColor(rgb2Argb(150, 150, 150)),
light: {
light: getAccentColor(rgb2Argb(150, 150, 150)),
primary: getAccentColor(rgb2Argb(10, 10, 10)),
shade: getAccentColor(rgb2Argb(210, 210, 210)),
shadeTwo: getAccentColor(rgb2Argb(255, 255, 255)),
bg: getAccentColor(rgb2Argb(50, 50, 50)),
bg: getAccentColor(rgb2Argb(24, 24, 28)),
mainBg: getAccentColor(rgb2Argb(11, 11, 11)),
},
};

View File

@@ -17,7 +17,7 @@ const formRules = () => {
// 邮箱验证
emailRule: {
required: true,
validator(rule, value) {
validator(_, value) {
if (!value) {
return new Error("请输入电子邮箱");
} else if (
@@ -35,14 +35,10 @@ const formRules = () => {
mobileRule: {
key: "phone",
required: true,
validator(rule, value) {
validator(_, value) {
if (!value) {
return new Error("请输入手机号码");
} else if (
!/^(?:(?:\+|00)86)?1(?:(?:3[\d])|(?:4[5-79])|(?:5[0-35-9])|(?:6[5-7])|(?:7[0-8])|(?:8[\d])|(?:9[1589]))\d{8}$/.test(
value,
)
) {
} else if (!/^(?:(?:\+|00)86)?1[3-9]\d{9}$/.test(value)) {
return new Error("请输入正确的手机号码");
}
return true;
@@ -52,5 +48,4 @@ const formRules = () => {
};
};
// 导出所有规则
export { formRules };

View File

@@ -18,6 +18,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
const imgUrl =
v &&
(v.picUrl ||
v.coverUrl ||
v.coverImgUrl ||
v.cover ||
(v.album && v.album.picUrl) ||
@@ -46,6 +47,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
updateTime: v.updateTime || v.trackNumberUpdateTime,
description: v.description,
tags: v.tags || v.algTags,
userId: v.userId,
};
// 歌曲
case "song":
@@ -57,7 +59,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
cover,
coverSize,
mv: v.mv,
alia: v.alias?.[0] || v.transNames?.[0],
alia: v.alia?.[0] || v.alias?.[0] || v.transNames?.[0],
fee: v.fee,
pc: v.pc,
size: v.size,
@@ -97,14 +99,14 @@ const formatData = (data, type = "playlist", noTracks = false) => {
// mv
case "mv":
return {
id: v.id,
name: v.name,
artists: v.artists,
id: v.id || v.vid,
name: v.name || v.title,
artists: v.artists || v.creator,
desc: v.copywriter,
cover,
coverSize: getCoverUrl(v.imgurl16v9 || v.picUrl || v.cover, "464y260"),
duration: v.duration,
playCount: v.playCount,
coverSize: getCoverUrl(cover, "464y260"),
duration: v.duration || v.durationms,
playCount: v.playCount || v.playTime,
};
default:
return null;

View File

@@ -1,8 +1,11 @@
import { checkPlatform } from "@/utils/helper";
import { playOrPause, changePlayIndex } from "@/utils/Player";
import { siteStatus } from "@/stores";
import "vue-slider-component/theme/default.css";
/**
* 全局事件
* @param {import('vue-router').Router} router - router
*/
const globalEvents = (router) => {
if (!checkPlatform.electron()) return false;
// 显示播放器

View File

@@ -4,18 +4,23 @@ import { musicData } from "@/stores";
/**
* 全局快捷键监听
* @param {KeyboardEvent} e - 键盘事件对象
* @param {import('vue-router').Router} router - router
* @returns {boolean} - 如果事件对象不存在则返回false
*/
const globalShortcut = (e) => {
const globalShortcut = (e, router) => {
if (!e) return false;
e.preventDefault();
e.stopPropagation();
// 播放或暂停
if (e.ctrlKey && e.code === "Space") playOrPause();
if (e.code === "Space") {
if (e.target.tagName === "INPUT") return false;
if (router.currentRoute.value.name === "videos-player") return false;
playOrPause();
}
// 调整音量
if (e.ctrlKey && (e.code === "ArrowUp" || e.code === "ArrowDown")) {
if (e.code === "ArrowUp" || e.code === "ArrowDown") {
const music = musicData();
const volume = music.playVolume;
const delta = e.code === "ArrowUp" ? 0.1 : -0.1;

View File

@@ -94,20 +94,20 @@ export const getLocalCoverData = async (path, isAlbum = false) => {
/**
* 内容复制
*/
export const copyData = async (data) => {
export const copyData = async (data, info) => {
try {
const isElectron = checkPlatform.electron();
// electron
if (isElectron) {
const result = await electron.ipcRenderer.invoke("copyData", data);
result ? $message.success("复制成功") : $message.error("复制失败");
result ? $message.success(`${info || "复制"}成功`) : $message.error(`${info || "复制"}失败`);
}
// 浏览器端
else {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(data);
$message.success("复制成功");
$message.success(`${info || "复制"}成功`);
} catch (error) {
console.error("复制出错:", error);
$message.error("复制失败");
@@ -120,7 +120,9 @@ export const copyData = async (data) => {
textArea.select();
try {
const successful = document.execCommand("copy");
successful ? $message.success("复制成功") : $message.error("复制失败");
successful
? $message.success(`${info || "复制"}成功`)
: $message.error(`${info || "复制"}失败`);
} catch (err) {
console.error("复制出错:", err);
$message.error("复制失败");
@@ -338,3 +340,18 @@ export const downloadFile = async (data, song, path = null) => {
return false;
}
};
/**
* 将字节数格式化为可读的大小字符串。
* @param {number} bytes - 要格式化的字节数
* @param {number} [decimals=2] - 小数点位数
* @returns {string} - 格式化后的大小字符串(例如,"10 KB"
*/
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 K";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["K", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};

View File

@@ -21,6 +21,10 @@ axios.interceptors.request.use(
if (!request.noCookie && (isLogin() || getCookie("MUSIC_U") !== null)) {
request.params.cookie = `MUSIC_U=${getCookie("MUSIC_U")};`;
}
// 去除 cookie
if (request.noCookie) {
request.params.noCookie = true;
}
// 附加 realIP
if (!checkPlatform.electron()) request.params.realIP = "116.25.146.177";
// 发送请求

View File

@@ -27,6 +27,7 @@
<script setup>
import { useRouter } from "vue-router";
import { getArtistSongs } from "@/api/artist";
import { getSongDetail } from "@/api/song";
import formatData from "@/utils/formatData";
const router = useRouter();
@@ -41,7 +42,14 @@ const getArtistHotData = async (id) => {
artistHotSongs.value = null;
// 获取热门歌曲
const result = await getArtistSongs(id);
artistHotSongs.value = formatData(result.hotSongs, "song");
// 处理数据
if (result.hotSongs?.[0]?.al?.picUrl) {
artistHotSongs.value = formatData(result.hotSongs, "song");
return true;
}
const ids = result.hotSongs.map((song) => song.id).join(",");
const songsDetail = await getSongDetail(ids);
artistHotSongs.value = formatData(songsDetail.songs, "song");
} catch (error) {
console.error("获取歌手热门数据失败:", error);
}

View File

@@ -8,8 +8,7 @@
v-for="item in artistInitials"
:key="item"
:bordered="false"
:type="item.key == artistInitialChoose ? 'primary' : 'default'"
class="tag"
:class="['tag', { choose: item.key == artistInitialChoose }]"
round
@click="artistInitialChange(item.key)"
>
@@ -21,10 +20,12 @@
<n-tag
v-for="(item, index) in artistTypeNames"
:key="item"
:class="['tag', item.length > 2 ? 'hidden' : 'show']"
:class="[
'tag',
item.length > 2 ? 'hidden' : 'show',
{ choose: index == artistTypeNamesChoose },
]"
:bordered="false"
:type="index == artistTypeNamesChoose ? 'primary' : 'default'"
class="tag"
round
@click="artistTypeChange(index)"
>
@@ -199,6 +200,10 @@ onMounted(() => {
&:active {
transform: scale(0.95);
}
&.choose {
background-color: var(--main-second-color);
color: var(--main-color);
}
}
.category {
margin-top: 18px;

View File

@@ -51,21 +51,8 @@ watch(
font-weight: bold;
}
.tabs {
position: sticky;
top: 20px;
margin-bottom: 20px;
z-index: 2;
&::after {
content: "";
position: absolute;
top: -20px;
left: -25px;
width: calc(100% + 50px);
height: calc(100% + 40px);
background-color: var(--n-color);
backdrop-filter: blur(40px);
z-index: -1;
}
}
}
</style>

View File

@@ -275,6 +275,7 @@ onMounted(() => {
}
}
.tag {
background-color: var(--n-action-color);
transition:
transform 0.3s,
background-color 0.3s,

31
src/views/Like/albums.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<div class="like-albums">
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.albums?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeAlbumsData" type="album" />
</div>
<n-empty
v-else
description="当前暂无收藏专辑"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理专辑数据
const likeAlbumsData = computed(() => {
return formatData(userLikeData.value.albums, "album");
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="like-artists">
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.artists?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeArtistsData" type="artist" columns="3 s:4 m:5 l:6" />
</div>
<n-empty
v-else
description="当前暂无收藏歌手"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理歌手数据
const likeArtistsData = computed(() => {
return formatData(userLikeData.value.artists, "artist");
});
</script>

50
src/views/Like/index.vue Normal file
View File

@@ -0,0 +1,50 @@
<template>
<div class="like">
<n-h1 class="title">我的收藏</n-h1>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="like-albums"> 专辑 </n-tab>
<n-tab name="like-playlists"> 歌单 </n-tab>
<n-tab name="like-artists"> 歌手 </n-tab>
<n-tab name="like-videos"> 视频 </n-tab>
</n-tabs>
<!-- 路由页面 -->
<router-view v-slot="{ Component }">
<keep-alive>
<Transition name="router" mode="out-in">
<component :is="Component" />
</Transition>
</keep-alive>
</router-view>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
const router = useRouter();
// 默认选中
const tabValue = ref(router.currentRoute.value?.name ?? "like-albums");
// 标签页切换
const tabChange = (val) => {
const routerPath = val.replace(/^like-/, "");
router.push({
path: `/like/${routerPath}`,
});
};
</script>
<style lang="scss" scoped>
.like {
.title {
margin: 20px 0;
font-size: 36px;
font-weight: bold;
}
.tabs {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="like-playlists">
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.playlists?.length" class="pl-list">
<!-- 分类 -->
<n-space class="type">
<n-tag
v-for="(item, index) in ['我创建的', '我收藏的']"
:key="index"
:bordered="false"
:type="index === plTypeChoose ? 'primary' : 'default'"
class="tag"
round
@click="plTypeChange(index)"
>
{{ item }}
</n-tag>
</n-space>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="plTypeChoose === 0" class="list">
<MainCover
:data="likePlaylistsData.filter((playlist) => playlist.userId === userData?.userId)"
/>
</div>
<div v-else class="list">
<MainCover
:data="likePlaylistsData.filter((playlist) => playlist.userId !== userData?.userId)"
/>
</div>
</Transition>
</div>
<n-empty
v-else
description="当前暂无收藏歌单"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { useRouter } from "vue-router";
import formatData from "@/utils/formatData";
const router = useRouter();
const data = siteData();
const { userData, userLikeData } = storeToRefs(data);
// 默认分类
const plTypeChoose = ref(Number(router.currentRoute.value.query?.type) || 0);
// 处理视频数据
const likePlaylistsData = computed(() => {
return formatData(userLikeData.value.playlists);
});
// 默认分类变化
const plTypeChange = (type) => {
router.push({
path: "/like/playlists",
query: {
type,
},
});
};
// 监听路由参数变化
watch(
() => router.currentRoute.value,
(val) => {
if (val.name === "like-playlists") {
plTypeChoose.value = Number(val.query?.type) || 0;
}
},
);
</script>
<style lang="scss" scoped>
.like-playlists {
.type {
margin-bottom: 20px;
.tag {
font-size: 13px;
padding: 0 16px;
line-height: 0;
cursor: pointer;
transition:
transform 0.3s,
background-color 0.3s,
color 0.3s;
&:hover {
background-color: var(--main-second-color);
color: var(--main-color);
}
&:active {
transform: scale(0.95);
}
}
}
}
</style>

31
src/views/Like/videos.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<div class="like-videos">
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.mvs?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeVideosData" columns="1 s:2 m:3 l:4 xl:5" type="mv" />
</div>
<n-empty
v-else
description="当前暂无收藏视频"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理视频数据
const likeVideosData = computed(() => {
return formatData(userLikeData.value.mvs, "mv");
});
</script>

View File

@@ -1,6 +1,6 @@
<!-- 专辑页面 -->
<template>
<div v-if="albumId" class="album">
<div v-if="albumId && Number(albumId)" class="album">
<Transition name="fade" mode="out-in">
<div v-if="albumDetail && Object.keys(albumDetail)?.length" class="detail">
<div class="cover">
@@ -32,7 +32,12 @@
</n-text>
<n-text v-if="albumDetail.alia" class="alia" depth="3">{{ albumDetail.alia }}</n-text>
<div v-if="albumDetail.artists" class="creator">
<n-text v-for="(item, index) in albumDetail.artists" :key="index" class="ar">
<n-text
v-for="(item, index) in albumDetail.artists"
:key="index"
class="ar"
@click="router.push(`/artist?id=${item.id}`)"
>
{{ item.name }}
</n-text>
</div>
@@ -102,6 +107,7 @@
:disabled="albumData === 'empty'"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@@ -113,7 +119,7 @@
</n-icon>
</template>
</n-button>
<n-button size="large" round strong secondary @click="likeOrDislike(albumId)">
<n-button size="large" tag="div" round strong secondary @click="likeOrDislike(albumId)">
<template #icon>
<n-icon>
<SvgIcon
@@ -124,7 +130,7 @@
{{ isLikeOrDislike(albumId) ? "收藏专辑" : "取消收藏" }}
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" circle strong secondary>
<n-button size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
@@ -255,26 +261,30 @@ const playAllSongs = async () => {
if (!albumData.value) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = albumData.value.slice();
// 是否处于专辑内
const songId = playSongData.value?.id;
const isHas = albumData.value.some((song) => song.id === songId);
const existingIndex = albumData.value.findIndex((song) => song.id === songId);
// 若不处于
if (!isHas) {
// 更改模式
playMode.value = "normal";
playList.value = albumData.value;
if (existingIndex === -1 || !songId) {
console.log("不在专辑内");
playSongData.value = albumData.value[0];
playIndex.value = 0;
// 初始化播放器
initPlayer(true);
} else {
console.log("处于专辑内");
playSongData.value = albumData.value[existingIndex];
playIndex.value = existingIndex;
// 播放
await fadePlayOrPause();
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
// 本地歌曲模糊搜索
// 歌曲模糊搜索
const localSearch = debounce((val) => {
const searchValue = val?.trim();
// 是否为空

View File

@@ -114,6 +114,7 @@
:disabled="playListData === 'empty'"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@@ -128,6 +129,7 @@
<n-button
v-if="isUserPLayList"
size="large"
tag="div"
round
strong
secondary
@@ -140,7 +142,15 @@
</template>
编辑歌单
</n-button>
<n-button v-else size="large" round strong secondary @click="likeOrDislike(playlistId)">
<n-button
v-else
size="large"
tag="div"
round
strong
secondary
@click="likeOrDislike(playlistId)"
>
<template #icon>
<n-icon>
<SvgIcon
@@ -153,7 +163,7 @@
{{ isLikeOrDislike(playlistId) ? "收藏歌单" : "取消收藏" }}
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" circle strong secondary>
<n-button size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
@@ -342,27 +352,30 @@ const playAllSongs = async () => {
if (!playListData.value) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = playListData.value.slice();
// 是否处于歌单内
const songId = music.getPlaySongData?.id;
const isHas = playListData.value.some((song) => song.id === songId);
const existingIndex = playListData.value.findIndex((song) => song.id === songId);
// 若不处于
if (!isHas || !songId) {
if (existingIndex === -1 || !songId) {
console.log("不在歌单内");
// 更改模式
playMode.value = "normal";
playList.value = playListData.value.slice();
playSongData.value = playListData.value[0];
playIndex.value = 0;
// 初始化播放器
initPlayer(true);
} else {
console.log("处于歌单内");
playSongData.value = playListData.value[existingIndex];
playIndex.value = existingIndex;
// 播放
await fadePlayOrPause();
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
// 本地歌曲模糊搜索
// 歌曲模糊搜索
const localSearch = debounce((val) => {
const searchValue = val?.trim();
// 是否为空

View File

@@ -2,6 +2,30 @@
<template>
<div class="local">
<n-h1 class="title">本地歌曲</n-h1>
<!-- 数据统计 -->
<n-space class="num">
<!-- 总数 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="music-note" />
</n-icon>
<n-number-animation :from="0" :to="localSongList ? localSongList.length : 0" />
首歌曲
</div>
<!-- 空间 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="storage" />
</n-icon>
占用
<n-number-animation
:from="0"
:to="Number((allSongStorage / 1024).toFixed(2))"
:precision="2"
/>
GB
</div>
</n-space>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="local-songs"> 歌曲 </n-tab>
@@ -157,6 +181,7 @@ const isLoading = ref(false);
// 本地歌曲
const localSongList = ref([]);
const allSongStorage = ref(0);
// 默认选中
const tabValue = ref(router.currentRoute.value?.name ?? "local-songs");
@@ -187,7 +212,7 @@ const getLocalSongList = async () => {
// 获取全部路径歌曲
const getAllPathMusic = async () => {
console.log("调用");
console.log("获取全部路径歌曲");
// 先获取
getLocalSongList();
// 全部路径
@@ -225,6 +250,8 @@ const getAllPathMusic = async () => {
// 更新本地歌曲
await indexedDB.setfilesDB("localSongList", uniqueSongsArray.slice());
getLocalSongList();
// 统计歌曲总容量
allSongStorage.value = uniqueSongsArray.reduce((acc, v) => (acc += Number(v.size)), 0);
} catch (error) {
console.error("获取歌曲内容时出错:", error);
} finally {
@@ -258,7 +285,7 @@ const changeLocalPath = async (isDel = false) => {
}
};
// 本地歌曲模糊搜索
// 歌曲模糊搜索
const localSearch = debounce((val) => {
const searchValue = val?.trim();
// 是否为空
@@ -293,10 +320,21 @@ onBeforeMount(async () => {
<style lang="scss" scoped>
.local {
.title {
margin: 20px 0;
margin: 20px 0 12px 0;
font-size: 36px;
font-weight: bold;
}
.num {
margin-bottom: 20px;
.num-item {
display: flex;
flex-direction: row;
align-items: center;
.n-icon {
margin-right: 4px;
}
}
}
.tabs {
margin-bottom: 20px;
.search {

4
src/views/Record/hot.vue Normal file
View File

@@ -0,0 +1,4 @@
<!-- 播客 - 热门 -->
<template>
<div class="record-hot">114514</div>
</template>

View File

@@ -0,0 +1,4 @@
<!-- 播客 -->
<template>
<div class="record">新建文件夹...</div>
</template>

View File

@@ -2,21 +2,18 @@
<template>
<div class="search">
<template v-if="searchKeywords">
<!-- 固定 -->
<div class="fixed">
<div class="title">
<n-text class="key">{{ searchKeywords }}</n-text>
<n-text depth="3">的相关搜索</n-text>
</div>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="line" @update:value="tabChange">
<n-tab name="sea-songs"> 单曲 </n-tab>
<n-tab name="sea-artists"> 歌手 </n-tab>
<n-tab name="sea-albums"> 专辑 </n-tab>
<n-tab name="sea-playlists"> 歌单 </n-tab>
<n-tab name="sea-videos"> 视频 </n-tab>
</n-tabs>
<div class="title">
<n-text class="key">{{ searchKeywords }}</n-text>
<n-text depth="3">的相关搜索</n-text>
</div>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="line" @update:value="tabChange">
<n-tab name="sea-songs"> 单曲 </n-tab>
<n-tab name="sea-artists"> 歌手 </n-tab>
<n-tab name="sea-albums"> 专辑 </n-tab>
<n-tab name="sea-playlists"> 歌单 </n-tab>
<n-tab name="sea-videos"> 视频 </n-tab>
</n-tabs>
<!-- 路由页面 -->
<router-view v-slot="{ Component }">
<keep-alive>
@@ -69,38 +66,20 @@ watch(
<style lang="scss" scoped>
.search {
.fixed {
position: sticky;
top: 34px;
background-color: var(--n-color);
z-index: 2;
&::after {
content: "";
position: absolute;
bottom: -20px;
left: -25px;
width: calc(100% + 50px);
height: calc(100% + 54px);
background-color: var(--n-color);
backdrop-filter: blur(40px);
z-index: -1;
.title {
margin: 10px 0;
font-size: 22px;
.key {
font-size: 36px;
font-weight: bold;
margin-right: 8px;
}
.title {
margin: 10px 0;
font-size: 22px;
.key {
font-size: 36px;
font-weight: bold;
margin-right: 8px;
}
.n-text {
display: inline;
}
}
.tabs {
margin-bottom: 20px;
.n-text {
display: inline;
}
}
.tabs {
margin-bottom: 20px;
}
}
</style>

View File

@@ -1,9 +1,17 @@
<!-- 全局设置 -->
<template>
<div class="setting">
<div :class="{ setting: true, 'use-cover': themeAutoCover }">
<n-h1 class="title">
<n-text>全局设置</n-text>
<n-text class="version" depth="3">v&nbsp;{{ packageJson.version }}</n-text>
<div class="copyright" @click="jump">
<div class="author">
<n-icon depth="3" size="18">
<SvgIcon icon="github" />
</n-icon>
<n-text class="author-text" depth="3">{{ packageJson.author }}</n-text>
</div>
<n-text class="version" depth="3">v&nbsp;{{ packageJson.version }}</n-text>
</div>
</n-h1>
<!-- 导航栏 -->
<n-tabs
@@ -24,7 +32,7 @@
ref="setScrollRef"
:style="{
height: `calc(100vh - ${
Object.keys(music.getPlaySongData)?.length && status.showPlayBar ? 328 : 248
Object.keys(music.getPlaySongData)?.length && showPlayBar ? 328 : 248
}px)`,
}"
class="all-set"
@@ -63,6 +71,13 @@
"
/>
</n-card>
<n-card class="set-item">
<div class="name">
开启侧边栏
<n-text class="tip">将导航栏放于侧边显示可展开或收起</n-text>
</div>
<n-switch v-model:value="showSider" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">显示搜索历史</div>
<n-switch v-model:value="searchHistory" :round="false" />
@@ -77,7 +92,7 @@
<n-card class="set-item">
<div class="name">
<div class="dev">
主题色跟随歌曲封面
全局动态取色
<n-tag :bordered="false" round size="small" type="warning">
开发中
<template #icon>
@@ -92,9 +107,53 @@
<n-switch
v-model:value="themeAutoCover"
:round="false"
:disabled="Object.keys(coverTheme)?.length === 0"
@update:value="themeAutoCoverChange"
/>
</n-card>
<n-card class="set-item">
<div class="name">
<div class="dev">
全局动态取色类别
<n-tag :bordered="false" round size="small" type="warning">
开发中
<template #icon>
<n-icon>
<SvgIcon icon="code" />
</n-icon>
</template>
</n-tag>
</div>
<n-text class="tip">将在下一首播放或刷新时生效不建议更改</n-text>
</div>
<n-select
v-model:value="themeAutoCoverType"
:disabled="!themeAutoCover"
:options="[
{
label: '中性',
value: 'neutral',
},
{
label: '中性变体',
value: 'neutralVariant',
},
{
label: '主要',
value: 'primary',
},
{
label: '次要',
value: 'secondary',
},
{
label: '次次要',
value: 'tertiary',
},
]"
class="set"
/>
</n-card>
</div>
<!-- 系统 -->
<div v-if="checkPlatform.electron()" class="set-type">
@@ -140,9 +199,11 @@
<n-card class="set-item">
<div class="name">
启动时自动播放
<n-text class="tip">程序启动时自动播放上次歌曲</n-text>
<n-text class="tip">
{{ checkPlatform.electron() ? "程序启动时自动播放上次歌曲" : "客户端独占功能" }}
</n-text>
</div>
<n-switch v-model:value="autoPlay" :round="false" />
<n-switch v-model:value="autoPlay" :disabled="!checkPlatform.electron()" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">记忆上次播放位置</div>
@@ -168,6 +229,26 @@
</div>
<n-switch v-model:value="bottomLyricShow" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">
播放器样式
<n-text class="tip"> 播放器左侧区域样式 </n-text>
</div>
<n-select
v-model:value="playCoverType"
:options="[
{
label: '封面模式',
value: 'cover',
},
{
label: '唱片模式',
value: 'record',
},
]"
class="set"
/>
</n-card>
<n-card class="set-item">
<div class="name">
播放背景样式
@@ -331,7 +412,7 @@
</template>
</n-tag>
</div>
<n-text class="tip">开启后可能会造成卡顿等性能问题</n-text>
<n-text class="tip">可能会造成卡顿等性能问题请确保显卡为 GTX 2060 及以上</n-text>
</div>
<n-switch v-model:value="showYrcAnimation" :disabled="!showYrc" :round="false" />
</n-card>
@@ -386,6 +467,10 @@
<!-- 其他 -->
<div class="set-type">
<n-h3 prefix="bar"> 其他 </n-h3>
<n-card class="set-item">
<div class="name">是否显示 GitHub 仓库按钮</div>
<n-switch v-model:value="showGithub" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">
默认加载数量
@@ -426,20 +511,22 @@
<script setup>
import { storeToRefs } from "pinia";
import { useOsTheme } from "naive-ui";
import { siteSettings, siteStatus, musicData, siteData } from "@/stores";
import { siteSettings, siteStatus, musicData } from "@/stores";
import { checkPlatform } from "@/utils/helper";
import debounce from "@/utils/debounce";
import packageJson from "@/../package.json";
const music = musicData();
const status = siteStatus();
const data = siteData();
const settings = siteSettings();
const { showPlayBar, coverTheme } = storeToRefs(status);
const {
themeType,
themeTypeName,
themeAuto,
themeAutoCover,
themeAutoCoverType,
showSider,
closeTip,
closeType,
loadSize,
@@ -464,6 +551,8 @@ const {
bottomLyricShow,
downloadPath,
memorySeek,
showGithub,
playCoverType,
} = storeToRefs(settings);
// 标签页数据
@@ -515,9 +604,9 @@ const songLevelData = {
// 封面自动跟随变化
const themeAutoCoverChange = (val) => {
typeof $changeThemeColor !== "undefined" && val
? $changeThemeColor(data.coverTheme, val)
: $changeThemeColor(themeTypeName.value, val);
if ($changeThemeColor !== "undefined" && Object.keys(coverTheme.value)?.length) {
$changeThemeColor(val ? coverTheme.value : themeTypeName.value, val);
}
};
// 标签页切换
@@ -549,6 +638,11 @@ const choosePath = async () => {
if (selectedDir) downloadPath.value = selectedDir;
};
// 跳转
const jump = () => {
window.open(packageJson.github);
};
// 程序重置
const resetApp = () => {
$dialog.warning({
@@ -557,8 +651,11 @@ const resetApp = () => {
positiveText: "重置",
negativeText: "取消",
onPositiveClick: () => {
if (typeof $cleanAll === "undefined") {
return $message.error("重置操作出现错误,请重试");
}
$cleanAll(false);
$message.success("重置成功,正在重启");
localStorage.clear();
setTimeout(() => {
if (checkPlatform.electron()) {
electron.ipcRenderer.send("window-relaunch");
@@ -583,11 +680,29 @@ const resetApp = () => {
margin: 20px 0;
font-size: 36px;
font-weight: bold;
.version {
.copyright {
display: flex;
flex-direction: row;
align-items: center;
margin-left: 12px;
margin-bottom: 6px;
font-size: 18px;
font-size: 16px;
font-weight: normal;
cursor: pointer;
.author {
display: flex;
align-items: center;
&::after {
content: "/";
transform: translateY(2px);
font-size: 14px;
margin: 0 6px;
opacity: 0.6;
}
.author-text {
margin-left: 6px;
}
}
}
}
.n-tabs {
@@ -635,5 +750,14 @@ const resetApp = () => {
}
}
}
&.use-cover {
.n-switch {
&.n-switch--active {
:deep(.n-switch__rail) {
background-color: var(--main-second-color);
}
}
}
}
}
</style>

View File

@@ -1,3 +0,0 @@
<template>
<div class="videos">114514</div>
</template>

View File

@@ -1,3 +0,0 @@
<template>
<div class="videos-list">111</div>
</template>

View File

@@ -138,7 +138,7 @@ const getUserCloudData = async (isOnce = false) => {
}
};
// 本地歌曲模糊搜索
// 歌曲模糊搜索
const localSearch = debounce((val) => {
const searchValue = val?.trim();
// 是否为空
@@ -167,7 +167,7 @@ const playAllSongs = async () => {
initPlayer(true);
} else {
// 播放
await fadePlayOrPause();
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
@@ -181,6 +181,10 @@ onBeforeMount(() => {
getUserCloudDataList();
getUserCloudData();
});
onMounted(() => {
window.$refreshCloudList = getUserCloudDataList;
});
</script>
<style lang="scss" scoped>

View File

@@ -48,7 +48,7 @@ const playAllSongs = async () => {
console.log(historyPlaylist.value);
playList.value = historyPlaylist.value;
playIndex.value = 0;
await fadePlayOrPause();
fadePlayOrPause();
$message.info("已开始播放", { showIcon: false });
};

View File

@@ -47,12 +47,7 @@
<Transition name="fade" mode="out-in">
<div :key="videoData?.id" class="menu">
<!-- 点赞 -->
<n-button
:type="videoData?.liked ? 'primary' : 'default'"
:title="`点赞数:${videoData?.likedCount}`"
quaternary
@click="videoLike"
>
<n-button quaternary @click="videoLike">
<template #icon>
<n-icon>
<SvgIcon :icon="videoData?.liked ? 'thumb-up' : 'thumb-up-outline'" />
@@ -65,13 +60,21 @@
}}
</n-button>
<!-- 收藏 -->
<n-button quaternary>
<n-button quaternary @click="videoCollection">
<template #icon>
<n-icon>
<SvgIcon icon="folder-plus-outline" />
<SvgIcon
:icon="
isLikeOrDislike(videoData?.id) ? 'favorite-outline-rounded' : 'favorite-rounded'
"
/>
</n-icon>
</template>
{{ formatNumber(videoData?.subCount || 0) }}
{{
isLikeOrDislike(videoData?.id)
? formatNumber(videoData?.subCount || 0)
: videoData?.subCount || 0
}}
</n-button>
<!-- 分享 -->
<n-button quaternary>
@@ -177,7 +180,13 @@
</n-image>
<div class="desc">
<n-text class="name">{{ item.name }}</n-text>
<n-button class="open" size="small" tertiary round>
<n-button
class="open"
size="small"
tertiary
round
@click="router.push(`/artist?id=${item.id}`)"
>
<template #icon>
<n-icon size="14">
<SvgIcon icon="account-music" />
@@ -205,24 +214,36 @@
<script setup>
import { NIcon } from "naive-ui";
import { getVideoDetail, getVideoInfo, getVideoUrl, getSimiVideo } from "@/api/video";
import { storeToRefs } from "pinia";
import {
getVideoDetail,
getVideoInfo,
getVideoUrl,
getSimiVideo,
likeVideo,
likeMv,
} from "@/api/video";
import { getComment, getHotComment } from "@/api/comment";
import { resourceLike } from "@/api/other";
import { fadePlayOrPause } from "@/utils/Player";
import { siteStatus } from "@/stores";
import { siteStatus, siteData } from "@/stores";
import { useRouter } from "vue-router";
import { formatNumber } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
import throttle from "@/utils/throttle";
import SvgIcon from "@/components/Global/SvgIcon";
import Plyr from "plyr";
import "plyr/dist/plyr.css";
const router = useRouter();
const data = siteData();
const status = siteStatus();
const router = useRouter();
const { userLikeData } = storeToRefs(data);
// id
const videoId = ref(router.currentRoute.value.query.id);
const isVideo = ref(router.currentRoute.value.query.is_video);
//
const videoData = ref(null);
@@ -303,7 +324,7 @@ const initPlayer = () => {
//
player.value?.on("playing", async () => {
status.showPlayBar = false;
await fadePlayOrPause("pause");
fadePlayOrPause("pause");
});
player.value?.on("pause", async () => {
status.showPlayBar = true;
@@ -350,6 +371,15 @@ const getVideoData = async (id) => {
}
};
//
const isLikeOrDislike = (id) => {
const mvs = userLikeData.value.mvs;
if (mvs.length) {
return !mvs.some((item) => Number(item.vid) === Number(id));
}
return true;
};
//
const getSimiVideoData = async (id) => {
simiVideo.value = null;
@@ -362,6 +392,7 @@ const getSimiVideoData = async (id) => {
const videoLike = throttle(
async () => {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const isLike = videoData.value?.liked;
if (isLike === undefined || !videoData.value?.id) return false;
//
@@ -379,6 +410,30 @@ const videoLike = throttle(
"请稍后再操作",
);
//
const videoCollection = throttle(
async () => {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const id = videoData.value.id;
const type = isLikeOrDislike(id) ? 1 : 2;
const result = isVideo.value ? await likeVideo(type, id) : await likeMv(type, id);
if (result.code === 200) {
$message.success((type === 1 ? "收藏" : "取消收藏") + "成功");
//
if (!isVideo.value) await data.setUserLikeMvs();
} else {
$message.error((type === 1 ? "收藏" : "取消收藏") + "失败,请重试");
}
} catch (error) {
console.error("收藏出错:", error);
$message.error("收藏操作出现错误");
}
},
3000,
"请稍后再操作",
);
//
const getCommentData = async (id, pageNo = 1, sortType = 3, pageSize = 20) => {
try {
@@ -430,6 +485,7 @@ watch(
videoData.value = null;
simiVideo.value = null;
videoId.value = val.query.id;
isVideo.value = val.query.is_video;
commentData.value = null;
hotCommentData.value = null;
commentPage.value = 1;