Compare commits

...

41 Commits

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-26 01:22:18 +00:00
imsyy
8eaeffeda3 🎈 perf: 优化云盘缓存 2023-12-25 18:30:38 +08:00
imsyy
eed76966c4 feat: 新增电台模式
- 修复搜索框无法输入空格 #102
- 优化部分动画展示
2023-12-25 16:02:32 +08:00
imsyy
b095e4eb36 🐞 fix: 修复播放模式切换时无法正常播放
- 改进侧边栏收起按钮样式 #100
2023-12-21 15:56:20 +08:00
imsyy
3dbdf3e613 🐞 fix: 修复日推日期计算错误 #101
- 修复浏览器端下载出现提示
- 新增搜索页面播放控制
2023-12-21 11:04:26 +08:00
imsyy
5ceca058a7 🔧 build: Sync NeteaseCloudMusicApi #82 2023-12-20 16:38:20 +08:00
imsyy
a8e867bbf9 🐞 fix: 修复签到出错 2023-12-20 15:35:09 +08:00
imsyy
4cb8eb0213 feat: 支持 Docker 部署 #82 2023-12-20 14:40:39 +08:00
imsyy
461f216cab 🐞 fix: 修复无法添加歌单 2023-12-20 10:06:40 +08:00
imsyy
2756313e4a 🦄 refactor: 重构全局播放列表
- 修复删除歌曲时播放异常
- 使用虚拟列表以提升性能
2023-12-19 18:08:46 +08:00
imsyy
e802a2f574 feat: 支持自动签到 2023-12-19 14:12:53 +08:00
imsyy
a45940b104 🦄 refactor: 修改部分文件名称 2023-12-18 16:00:17 +08:00
imsyy
ac0ac5f4ea feat: 新增每日推荐 2023-12-18 15:51:36 +08:00
imsyy
883b6d13a5 🔧 build: 修复部分构建图标显示错误 2023-12-16 16:07:51 +08:00
158 changed files with 7983 additions and 3209 deletions

12
.dockerignore Normal file
View File

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

View File

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

View File

@@ -21,8 +21,6 @@ RENDERER_VITE_SITE_ANTHOR = "無名"
RENDERER_VITE_SITE_KEYWORDS = "SPlayer,云音乐,播放器,在线音乐,在线播放器,音乐播放器"
RENDERER_VITE_SITE_DES = "一个简约的在线音乐播放器具有音乐搜索、播放、每日推荐、私人FM、歌词显示、歌曲评论、网易云登录与云盘等功能"
RENDERER_VITE_SITE_URL = "imsyy.top"
RENDERER_VITE_SITE_LOGO = "/images/logo/favicon.svg"
RENDERER_VITE_SITE_APPLE_LOGO = "/images/logo/favicon-apple.png"
# Cookie
## 咪咕音乐 Cookie

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
title: 请填写标题
labels: [bug]
body:
- type: input
@@ -30,4 +31,4 @@ body:
id: other
attributes:
label: "具体信息"
description: "有需要补充的信息吗?比如控制台的报错什么的"
description: "有需要补充的信息吗?比如控制台的报错什么的 Ctrl + Shift + i 打开控制台 "

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 添加功能
url: https://github.com/imsyy/SPlayer/discussions/new?category=%E6%83%B3%E6%B3%95-ideas
about: 新的功能建议和提问答疑请到讨论区发起
- name: 转到讨论区
url: https://github.com/imsyy/SPlayer/discussions
about: Issues 用于反馈 Bug, 新的功能建议和提问答疑请到讨论区发起

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

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

5
.npmrc
View File

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

View File

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

View File

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

29
Dockerfile Normal file
View File

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

309
README.md
View File

@@ -1,15 +1,5 @@
> [!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" />
<img alt="logo" height="80" src="./public/images/icons/favicon.png" />
<h2>SPlayer</h2>
<p>一个简约的音乐播放器</p>
<img alt="main" src="./screenshots/main.png" />
@@ -18,10 +8,23 @@
## 说明
> [!IMPORTANT]
>
> ### 严肃警告
>
> - 请务必遵守 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 许可协议
> - 在您的修改、演绎、分发或派生项目中,必须同样采用 **AGPL-3.0** 许可协议,**并在适当的位置包含本项目的许可和版权信息**
> - **禁止用于售卖或其他商业用途**,如若发现,作者保留追究法律责任的权利
> - 若发现未遵守 **AGPL-3.0** 许可协议的行为,**本项目将永久停更**
> - 感谢您的尊重与理解
- 本项目采用 [Vue 3](https://cn.vuejs.org/) 全家桶和 [Naïve UI](https://www.naiveui.com/) 组件库及 [Electron](https://www.electronjs.org/zh/docs/latest/) 开发
- 支持网页端与客户端,由于设备有限,目前仅适配 `Win`,其他平台可自行构建
- ~~仅对移动端做了基础适配,**不保证功能全部可用**~~
- 欢迎各位大佬指点和 `Star` 哦 😍
- 支持网页端与客户端,由于设备有限,目前仅适配 `Win`,其他平台可自行解决兼容性后进行构建
- 仅对移动端做了基础适配,**不保证功能全部可用**
> 请注意,本程序不打算开发移动端,也不会对移动端进行完美适配,仅保证基础可用性
- 欢迎各位大佬 `Star` 😍
## 👀 Demo
@@ -29,38 +32,31 @@
## 🎉 功能
- 支持扫码登录
- 支持手机号登录
- 自动进行每日签到及云贝签到
- 封面主题色自适应
- 本地歌曲管理及分类 ~~以及音乐标签编辑~~
- **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- 下载歌曲(最高支持 Hi-Res
- 新建歌单及歌单编辑
- 收藏 / 取消收藏歌单或歌手
- 每日推荐歌曲
- 私人 FM
- 云盘音乐上传
- 云盘内歌曲播放
- 云盘内歌曲纠正
- 云盘歌曲删除
- 支持逐字歌词
- 歌词滚动以及歌词翻译
- MV 与视频播放
- 音乐频谱显示 暂时去除,还待完善
- 音乐渐入渐出
- 支持 PWA
- 支持评论区及评论点赞
- 明暗模式自动 / 手动切换
- ~~移动端基础适配~~
- ~~`i18n` 支持~~
#### 待办
- [ ] 完善音乐频谱
- [ ] 添加桌面歌词
- [ ] 多种布局方式
- [ ] 发表评论
- 支持扫码登录
- 📱 支持手机号登录
- 📅 自动进行每日签到及云贝签到
- 🎨 封面主题色自适应
- 📁 本地歌曲管理及分类(建议先使用 [音乐标签](https://www.cnblogs.com/vinlxc/p/11347744.html) 进行匹配后再使用)
- 🎵 **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- ⬇️ 下载歌曲(最高支持 Hi-Res
- 新建歌单及歌单编辑
- ❤️ 收藏 / 取消收藏歌单或歌手
- 🎶 每日推荐歌曲
- 📻 私人 FM
- ☁️ 云盘音乐上传
- 📂 云盘内歌曲播放
- 🔄 云盘内歌曲纠正
- 🗑️ 云盘歌曲删除
- 📝 支持逐字歌词
- 🔄 歌词滚动以及歌词翻译
- 📹 MV 与视频播放
- 🎶 音乐频谱显示
- ⏭️ 音乐渐入渐出
- 🔄 支持 PWA
- 💬 支持评论区及评论点赞
- 🌓 明暗模式自动 / 手动切换
- 📱 移动端基础适配
- ~~🌐 `i18n` 支持~~
## 🖼️ Screenshots
@@ -120,6 +116,38 @@
[Dev Workflow](https://github.com/imsyy/SPlayer/actions/workflows/build.yml)
## ⚙️ Docker 部署
> 安装及配置 `Docker` 将不在此处说明,请自行解决
### 本地构建
> 请尽量拉取最新分支后使用本地构建方式,在线部署的仓库可能更新不及时
```bash
# 构建
docker build -t splayer .
# 运行
docker run -d --name SPlayer -p 7899:7899 splayer
# 或使用 Docker Compose
docker-compose up -d
```
### 在线部署
```bash
# 从 Docker Hub 拉取
docker pull imsyy/splayer:latest
# 从 GitHub ghcr 拉取
docker pull ghcr.io/imsyy/splayer:latest
# 运行
docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
```
以上步骤成功后,将会在本地 [localhost:7899](http://localhost:7899/) 启动,如需更换端口,请自行修改命令行中的端口号
## ⚙️ Vercel 部署
> 其他部署平台大致相同,在此不做说明
@@ -132,6 +160,7 @@
```js
RENDERER_VITE_SERVER_URL = "https://example.com";
```
5. 将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
![build](/screenshots/build.png)
@@ -185,11 +214,11 @@
5. 复制 `/.env.example` 文件并重命名为 `/.env` 并修改配置
6. 打包客户端,请依据你的系统类型来选择,打包成功后,会输出安装包或可执行文件在 `/dist` 目录中,可自行安装
| 命令 | 系统类型 |
| --- | --- |
| `pnpm build:win` | Windows |
| `pnpm build:linux` | Linux |
| `pnpm build:mac` | MacOS |
| 命令 | 系统类型 |
| ------------------ | -------- |
| `pnpm build:win` | Windows |
| `pnpm build:linux` | Linux |
| `pnpm build:mac` | MacOS |
## 😘 鸣谢
@@ -198,7 +227,6 @@
- [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
- [YesPlayMusic](https://github.com/qier222/YesPlayMusic)
- [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server)
- [BlurLyric](https://github.com/Project-And-Factory/BlurLyric)
- [Vue-mmPlayer](https://github.com/maomao1996/Vue-mmPlayer)
## 📢 免责声明
@@ -221,3 +249,180 @@
4. **免责声明:** 根据 AGPL-3.0,本项目不提供任何明示或暗示的担保。请详细阅读 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 以了解完整的免责声明内容
5. **社区参与:** 欢迎社区的参与和贡献,我们鼓励开发者一同改进和维护本项目
6. **许可证链接:** 请阅读 [GNU Affero General Public License (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html) 了解更多详情
## 📂 目录结构
<details>
<summary>查看目录结构详情</summary>
> ChatGPT 写的,如有错误,请见谅
```dir
├── auto-imports.d.ts # 自动导入
├── components.d.ts # 自动导入
├── docker-compose.yml # Docker Compose
├── Dockerfile # Docker
├── electron # Electron
│   ├── main # Electron 主进程
│   │   ├── index.js # 主进程入口
│   │   ├── mainIpcMain.js # 主进程与渲染进程通信
│   │   ├── startMainServer.js # 启动主进程服务器
│   │   ├── startNcmServer.js # 启动网易云音乐服务
│   │   └── utils # 主进程工具函数
│   │   ├── checkUpdates.js # 检查更新
│   │   ├── createGlobalShortcut.js # 创建全局快捷键
│   │   ├── createSystemTray.js # 创建系统托盘
│   │   ├── getNeteaseMusicUrl.js # 解灰
│   │   ├── kwDES.js # DES加密算法
│   │   └── readDirAsync.js # 异步读取目录
│   └── preload # Electron 预加载脚本
│   └── index.js # 预加载脚本入口文件
├── electron-builder.yml # Electron Builder
├── electron.vite.config.js # Electron Vite
├── index.html # 主页面 HTML
├── LICENSE # 项目许可证
├── nginx.conf # Nginx 配置
├── src # 项目源代码
│   ├── api # API 相关
│   │   ├── ./..
│   ├── App.vue # 根组件
│   ├── assets # 静态资源
│   │   ├── emoji.json # 表情数据
│   │   ├── icon.json # 图标数据
│   │   └── themeColor.json # 主题颜色数据
│   ├── components # 组件目录
│   │   ├── Cover # 封面相关组件目录
│   │   │   ├── CoverDropdown.vue # 封面下拉组件
│   │   │   ├── MainCover.vue # 主封面组件
│   │   │   ├── SpecialCoverCard.vue # 特殊封面卡片组件
│   │   │   └── SpecialCover.vue # 特殊封面组件
│   │   ├── Global # 全局组件目录
│   │   │   ├── MainLayout.vue # 主布局组件
│   │   │   ├── Menu.vue # 菜单组件
│   │   │   ├── Pagination.vue # 分页组件
│   │   │   ├── Playlist.vue # 歌单组件
│   │   │   ├── Provider.vue # 全局化配置组件
│   │   │   └── SvgIcon.vue # SVG 图标组件
│   │   ├── List # 列表组件目录
│   │   │   ├── CommentList.vue # 评论列表组件
│   │   │   ├── SongListDropdown.vue # 歌曲下拉组件
│   │   │   └── SongList.vue # 歌曲列表组件
│   │   ├── Modal # 弹窗相关组件目录
│   │   │   ├── AddPlaylist.vue # 添加歌单组件
│   │   │   ├── CloudSongMatch.vue # 云盘歌曲匹配组件
│   │   │   ├── CreatePlaylist.vue # 创建歌单组件
│   │   │   ├── DownloadSong.vue # 下载歌曲组件
│   │   │   ├── LoginPhone.vue # 手机登录组件
│   │   │   ├── LoginQRCode.vue # 二维码登录组件
│   │   │   ├── Login.vue # 登录组件
│   │   │   ├── PlaylistUpdate.vue # 歌单编辑组件
│   │   │   └── UpCloudSong.vue # 上传云盘歌曲组件
│   │   ├── Nav # 导航相关组件目录
│   │   │   ├── MainNav.vue # 主导航组件
│   │   │   └── UserData.vue # 用户数据组件
│   │   ├── Player # 播放器相关组件目录
│   │   │   ├── CountDown.vue # 倒计时组件
│   │   │   ├── FullPlayer.vue # 全屏播放器组件
│   │   │   ├── Lyric.vue # 歌词组件
│   │   │   ├── MainControl.vue # 主控制组件
│   │   │   ├── PlayerControl.vue # 播放器控制组件
│   │   │   ├── PlayerCover.vue # 播放器封面组件
│ │ │ └── PrivateFm.vue # 私人 FM 组件
│ │ ├── Search # 搜索相关组件
│ │ │ ├── SearchHot.vue # 热门搜索组件
│ │ │ ├── SearchInp.vue # 搜索输入组件
│ │ │ └── SearchSuggestions.vue # 搜索建议组件
│ │ └── WinDom # 窗口 DOM 相关组件
│ │ └── TitleBar.vue # 标题栏组件
│ ├── main.js # Vue 应用的入口文件
│ ├── router # Vue Router 相关文件夹
│ │ ├── index.js # Vue Router 入口文件
│ │ └── routes.js # 路由配置文件
│ ├── stores # Vuex Store 相关文件夹
│ │ ├── indexedDB.js # IndexedDB 数据库相关文件
│ │ ├── index.js # Vuex Store 入口文件
│ │ ├── musicData.js # 音乐数据相关文件
│ │ ├── siteData.js # 网站数据相关文件
│ │ ├── siteSettings.js # 网站设置相关文件
│ │ └── siteStatus.js # 网站状态相关文件
│ ├── style # 样式相关文件夹
│ │ ├── animate.scss # 动画样式文件
│ │ └── main.scss # 主样式文件
│ ├── utils # 工具函数文件夹
│ │ ├── auth.js # 认证相关函数
│ │ ├── base64.js # Base64编码解码相关函数
│ │ ├── color-utils.js # 颜色工具函数
│ │ ├── cover-color.js # 封面颜色相关函数
│ │ ├── debounce.js # 防抖函数
│ │ ├── formatData.js # 数据格式化函数
│ │ ├── formRules.js # 表单验证规则
│ │ ├── globalEvents.js # 全局事件处理函数
│ │ ├── globalShortcut.js # 全局快捷键相关函数
│ │ ├── helper.js # 辅助函数
│ │ ├── parseLyric.js # 解析歌词函数
│ │ ├── Player.js # 播放器控制相关函数
│ │ ├── request.js # 网络请求相关函数
│ │ ├── throttle.js # 节流函数
│ │ ├── timeTools.js # 时间工具函数
│ │ └── userSignIn.js # 用户登录相关函数
│ └── views # Vue组件文件夹
│ ├── Artist # 艺术家相关组件
│ │ ├── albums.vue # 艺术家专辑组件
│ │ ├── hot.vue # 艺术家热门组件
│ │ ├── index.vue # 艺术家主组件
│ │ ├── songs.vue # 艺术家歌曲组件
│ │ └── videos.vue # 艺术家视频组件
│ ├── Cloud.vue # 云盘组件
│ ├── Comment.vue # 评论组件
│ ├── DailySongs.vue # 每日推荐组件
│ ├── Discover # 发现音乐相关组件
│ │ ├── artists.vue # 发现音乐艺术家组件
│ │ ├── index.vue # 发现音乐主组件
│ │ ├── new.vue # 发现音乐新歌组件
│ │ ├── playlists.vue # 发现音乐歌单组件
│ │ └── toplists.vue # 发现音乐排行榜组件
│ ├── History.vue # 历史记录组件
│ ├── Home.vue # 主页组件
│ ├── Like # 我喜欢的相关组件
│ │ ├── albums.vue # 我喜欢的专辑组件
│ │ ├── artists.vue # 我喜欢的艺术家组件
│ │ ├── index.vue # 我喜欢的主组件
│ │ ├── playlists.vue # 我喜欢的歌单组件
│ │ └── videos.vue # 我喜欢的视频组件
│ ├── List # 列表相关组件
│ │ ├── album.vue # 专辑组件
│ │ └── playlist.vue # 歌单组件
│ │ └── dj.vue # 电台组件
│ ├── Local # 本地音乐相关组件
│ │ ├── albums.vue # 本地音乐专辑组件
│ │ ├── artists.vue # 本地音乐艺术家组件
│ │ ├── index.vue # 本地音乐主组件
│ │ └── songs.vue # 本地音乐歌曲组件
│ ├── Player.vue # 视频播放器组件
│ ├── Dj # 电台相关组件
│ │ └── index.vue # 电台主组件
│ │ └── type.vue # 电台分类组件
│ ├── Search # 搜索相关组件
│ │ ├── albums.vue # 搜索专辑组件
│ │ ├── artists.vue # 搜索艺术家组件
│   │   ├── index.vue # 搜索主组件
│   │   ├── playlists.vue # 搜索歌单组件
│   │   ├── songs.vue # 搜索歌曲组件
│   │   └── videos.vue # 搜索视频组件
│   │   └── djs.vue # 搜索电台组件
│   ├── Setting # 设置相关组件
│   │   └── index.vue # 设置主组件
│   ├── Song.vue
│   ├── State
│   │   ├── 403.vue
│   │   ├── 404.vue
│   │   └── 500.vue
│   └── Test.vue
└── vercel.json # Vercel 部署配置
```
</details>
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=imsyy/SPlayer&type=Date)](https://star-history.com/#imsyy/SPlayer&Date)

3
auto-imports.d.ts vendored
View File

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

9
components.d.ts vendored
View File

@@ -12,6 +12,7 @@ declare module 'vue' {
CommentList: typeof import('./src/components/List/CommentList.vue')['default']
CountDown: typeof import('./src/components/Player/CountDown.vue')['default']
CoverDropdown: typeof import('./src/components/Cover/CoverDropdown.vue')['default']
CoverPlayBtn: typeof import('./src/components/Cover/CoverPlayBtn.vue')['default']
CreatePlaylist: typeof import('./src/components/Modal/CreatePlaylist.vue')['default']
DownloadSong: typeof import('./src/components/Modal/DownloadSong.vue')['default']
FullPlayer: typeof import('./src/components/Player/FullPlayer.vue')['default']
@@ -27,11 +28,11 @@ declare module 'vue' {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
@@ -39,6 +40,7 @@ declare module 'vue' {
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
@@ -67,6 +69,7 @@ declare module 'vue' {
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
@@ -74,7 +77,6 @@ declare module 'vue' {
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
@@ -83,6 +85,7 @@ declare module 'vue' {
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NVirtualList: typeof import('naive-ui')['NVirtualList']
Pagination: typeof import('./src/components/Global/Pagination.vue')['default']
PlayerControl: typeof import('./src/components/Player/PlayerControl.vue')['default']
PlayerCover: typeof import('./src/components/Player/PlayerCover.vue')['default']
@@ -96,9 +99,11 @@ declare module 'vue' {
SearchInp: typeof import('./src/components/Search/SearchInp.vue')['default']
SearchSuggestions: typeof import('./src/components/Search/SearchSuggestions.vue')['default']
SongList: typeof import('./src/components/List/SongList.vue')['default']
SongListDrawer: typeof import('./src/components/List/SongListDrawer.vue')['default']
SongListDropdown: typeof import('./src/components/List/SongListDropdown.vue')['default']
SpecialCover: typeof import('./src/components/Cover/SpecialCover.vue')['default']
SpecialCoverCard: typeof import('./src/components/Cover/SpecialCoverCard.vue')['default']
Spectrum: typeof import('./src/components/Player/Spectrum.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
TitleBar: typeof import('./src/components/WinDom/TitleBar.vue')['default']
UpCloudSong: typeof import('./src/components/Modal/UpCloudSong.vue')['default']

12
docker-compose.yml Normal file
View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export default defineConfig(({ mode }) => {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.js"),
index: resolve(__dirname, "electron/preload/index.mjs"),
},
},
},
@@ -97,21 +97,31 @@ export default defineConfig(({ mode }) => {
],
},
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,
name: getEnv("RENDERER_VITE_SITE_TITLE"),
short_name: getEnv("RENDERER_VITE_SITE_TITLE"),
description: getEnv("RENDERER_VITE_SITE_DES"),
display: "standalone",
start_url: "/",
theme_color: "#fff",
background_color: "#efefef",
icons: [
{
src: "/images/logo/favicon.png",
sizes: "200x200",
src: "/images/icons/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "/images/logo/favicon_512.png",
src: "/images/icons/favicon-96x96.png",
sizes: "96x96",
type: "image/png",
},
{
src: "/images/icons/favicon-256x256.png",
sizes: "256x256",
type: "image/png",
},
{
src: "/images/icons/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
},
@@ -147,12 +157,6 @@ export default defineConfig(({ mode }) => {
},
},
sourcemap: false,
win: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
linux: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
},
},
};

View File

@@ -1,10 +1,10 @@
import { join } from "path";
import { app, protocol, shell, BrowserWindow, globalShortcut } from "electron";
import { app, protocol, shell, BrowserWindow, globalShortcut, nativeImage } from "electron";
import { platform, optimizer, is } from "@electron-toolkit/utils";
import { startNcmServer } from "@main/startNcmServer";
import { startMainServer } from "@main/startMainServer";
import { configureAutoUpdater } from "@main/utils/checkUpdates";
import createSystemInfo from "@main/utils/createSystemInfo";
import createSystemTray from "@main/utils/createSystemTray";
import createGlobalShortcut from "@main/utils/createGlobalShortcut";
import mainIpcMain from "@main/mainIpcMain";
import Store from "electron-store";
@@ -15,7 +15,7 @@ process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 配置 log
log.transports.file.resolvePathFn = () =>
join(app.getPath("documents"), "/SPlayer/splayer-log.txt");
join(app.getPath("documents"), "/SPlayer/SPlayer-log.txt");
// 设置日志文件的最大大小为 2 MB
log.transports.file.maxSize = 2 * 1024 * 1024;
// 绑定 console 事件
@@ -93,7 +93,7 @@ class MainProcess {
}
// 注册应用协议
app.setAsDefaultProtocolClient("splayer");
app.setAsDefaultProtocolClient("SPlayer");
// 应用程序准备好之前注册
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } },
@@ -107,6 +107,7 @@ class MainProcess {
createWindow() {
// 创建浏览器窗口
this.mainWindow = new BrowserWindow({
title: app.getName() || "SPlayer",
width: this.store.get("windowSize.width") || 1280, // 窗口宽度
height: this.store.get("windowSize.height") || 740, // 窗口高度
minHeight: 700, // 最小高度
@@ -117,11 +118,11 @@ class MainProcess {
titleBarStyle: "customButtonsOnHover", // Macos 隐藏菜单栏
autoHideMenuBar: true, // 失去焦点后自动隐藏菜单栏
// 图标配置
icon: join(__dirname, "../../public/images/logo/favicon.png"),
icon: nativeImage.createFromPath(join(__dirname, "../../public/images/icons/favicon.png")),
// 预加载
webPreferences: {
// devTools: is.dev, //是否开启 DevTools
preload: join(__dirname, "../preload/index.js"),
// devTools: is.dev,
preload: join(__dirname, "../preload/index.mjs"),
sandbox: false,
webSecurity: false,
hardwareAcceleration: true,
@@ -133,8 +134,6 @@ class MainProcess {
this.mainWindow.show();
// mainWindow.maximize();
this.store.set("windowSize", this.mainWindow.getBounds());
// 创建系统信息
createSystemInfo(this.mainWindow);
});
// 主窗口事件
@@ -166,6 +165,8 @@ class MainProcess {
configureAutoUpdater();
// 引入主 Ipc
mainIpcMain(this.mainWindow);
// 系统托盘
createSystemTray(this.mainWindow);
// 注册快捷键
createGlobalShortcut(this.mainWindow);
});

View File

@@ -1,7 +1,7 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { write } from "node-id3";
import { download } from "electron-dl";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import axios from "axios";
@@ -193,32 +193,39 @@ const mainIpcMain = (win) => {
});
// 下载文件至指定目录
ipcMain.handle("downloadFile", async (_, data, song, songName, songType, path) => {
ipcMain.handle("downloadFile", async (_, songData, options) => {
try {
const { url, data, lyric, name, type } = JSON.parse(songData);
const { path, downloadMeta, downloadCover, downloadLyrics } = JSON.parse(options);
if (fs.access(path)) {
const songData = JSON.parse(song);
console.info("开始下载:", songData, data);
console.info("开始下载:", name, url);
// 下载歌曲
const songDownload = await download(win, data.url, {
const songDownload = await download(win, url, {
directory: path,
filename: `${songName}.${songType}`,
filename: `${name}.${type}`,
});
// 若关闭,则不进行元信息写入
if (!downloadMeta) return true;
// 下载封面
const coverDownload = await download(win, songData.cover, {
const coverDownload = await download(win, data.cover, {
directory: path,
filename: `${songName}.jpg`,
filename: `${name}.jpg`,
});
// 生成歌曲文件的元数据
const songTag = {
title: songData.name,
artist: Array.isArray(songData.artists)
? songData.artists.map((ar) => ar.name).join(" / ")
: songData.artists || "未知歌手",
album: songData.album?.name || songData.album,
image: coverDownload.getSavePath(),
};
// 读取歌曲文件
const songFile = File.createFromPath(songDownload.getSavePath());
// 生成图片信息
const songCover = Picture.fromPath(coverDownload.getSavePath());
// 保存修改后的元数据
write(songTag, songDownload.getSavePath());
Id3v2Settings.forceDefaultVersion = true;
Id3v2Settings.defaultVersion = 3;
songFile.tag.title = data.name || "未知曲目";
songFile.tag.album = data.album?.name || "未知专辑";
songFile.tag.performers = data?.artists?.map((ar) => ar.name) || ["未知艺术家"];
if (downloadLyrics) songFile.tag.lyrics = lyric;
if (downloadCover) songFile.tag.pictures = [songCover];
// 保存元信息
songFile.save();
songFile.dispose();
// 删除封面
await fs.unlink(coverDownload.getSavePath());
return true;

View File

@@ -1,4 +1,4 @@
const netEaseApi = require("NeteaseCloudMusicApi");
import netEaseApi from "NeteaseCloudMusicApi";
/**
* 启动网易云音乐 API 服务器

View File

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

View File

@@ -1,55 +1,58 @@
import { join } from "path";
import { platform } from "@electron-toolkit/utils";
import { Tray, Menu, app, ipcMain, nativeImage, nativeTheme } from "electron";
import { join } from "path";
// 当前播放歌曲数据
// 当前歌曲数据
let playSongName = "当前暂无播放歌曲";
let playSongState = false;
/**
* 创建系统自定义信息
* 创建系统托盘
* @param {BrowserWindow} win - 程序窗口
*/
const createSystemInfo = (win) => {
// 弹出列表
app.setUserTasks([]);
const createSystemTray = (win) => {
// 系统托盘
const mainTray = new Tray(join(__dirname, "../../public/images/logo/favicon.png"));
// 默认托盘菜单
Menu.setApplicationMenu(Menu.buildFromTemplate(createTrayMenu(win)));
// 给托盘图标设置气球提示
const mainTray = new Tray(
nativeImage
.createFromPath(
join(
__dirname,
process.platform === "win32"
? "../../public/images/icons/favicon.ico"
: "../../public/images/icons/favicon-32x32.png",
),
)
.resize({
height: 32,
width: 32,
}),
);
// 应用内菜单
Menu.setApplicationMenu(createTrayMenu(win));
// 默认名称
win.setTitle(app.getName());
mainTray.setTitle(app.getName());
mainTray.setToolTip(app.getName());
// 自定义任务栏缩略图
createThumbar(win);
// 歌曲数据改变时
// 左键事件
mainTray.on("click", () => win.show());
// 托盘菜单
mainTray.setContextMenu(createTrayMenu(win));
// 系统主题改变
nativeTheme.on("updated", () => {
mainTray.setContextMenu(createTrayMenu(win));
});
// 播放歌曲改变
ipcMain.on("songNameChange", (_, val) => {
playSongName = val;
// 托盘图标标题
mainTray.setToolTip(val);
// 更改应用标题
win.setTitle(val);
mainTray.setTitle(val);
mainTray.setToolTip(val);
mainTray.setContextMenu(createTrayMenu(win));
});
// 播放状态改变
ipcMain.on("songStateChange", (_, val) => {
playSongState = val;
createThumbar(win);
mainTray.setContextMenu(createTrayMenu(win));
});
// 监听系统主题改变
nativeTheme.on("updated", () => {
createThumbar(win);
});
// 左键事件
mainTray.on("click", () => {
// 显示窗口
win.show();
});
// 右键事件
mainTray.on("right-click", () => {
mainTray.popUpContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
});
// linux 右键菜单
if (platform.isLinux) {
mainTray.setContextMenu(Menu.buildFromTemplate(createTrayMenu(win)));
}
};
// 生成图标
@@ -60,8 +63,8 @@ const createIcon = (name) => {
return nativeImage
.createFromPath(
isDarkMode
? join(__dirname, `../../public/images/icon/${name}-dark.png`)
: join(__dirname, `../../public/images/icon/${name}-light.png`),
? join(__dirname, `../../public/images/icons/${name}-dark.png`)
: join(__dirname, `../../public/images/icons/${name}-light.png`),
)
.resize({ width: 16, height: 16 });
};
@@ -69,7 +72,7 @@ const createIcon = (name) => {
// 生成右键菜单
const createTrayMenu = (win) => {
// 返回菜单
return [
return Menu.buildFromTemplate([
{
label: playSongName,
icon: createIcon("open"),
@@ -113,7 +116,9 @@ const createTrayMenu = (win) => {
label: "全局设置",
icon: createIcon("setting"),
click: () => {
win.webContents.send("setting");
win.show();
win.focus();
win.webContents.send("open-setting");
},
},
{
@@ -128,35 +133,7 @@ const createTrayMenu = (win) => {
app.quit();
},
},
];
};
// 自定义任务栏缩略图 - 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;
export default createSystemTray;

View File

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

28
nginx.conf Normal file
View File

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

View File

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

2712
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,7 +1,7 @@
<template>
<Provider>
<!-- 主框架 -->
<n-layout class="all-layout">
<n-layout :class="['all-layout', { 'full-player': showFullPlayer }]">
<!-- 导航栏 -->
<n-layout-header bordered>
<MainNav />
@@ -11,7 +11,7 @@
v-if="showSider"
:class="{
'body-layout': true,
'player-bar': Object.keys(music.getPlaySongData)?.length && showPlayBar,
'player-bar': music.getPlaySongData?.id && showPlayBar,
}"
position="absolute"
has-sider
@@ -43,7 +43,7 @@
v-else
:class="{
'body-layout': true,
'player-bar': Object.keys(music.getPlaySongData)?.length && showPlayBar,
'player-bar': music.getPlaySongData?.id && showPlayBar,
}"
:native-scrollbar="false"
position="absolute"
@@ -79,11 +79,12 @@
<script setup>
import { storeToRefs } from "pinia";
import { darkTheme, NButton } from "naive-ui";
import { useRouter } from "vue-router";
import { darkTheme, NButton } from "naive-ui";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { initPlayer } from "@/utils/Player";
import { checkPlatform } from "@/utils/helper";
import { initPlayer } from "@/utils/Player";
import userSignIn from "@/utils/userSignIn";
import globalShortcut from "@/utils/globalShortcut";
import globalEvents from "@/utils/globalEvents";
import packageJson from "@/../package.json";
@@ -92,7 +93,7 @@ const router = useRouter();
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { autoPlay, showSider } = storeToRefs(settings);
const { autoPlay, showSider, autoSignIn } = storeToRefs(settings);
const { showPlayBar, asideMenuCollapsed, showFullPlayer } = storeToRefs(status);
// 公告数据
@@ -111,9 +112,9 @@ if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (checkPlatform.electron()) {
$notification.create({
title: "🎉 更新提醒",
content: "检测到软件有更新,是否重新启动软件以应用更新?",
meta: "v " + (packageJson.version || "1.0.0"),
title: "🎉 更新",
content: "检测到软件内资源有更新,是否重新启动软件以应用更新?",
meta: "当前版本 v " + (packageJson.version || "1.0.0"),
action: () =>
h(
NButton,
@@ -129,14 +130,14 @@ if ("serviceWorker" in navigator) {
},
),
onAfterLeave: () => {
$message.info("已取消本次更新,将在下次启动软件后生效", {
$message.info("已取消本次更新,更新将在下次启动软件后生效", {
duration: 6000,
});
},
});
} else {
console.info("站点更新,刷新后生效");
$message.info("站点更新,刷新后生效", {
console.info("站点资源有更新,刷新以应用更新");
$message.info("站点资源有更新,刷新以应用更新", {
closable: true,
duration: 0,
});
@@ -178,17 +179,19 @@ const handleKeyUp = (event) => {
globalShortcut(event, router);
};
onMounted(() => {
onMounted(async () => {
// 挂载方法
window.$canNotConnect = canNotConnect;
// 主播放器
initPlayer(autoPlay.value);
await initPlayer(autoPlay.value);
// 全局事件
globalEvents(router);
// 键盘监听
if (!checkPlatform.electron()) {
window.addEventListener("keyup", handleKeyUp);
}
// 自动签到
if (autoSignIn.value) await userSignIn();
// 显示公告
showAnnouncements();
});
@@ -201,7 +204,9 @@ onUnmounted(() => {
<style lang="scss" scoped>
.all-layout {
height: 100%;
transition: transform 0.3s;
transition:
transform 0.3s,
opacity 0.3s;
.n-layout-header {
height: 60px;
display: flex;
@@ -219,7 +224,7 @@ onUnmounted(() => {
.sider-all {
height: 100%;
}
@media (max-width: 720px) {
@media (max-width: 900px) {
display: none;
}
}
@@ -227,5 +232,9 @@ onUnmounted(() => {
bottom: 80px;
}
}
&.full-player {
opacity: 0;
transform: scale(0.9);
}
}
</style>

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

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

View File

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

View File

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

View File

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

View File

@@ -119,6 +119,19 @@ export const getUserMv = () => {
});
};
/**
* 获取用户电台的订阅列表
*/
export const getUserDj = () => {
return axios({
method: "GET",
url: "/dj/sublist",
params: {
timestamp: new Date().getTime(),
},
});
};
/**
* 获取用户喜欢的音乐列表
* @param {string} uid 用户的id

View File

@@ -97,3 +97,25 @@ export const likeMv = (t, mvid) => {
},
});
};
/**
* 全部 mv
* @param {string} area - 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部
* @param {string} type - 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部
* @param {string} order - 排序,可选值为上升最快,最热,最新,不填则为上升最快
* @param {number} [limit=12] - 返回数量默认12
* @param {number} [offset=0] - 偏移数量默认0
*/
export const allMv = (area, type, order, limit = 12, offset = 0) => {
return axios({
method: "GET",
url: "/mv/all",
params: {
area,
type,
order,
limit,
offset,
},
});
};

View File

@@ -1,8 +1,10 @@
{
"album": "M12 11a1 1 0 0 0-1 1a1 1 0 0 0 1 1a1 1 0 0 0 1-1a1 1 0 0 0-1-1m0 5.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5s4.5 2 4.5 4.5s-2 4.5-4.5 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"chevron-up": "M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6l1.41 1.41Z",
"chevron-down": "M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z",
"play": "M9.525 18.025q-.5.325-1.012.038T8 17.175V6.825q0-.6.513-.888t1.012.038l8.15 5.175q.45.3.45.85t-.45.85l-8.15 5.175Z",
"play-circle": "M10 16.5v-9l6 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"pause-circle": "M15 16h-2V8h2m-4 8H9V8h2m1-6A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2",
"play-next": "m10 16.5l6-4.5l-6-4.5M22 12c0-5.54-4.46-10-10-10c-1.17 0-2.3.19-3.38.56l.7 1.94c.85-.34 1.74-.53 2.68-.53c4.41 0 8.03 3.62 8.03 8.03c0 4.41-3.62 8.03-8.03 8.03c-4.41 0-8.03-3.62-8.03-8.03c0-.94.19-1.88.53-2.72l-1.94-.66C2.19 9.7 2 10.83 2 12c0 5.54 4.46 10 10 10s10-4.46 10-10M5.47 3.97c.85 0 1.53.71 1.53 1.5C7 6.32 6.32 7 5.47 7c-.79 0-1.5-.68-1.5-1.53c0-.79.71-1.5 1.5-1.5Z",
"playlist-add": "M4 16q-.425 0-.713-.288T3 15q0-.425.288-.713T4 14h5q.425 0 .713.288T10 15q0 .425-.288.713T9 16H4Zm0-4q-.425 0-.713-.288T3 11q0-.425.288-.713T4 10h9q.425 0 .713.288T14 11q0 .425-.288.713T13 12H4Zm0-4q-.425 0-.713-.288T3 7q0-.425.288-.713T4 6h9q.425 0 .713.288T14 7q0 .425-.288.713T13 8H4Zm13 12q-.425 0-.713-.288T16 19v-3h-3q-.425 0-.713-.288T12 15q0-.425.288-.713T13 14h3v-3q0-.425.288-.713T17 10q.425 0 .713.288T18 11v3h3q.425 0 .713.288T22 15q0 .425-.288.713T21 16h-3v3q0 .425-.288.713T17 20Z",
"account-music": "M11 14c1 0 2.05.16 3.2.44c-.81.87-1.2 1.89-1.2 3.06c0 .89.25 1.73.78 2.5H3v-2c0-1.19.91-2.15 2.74-2.88C7.57 14.38 9.33 14 11 14m0-2c-1.08 0-2-.39-2.82-1.17C7.38 10.05 7 9.11 7 8c0-1.08.38-2 1.18-2.82C9 4.38 9.92 4 11 4c1.11 0 2.05.38 2.83 1.18C14.61 6 15 6.92 15 8c0 1.11-.39 2.05-1.17 2.83c-.78.78-1.72 1.17-2.83 1.17m7.5-2H22v2h-2v5.5a2.5 2.5 0 0 1-2.5 2.5a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5c.36 0 .69.07 1 .21V10Z",
@@ -48,6 +50,7 @@
"favorite-rounded": "M12 20.325q-.35 0-.713-.125t-.637-.4l-1.725-1.575q-2.65-2.425-4.788-4.813T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.538t2.5-.562q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125Z",
"history": "M12 21q-3.15 0-5.575-1.913T3.275 14.2q-.1-.375.15-.687t.675-.363q.4-.05.725.15t.45.6q.6 2.25 2.475 3.675T12 19q2.925 0 4.963-2.038T19 12q0-2.925-2.038-4.963T12 5q-1.725 0-3.225.8T6.25 8H8q.425 0 .713.288T9 9q0 .425-.288.713T8 10H4q-.425 0-.713-.288T3 9V5q0-.425.288-.713T4 4q.425 0 .713.288T5 5v1.35q1.275-1.6 3.113-2.475T12 3q1.875 0 3.513.713t2.85 1.924q1.212 1.213 1.925 2.85T21 12q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 21Zm1-9.4l2.5 2.5q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.8-2.8q-.15-.15-.225-.337T11 11.975V8q0-.425.288-.713T12 7q.425 0 .713.288T13 8v3.6Z",
"delete": "M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12Z",
"delete-sweep": "M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3zM14 5h-3l-1-1H6L5 5H2v2h12z",
"fire": "M17.66 11.2c-.23-.3-.51-.56-.77-.82c-.67-.6-1.43-1.03-2.07-1.66C13.33 7.26 13 4.85 13.95 3c-.95.23-1.78.75-2.49 1.32c-2.59 2.08-3.61 5.75-2.39 8.9c.04.1.08.2.08.33c0 .22-.15.42-.35.5c-.23.1-.47.04-.66-.12a.58.58 0 0 1-.14-.17c-1.13-1.43-1.31-3.48-.55-5.12C5.78 10 4.87 12.3 5 14.47c.06.5.12 1 .29 1.5c.14.6.41 1.2.71 1.73c1.08 1.73 2.95 2.97 4.96 3.22c2.14.27 4.43-.12 6.07-1.6c1.83-1.66 2.47-4.32 1.53-6.6l-.13-.26c-.21-.46-.77-1.26-.77-1.26m-3.16 6.3c-.28.24-.74.5-1.1.6c-1.12.4-2.24-.16-2.9-.82c1.19-.28 1.9-1.16 2.11-2.05c.17-.8-.15-1.46-.28-2.23c-.12-.74-.1-1.37.17-2.06c.19.38.39.76.63 1.06c.77 1 1.98 1.44 2.24 2.8c.04.14.06.28.06.43c.03.82-.33 1.72-.93 2.27Z",
"search-rounded": "M9.5 16q-2.725 0-4.612-1.888T3 9.5q0-2.725 1.888-4.612T9.5 3q2.725 0 4.612 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16Zm0-2q1.875 0 3.188-1.313T14 9.5q0-1.875-1.313-3.188T9.5 5Q7.625 5 6.312 6.313T5 9.5q0 1.875 1.313 3.188T9.5 14Z",
"search-off": "m7 17.7l1.4 1.425q.15.15.35.15t.35-.15q.15-.15.15-.363T9.1 18.4L7.7 17l1.425-1.425q.15-.15.15-.35t-.15-.35q-.15-.15-.35-.15t-.35.15L7 16.3l-1.425-1.425q-.15-.15-.35-.15t-.35.15q-.15.15-.15.35t.15.35L6.3 17l-1.425 1.425q-.15.15-.15.35t.15.35q.15.15.35.15t.35-.15L7 17.7ZM7 22q-2.075 0-3.538-1.463T2 17q0-2.075 1.463-3.538T7 12q2.075 0 3.538 1.463T12 17q0 2.075-1.463 3.538T7 22Zm7.2-7.4q-.3-.325-.638-.663T12.9 13.3q.95-.6 1.525-1.6T15 9.5q0-1.875-1.313-3.188T10.5 5Q8.625 5 7.312 6.313T6 9.5q0 .15.013.288t.037.287q-.45.05-.987.2t-.963.35q-.05-.275-.075-.55T4 9.5q0-2.725 1.888-4.612T10.5 3q2.725 0 4.612 1.888T17 9.5q0 1.075-.338 2.038t-.937 1.762l5.575 5.6q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275l-5.7-5.7Z",
@@ -87,5 +90,6 @@
"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"
"storage": "M4 20h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2s.9 2 2 2m0-3h2v2H4zM2 6c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2m4 1H4V5h2zm-2 7h16c1.1 0 2-.9 2-2s-.9-2-2-2H4c-1.1 0-2 .9-2 2s.9 2 2 2m0-3h2v2H4z",
"lrc-text": "M13.8 22H5c-1.7 0-3-1.3-3-3v-1h11.1c-.1.3-.1.7-.1 1c0 1.1.3 2.1.8 3m0-6H5V5c0-1.7 1.3-3 3-3h11c1.7 0 3 1.3 3 3v1h-2V5c0-.6-.4-1-1-1s-1 .4-1 1v8.1c-1.8.3-3.3 1.4-4.2 2.9M8 8h7V6H8zm0 4h6v-2H8zm9 4v6l5-3z"
}

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

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<main id="main-layout" :class="['main-layout', { 'no-sider': !showSider }]">
<!-- 回顶 -->
<n-back-top
:bottom="Object.keys(music.getPlaySongData)?.length && showPlayBar ? 110 : 50"
:bottom="music.getPlaySongData?.id && showPlayBar ? 110 : 50"
style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
<n-icon size="26">

View File

@@ -3,15 +3,15 @@
<n-menu
ref="mainMenuRef"
v-model:value="menuActiveKey"
class="main-menu"
:root-indent="showSider ? 36 : 28"
:class="['main-menu', { cover: siderShowCover }]"
:root-indent="showSider ? 36 : 26"
:indent="0"
:collapsed="asideMenuCollapsed.value"
:defaultExpandedKeys="['user-playlists', 'favorite-playlists']"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
@contextmenu="openSideDropdown($event)"
@contextmenu.stop
@update:value="checkMenuItem"
/>
<!-- 右键菜单 -->
@@ -22,8 +22,8 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteStatus, siteData, musicData } from "@/stores";
import { NIcon, NText, NButton } from "naive-ui";
import { siteStatus, siteData, musicData, siteSettings } from "@/stores";
import { NIcon, NText, NButton, NAvatar } from "naive-ui";
import { useRouter, RouterLink } from "vue-router";
import { getHeartRateList } from "@/api/playlist";
import { checkPlatform } from "@/utils/helper";
@@ -37,17 +37,12 @@ const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const { asideMenuCollapsed, showSider, showFullPlayer } = storeToRefs(status);
const settings = siteSettings();
const { siderShowCover } = storeToRefs(settings);
const { asideMenuCollapsed, showSider, showFullPlayer, playIndex, playMode, playHeartbeatMode } =
storeToRefs(status);
const { userData, userLikeData, userLoginStatus } = storeToRefs(data);
const {
playList,
playListOld,
playIndex,
playSongData,
playHeartbeatMode,
playMode,
privateFmSong,
} = storeToRefs(music);
const { playList, playListOld, playSongData, privateFmSong } = storeToRefs(music);
// 子组件
const coverDropdownRef = ref(null);
@@ -140,12 +135,12 @@ const menuOptions = computed(() => [
RouterLink,
{
to: {
name: "record",
name: "dj-hot",
},
},
() => ["播客电台"],
),
key: "record",
key: "dj-hot",
icon: renderIcon("record"),
},
{
@@ -168,7 +163,6 @@ const menuOptions = computed(() => [
path: "/like-songs",
},
class: "user-playlist",
menuid: "like-songs",
},
() => [
h(
@@ -285,43 +279,79 @@ const changeUserPlaylists = (data) => {
userPlaylists.value.children = userPlaylistsData.slice(1).map((v) => {
return {
label: () =>
h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
siderShowCover.value
? h(
"div",
{
class: "user-pl-cover",
onclick: () => {
router.push({
path: "/playlist",
query: {
id: v.id,
},
});
},
},
},
class: "user-playlist",
menuId: v.id,
},
() => [h(NText, null, () => [v.name])],
),
[
h(NAvatar, { src: v?.coverSize?.s, fallbackSrc: "/images/pic/album.jpg?assest" }),
h(NText, null, () => [v.name]),
],
)
: h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
},
},
class: "user-playlist",
},
() => [h(NText, null, () => [v.name])],
),
key: v.id,
icon: renderIcon("queue-music-rounded"),
icon: siderShowCover.value ? null : renderIcon("queue-music-rounded"),
};
});
favoritePlaylists.value.children = favoritePlaylistsData.map((v) => {
return {
label: () =>
h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
siderShowCover.value
? h(
"div",
{
class: "user-pl-cover",
onclick: () => {
router.push({
path: "/playlist",
query: {
id: v.id,
},
});
},
},
},
class: "user-playlist",
menuId: v.id,
},
() => [h(NText, null, () => [v.name])],
),
[
h(NAvatar, { src: v?.coverSize?.s, fallbackSrc: "/images/pic/album.jpg?assest" }),
h(NText, null, () => [v.name]),
],
)
: h(
RouterLink,
{
to: {
path: "/playlist",
query: {
id: v.id,
},
},
class: "user-playlist",
},
() => [h(NText, null, () => [v.name])],
),
key: v.id,
icon: renderIcon("queue-music-rounded"),
icon: siderShowCover.value ? null : renderIcon("queue-music-rounded"),
};
});
};
@@ -379,15 +409,6 @@ const checkMenuItem = async (key) => {
mainMenuRef.value?.showOption(key);
};
// 开启右键菜单
const openSideDropdown = (e) => {
e.preventDefault();
if (!e.target.classList.contains("user-playlist")) return false;
// 获取 id
const menuId = e.target.getAttribute("menuid");
coverDropdownRef.value?.openDropdown(e, "playlist", menuId);
};
// 开启心动模式
const startHeartRate = debounce(async () => {
try {
@@ -441,13 +462,7 @@ watch(
// 监听用户歌单变化
watch(
() => userLikeData.value.playlists,
(val) => {
changeUserPlaylists(val);
},
);
watch(
() => userLoginStatus.value,
[() => userLikeData.value.playlists, () => userLoginStatus.value, () => siderShowCover.value],
() => changeUserPlaylists(userLikeData.value.playlists),
);
@@ -458,6 +473,7 @@ onMounted(() => {
<style lang="scss" scoped>
.main-menu {
padding-bottom: 14px;
:deep(.n-menu-item) {
.n-menu-item-content {
&.n-menu-item-content--selected {
@@ -470,12 +486,25 @@ onMounted(() => {
text-overflow: ellipsis;
overflow: hidden;
}
// 我喜欢的音乐
// 普通歌单
.user-playlist {
display: flex;
align-items: center;
justify-content: space-between;
}
// 带封面歌单
.user-pl-cover {
display: flex;
flex-direction: row;
align-items: center;
.n-avatar {
border-radius: 8px;
width: 34px;
height: 34px;
min-width: 34px;
margin-right: 12px;
}
}
}
}
// 折叠菜单
@@ -508,6 +537,11 @@ onMounted(() => {
}
}
}
&.cover {
:deep(.n-submenu-children) {
--n-item-height: 50px;
}
}
}
</style>

View File

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

View File

@@ -70,8 +70,8 @@ const changeThemeColor = (val, isCover = false) => {
isCover && Object.keys(val)?.length
? val[themeType.value]
: val !== "custom"
? themeColorData[val]
: themeTypeData.value;
? themeColorData[val]
: themeTypeData.value;
// 微调主题色
const primaryColor = isCover ? `rgb(${mainColorData.bg})` : mainColorData.primaryColor;
const primaryColorHover = isCover ? `rgba(${mainColorData.bg}, 0.29)` : primaryColor + "29";

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@
</n-card>
</Transition>
<template #footer>
<n-space justify="end">
<n-flex justify="end">
<n-button @click="closeCloudSongMatch"> 取消 </n-button>
<n-button
:disabled="!cloudMatchValue.asid"
@@ -87,7 +87,7 @@
>
纠正
</n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</template>
@@ -150,7 +150,7 @@ const setCloudSongMatchBtn = async (data) => {
allCloudSongs[cloudMatchIndex.value] = JSON.parse(JSON.stringify(cloudMatchSongData.value));
await indexedDB.setfilesDB("userCloudList", allCloudSongs.slice());
// 刷新列表
if (typeof $refreshCloudList !== "undefined") $refreshCloudList();
if (typeof $refreshCloudCatch !== "undefined") $refreshCloudCatch();
} catch (error) {
console.error("更改云盘列表时出错:", error);
$message.error("更改云盘列表时出错,请刷新后重试");

View File

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

View File

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

View File

@@ -2,19 +2,18 @@
<template>
<n-modal
v-model:show="loginModalShow"
style="width: 400px"
class="login"
:auto-focus="false"
:mask-closable="false"
:bordered="false"
:close-on-esc="false"
:closable="false"
style="width: 400px"
preset="card"
transform-origin="center"
>
<div class="login-content">
<div class="title">
<img class="logo" src="/images/logo/favicon.png?asset" alt="logo" />
<img class="logo" src="/images/icons/favicon.png?asset" alt="logo" />
</div>
<!-- 登录方式 -->
<n-tabs class="login-tabs" default-value="login-qr" type="segment" animated>
@@ -27,6 +26,7 @@
</n-tabs>
<!-- 关闭登录弹窗 -->
<n-button
:focusable="false"
class="close"
strong
secondary
@@ -45,12 +45,15 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { siteData, siteSettings } from "@/stores";
import { getLoginState, refreshLogin } from "@/api/login";
import { setCookies, toLogout, isLogin } from "@/utils/auth";
import userSignIn from "@/utils/userSignIn";
const data = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(data);
const { autoSignIn } = storeToRefs(settings);
// 登录数据
const loginModalShow = ref(false);
@@ -83,6 +86,9 @@ const setLoginData = async (loginData) => {
setCookies(loginData.cookie);
// 获取用户信息
await data.setUserProfile();
await data.setDailySongsData();
// 签到
if (autoSignIn.value) await userSignIn();
// 更改状态
data.userLoginStatus = true;
$message.success("登录成功");

View File

@@ -104,36 +104,39 @@ const getCaptcha = (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("登录出错,请重试");
try {
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("请检查你的输入");
}
} else {
$message.error("请检查你的输入");
}
});
});
} catch (error) {
phoneFormData.value.captcha = null;
console.error("登录出错:", error);
$message.error("登录出错,请重试");
}
};
</script>

View File

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

View File

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

View File

@@ -2,38 +2,62 @@
<template>
<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" />
<div :class="['logo', asideMenuCollapsed ? 'collapsed' : null]" @click="router.push('/')">
<!-- <n-avatar class="logo-img" src="/images/icons/favicon.png?asset" /> -->
<n-icon class="logo-img" size="30">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 1024 1024"
>
<path
d="M511.764091 131.708086a446.145957 446.145957 0 1 0 446.145957 446.145957 446.145957 446.145957 0 0 0-446.145957-446.145957z m0 519.76004A71.829499 71.829499 0 1 1 583.59359 580.530919 72.275645 72.275645 0 0 1 511.764091 651.468126z"
:fill="themeAutoCover ? 'var(--main-second-color)' : '#F55E55'"
/>
<path
d="M802.205109 0.541175l-168.197026 37.030114a67.814185 67.814185 0 0 0-53.091369 66.029602V223.614153l3.569168 349.778431h114.213365V223.614153h108.859613a26.322611 26.322611 0 0 0 26.768758-26.322611V26.863786a26.768757 26.768757 0 0 0-32.122509-26.322611z"
:fill="themeAutoCover ? 'var(--main-color)' : '#F9BBB8'"
/>
<path
d="M511.764091 386.457428a186.935156 186.935156 0 1 0 186.935156 186.48901A186.935156 186.935156 0 0 0 511.764091 386.457428z m0 264.564552a71.383353 71.383353 0 1 1 71.383353-71.383353 71.383353 71.383353 0 0 1-71.383353 71.383353z"
:fill="themeAutoCover ? 'var(--main-color)' : '#F9BBB8'"
/>
</svg>
</n-icon>
<Transition name="fade" mode="out-in">
<n-text v-if="!status.asideMenuCollapsed && showSider" class="site-name">
<n-text v-if="!asideMenuCollapsed && showSider" class="site-name">
{{ siteName }}
</n-text>
</Transition>
</div>
<div class="navigation">
<n-button class="nav-icon" quaternary @click="router.go(-1)">
<n-flex :class="['navigation', { hidden: searchInputFocus }]" :size="6">
<n-button :focusable="false" class="nav-icon" quaternary @click="router.go(-1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-left" />
</n-icon>
</template>
</n-button>
<n-button class="nav-icon" quaternary @click="router.go(1)">
<n-button :focusable="false" class="nav-icon" quaternary @click="router.go(1)">
<template #icon>
<n-icon>
<SvgIcon icon="chevron-right" />
</n-icon>
</template>
</n-button>
</div>
</n-flex>
<!-- 搜索框 -->
<SearchInp />
<!-- GitHub -->
<Transition name="fade" mode="out-in">
<n-button v-if="showGithub" class="github" circle quaternary @click="openGithub">
<n-button
v-if="showGithub"
:focusable="false"
class="github"
circle
quaternary
@click="openGithub"
>
<template #icon>
<n-icon size="20">
<SvgIcon icon="github" />
@@ -45,7 +69,6 @@
<div class="right">
<!-- 全局菜单 -->
<n-dropdown
v-if="!showSider"
:show="mainMenuShow"
:show-arrow="true"
:options="mainMenuOptions"
@@ -54,7 +77,7 @@
>
<n-button
:style="{ pointerEvents: mainMenuShow ? 'none' : 'auto' }"
class="main-menu"
:class="['main-menu', { show: !showSider }]"
secondary
strong
round
@@ -87,7 +110,8 @@ import packageJson from "@/../package.json";
const router = useRouter();
const status = siteStatus();
const settings = siteSettings();
const { showGithub, showSider } = storeToRefs(settings);
const { asideMenuCollapsed, searchInputFocus } = storeToRefs(status);
const { showGithub, showSider, themeAutoCover } = storeToRefs(settings);
// 站点信息
const siteName = import.meta.env.RENDERER_VITE_SITE_TITLE;
@@ -142,13 +166,13 @@ const mainMenuOptions = computed(() => [
width 0.3s,
padding-left 0.3s;
-webkit-app-region: no-drag;
cursor: pointer;
.logo-img {
width: 30px;
height: 30px;
min-width: 30px;
background-color: transparent;
transition: transform 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.15);
}
@@ -167,18 +191,32 @@ const mainMenuOptions = computed(() => [
}
}
.navigation {
margin-right: 12px;
display: flex;
flex-direction: row;
justify-content: flex-start;
height: 34px;
width: 86px;
min-width: 86px;
transition:
width 0.3s,
min-width 0.3s,
opacity 0.3s;
overflow: hidden;
-webkit-app-region: no-drag;
.nav-icon {
border-radius: 8px;
padding: 0 8px;
&:first-child {
margin-right: 6px;
}
.n-icon {
font-size: 24px;
}
}
@media (max-width: 700px) {
&.hidden {
opacity: 0;
width: 0px;
min-width: 0px;
}
}
}
.github {
margin-left: 12px;
@@ -187,7 +225,15 @@ const mainMenuOptions = computed(() => [
.main-menu {
-webkit-app-region: no-drag;
margin-right: 12px;
display: none;
&.show {
display: flex;
}
@media (max-width: 900px) {
display: flex;
}
}
&.no-sider {
max-width: 1400px;
margin: 0 auto;
@@ -201,5 +247,25 @@ const mainMenuOptions = computed(() => [
margin-right: 12px;
}
}
@media (max-width: 900px) {
.left {
.logo {
width: auto;
padding-left: 0;
margin-right: 12px;
.site-name {
display: none;
}
}
}
}
@media (max-width: 700px) {
.left {
width: 100%;
}
.github {
display: none;
}
}
}
</style>

View File

@@ -48,6 +48,7 @@ import { useRouter } from "vue-router";
import { NIcon, NText, NNumberAnimation, NButton } from "naive-ui";
import { siteData, siteSettings } from "@/stores";
import SvgIcon from "@/components/Global/SvgIcon";
import userSignIn from "@/utils/userSignIn";
const data = siteData();
const router = useRouter();
@@ -61,9 +62,6 @@ const userMenuShow = ref(false);
// 登录弹窗
const loginRef = ref(null);
// 是否签到
const signInStatus = ref(false);
// 图标渲染
const renderIcon = (icon) => {
return () => h(NIcon, null, () => h(SvgIcon, { icon }));
@@ -71,14 +69,28 @@ const renderIcon = (icon) => {
// 数量统计模块
const createUserNumber = (num, text, duration = 1000) => {
return h("div", { className: "user-pl" }, [
h(NNumberAnimation, { from: 0, to: num, duration }),
h(NText, { depth: 3, style: { fontSize: "12px" } }, () => [text]),
]);
return h(
"div",
{
className: "user-pl",
onclick: () => {
userMenuShow.value = false;
router.push(
`/like/${text === "歌单" ? "playlists?" : text === "专辑" ? "albums" : "artists"}`,
);
},
},
[
h(NNumberAnimation, { from: 0, to: num, duration }),
h(NText, { depth: 3, style: { fontSize: "12px" } }, () => [text]),
],
);
};
// 生成导航栏用户信息
const createUserData = () => {
// 是否签到
const signInStatus = sessionStorage.getItem("lastSignInDate") ? true : false;
return h(
"div",
{ className: "nav-user-data" },
@@ -96,12 +108,14 @@ const createUserData = () => {
NButton,
{
round: true,
renderIcon: renderIcon(signInStatus.value ? "calendar-check" : "calendar-badge"),
onclick: () => {
$message.warning("施工中( 新建文件夹 ");
renderIcon: renderIcon(signInStatus ? "calendar-check" : "calendar-badge"),
disabled: signInStatus,
onclick: async () => {
userMenuShow.value = false;
await userSignIn();
},
},
() => [signInStatus.value ? "Lv." + userData.value.detail?.level || 1 : "立即签到"],
() => [signInStatus ? "今日已签到" : "立即签到"],
),
]),
]
@@ -205,6 +219,15 @@ const userMenuSelect = (key) => {
margin-left: 2px;
}
}
@media (max-width: 700px) {
padding: 0;
.avatar {
margin: 0;
}
.user-data {
display: none;
}
}
}
</style>
@@ -219,6 +242,7 @@ const userMenuSelect = (key) => {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
.user-pl {
display: flex;
flex-direction: column;

View File

@@ -91,6 +91,14 @@ const pointOpacity = (index) => {
margin-right: 12px;
border-radius: 50%;
background-color: var(--cover-main-color);
@media (max-width: 900px) {
width: 24px;
height: 24px;
}
@media (max-width: 700px) {
width: 20px;
height: 20px;
}
}
}
@keyframes breathe {

View File

@@ -11,6 +11,7 @@
}"
class="full-player"
@mousemove="controlShowChange"
@mouseleave="closePlayerControlShow"
>
<!-- 遮罩 -->
<Transition name="fade" mode="out-in">
@@ -48,13 +49,18 @@
<div v-show="playerControlShow" class="menu">
<div class="left">
<!-- 歌词模式 -->
<div v-if="isHasLrc" class="n-icon" @click="pureLyricMode = !pureLyricMode">
<n-text></n-text>
</div>
<n-icon
v-if="isHasLrc"
:class="['lrc-open', { open: pureLyricMode }]"
size="28"
@click="pureLyricMode = !pureLyricMode"
>
<SvgIcon icon="lrc-text" />
</n-icon>
</div>
<div class="right">
<!-- 全屏切换 -->
<n-icon @click.stop="screenfullChange">
<n-icon class="hidden" @click.stop="screenfullChange">
<SvgIcon
:icon="screenfullStatus ? 'fullscreen-exit-rounded' : 'fullscreen-rounded'"
/>
@@ -76,7 +82,7 @@
<!-- 封面 -->
<PlayerCover />
<!-- 信息 -->
<div v-if="playCoverType === 'cover' || !isHasLrc" :class="['data', playCoverType]">
<div v-show="playCoverType === 'cover' || !isHasLrc" :class="['data', playCoverType]">
<div class="desc">
<div class="title">
<span class="name">{{ music.getPlaySongData.name || "未知曲目" }}</span>
@@ -225,7 +231,9 @@
</div>
</Transition>
<!-- 控制中心 -->
<PlayerControl v-show="playerControlShow" />
<PlayerControl />
<!-- 音乐频谱 -->
<Spectrum v-if="showSpectrums" :show="!playerControlShow" :height="60" />
</div>
</Transition>
</template>
@@ -242,7 +250,7 @@ const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playList, playSongLyric } = storeToRefs(music);
const { playerBackgroundType, showYrc, playCoverType } = storeToRefs(settings);
const { playerBackgroundType, showYrc, playCoverType, showSpectrums } = storeToRefs(settings);
const {
playerControlShow,
controlTimeOut,
@@ -269,9 +277,16 @@ const screenfullChange = () => {
}
};
// 关闭控制中心
const closePlayerControlShow = () => {
if (window.innerWidth <= 700) return false;
playerControlShow.value = false;
};
// 控制中心显隐
const controlShowChange = throttle(() => {
playerControlShow.value = true;
if (window.innerWidth <= 700) return false;
if (controlTimeOut.value) {
clearTimeout(controlTimeOut.value);
}
@@ -396,17 +411,6 @@ 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;
@@ -431,6 +435,17 @@ onUnmounted(() => {
transform: scale(1);
}
}
.left {
justify-content: flex-start;
.n-icon {
margin-left: 0;
&.lrc-open {
&.open {
opacity: 0.8;
}
}
}
}
}
.main-player {
display: flex;
@@ -652,6 +667,41 @@ onUnmounted(() => {
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
@media (max-width: 700px) {
.menu {
.hidden {
display: none;
}
}
.main-player {
.content {
width: 100%;
.data {
display: block !important;
&.record {
margin-top: 0;
}
}
&.no-lrc {
transform: translateX(0);
}
}
.right {
display: none;
.data {
.name {
font-size: 24px;
.name-alias {
font-size: 16px;
}
}
.other {
font-size: 14px;
}
}
}
}
}
}
// 局外样式
.title-tip {

View File

@@ -148,8 +148,8 @@ const props = defineProps({
const music = musicData();
const settings = siteSettings();
const status = siteStatus();
const { playSeek, pureLyricMode } = storeToRefs(status);
const { playSongLyric, playSongLyricIndex } = storeToRefs(music);
const { playSeek, pureLyricMode, playSongLyricIndex } = storeToRefs(status);
const { playSongLyric } = storeToRefs(music);
const {
showYrc,
showYrcAnimation,
@@ -493,8 +493,8 @@ onMounted(() => {
}
&.record,
&.pure {
height: calc(100vh - 340px);
margin-bottom: 20px;
height: calc(100vh - 300px);
margin-bottom: 40px;
.lrc-line {
margin-bottom: -12px;
transform: scale(0.76);
@@ -503,5 +503,21 @@ onMounted(() => {
}
}
}
@media (max-width: 700px) {
:deep(.n-scrollbar-content) {
padding: 0 20px !important;
}
.lrc-line {
.lrc-content {
font-size: 6.5vw !important;
}
.lrc-fy {
font-size: 4.5vw !important;
}
.lrc-roma {
font-size: 4vw !important;
}
}
}
}
</style>

View File

@@ -3,11 +3,11 @@
<n-card
:class="{
'main-player': true,
'show-bar': Object.keys(music.getPlaySongData)?.length && showPlayBar,
'show-bar': music.getPlaySongData?.id && showPlayBar,
'no-sider': !showSider,
}"
content-style="padding: 0"
@dblclick.stop="showFullPlayer = true"
@dblclick.stop="openFullPlayer"
>
<!-- 进度条 -->
<vue-slider
@@ -31,15 +31,21 @@
<div class="player">
<!-- 歌曲信息 -->
<div class="info">
<div class="cover" @click.stop="showFullPlayer = true">
<Transition name="fade" mode="out-in">
<Transition name="fade" mode="out-in">
<div
:key="`${music.getPlaySongData?.id}-${playCoverType}`"
:class="['cover', playCoverType]"
@click.stop="openFullPlayer"
>
<n-image
:key="music.getPlaySongData?.id"
:src="
music.getPlaySongData?.coverSize?.s ||
music.getPlaySongData?.cover ||
music.getPlaySongData?.localCover
"
:style="{
animationPlayState: playState ? 'running' : 'paused',
}"
class="cover-img"
preview-disabled
@load="
@@ -54,17 +60,20 @@
</div>
</template>
</n-image>
</Transition>
<!-- 打开播放器 -->
<n-icon class="open" size="30">
<SvgIcon icon="pan-zoom-rounded" />
</n-icon>
</div>
<!-- 打开播放器 -->
<n-icon class="open" size="30">
<SvgIcon icon="pan-zoom-rounded" />
</n-icon>
</div>
</Transition>
<div class="song-info">
<div class="name">
<n-text class="text">{{ music.getPlaySongData?.name || "未知曲目" }}</n-text>
<n-text class="text">
{{ music.getPlaySongData?.name || "未知曲目" }}
</n-text>
<!-- 喜欢歌曲 -->
<n-icon
v-if="playMode !== 'dj'"
class="favorite"
@click.stop="
data.changeLikeList(
@@ -85,7 +94,7 @@
</n-icon>
<!-- 更多操作 -->
<n-dropdown
v-if="!music.getPlaySongData?.path"
v-if="playMode !== 'dj' && !music.getPlaySongData?.path"
:options="songMoreOptions"
:show-arrow="true"
placement="top-start"
@@ -100,7 +109,8 @@
<!-- 歌手 -->
<div
v-if="
((!playState || !bottomLyricShow) && playSongLyric.lrc?.length) ||
((!playState || !bottomLyricShow || playMode === 'dj') &&
playSongLyric.lrc?.length) ||
playSongLyricIndex === -1
"
class="artist"
@@ -119,6 +129,7 @@
{{ ar.name }}
</n-text>
</template>
<div v-else-if="playMode === 'dj'" class="ar">电台节目</div>
<n-text v-else class="ar">
{{ music.getPlaySongData?.artists || "未知艺术家" }}
</n-text>
@@ -172,6 +183,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playLoading"
:focusable="false"
tag="div"
type="primary"
class="play-control"
@@ -194,110 +206,129 @@
</n-icon>
</div>
<!-- 功能区 -->
<div class="menu">
<!-- 时间进度 -->
<div class="time">
<n-text class="played" depth="3">{{ playTimeData.played }}</n-text>
<n-text depth="3">{{ playTimeData.durationTime }}</n-text>
</div>
<!-- 播放模式 -->
<n-dropdown
v-if="playMode === 'normal'"
:options="playModeOptions"
:show-arrow="true"
trigger="hover"
@select="playModeChange"
>
<div class="mode" @click.stop @dblclick.stop>
<n-icon size="22">
<Transition name="fade" mode="out-in">
<div :key="playMode" class="menu">
<!-- 时间进度 -->
<div class="time hidden">
<n-text class="played" depth="3">{{ playTimeData.played }}</n-text>
<n-text depth="3">{{ playTimeData.durationTime }}</n-text>
</div>
<!-- 播放模式 -->
<n-dropdown
v-if="playMode !== 'fm'"
:options="playModeOptions"
:show-arrow="true"
trigger="hover"
@select="playModeChange"
>
<n-icon
class="mode hidden"
size="22"
@click.stop="playModeChange(false)"
@dblclick.stop
>
<SvgIcon
:icon="
playHeartbeatMode
? 'heartbit'
: playSongMode === 'normal'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
"
isSpecial
/>
</n-icon>
</div>
</n-dropdown>
<!-- 倍速 -->
<n-popover :show-arrow="false" trigger="hover" placement="top-end" raw>
<template #trigger>
<div class="speed" @click.stop="(playRate = 1), setRate(1)" @dblclick.stop>
<n-icon v-if="playRate === 1" size="22">
<SvgIcon icon="speed-rounded" />
</n-icon>
<n-text v-else class="speed-text">{{ playRate }}x</n-text>
</div>
</template>
<!-- 倍速调整 -->
<div class="slider-content">
<n-slider
v-model:value="playRate"
:tooltip="false"
:min="0.1"
:max="2"
:step="0.1"
:marks="{
0.1: '减速',
1: '正常',
2: '加速',
}"
style="width: 220px"
@update:value="setRate"
/>
</div>
</n-popover>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon class="volume" size="22" @click.stop="setVolumeMute" @wheel="changeVolume">
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon v-else-if="playVolume > 0 && playVolume < 0.4" icon="volume-mute-rounded" />
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
</n-dropdown>
<!-- 倍速 -->
<n-popover :show-arrow="false" trigger="hover" placement="top-end" raw>
<template #trigger>
<div class="speed hidden" @click.stop="(playRate = 1), setRate(1)" @dblclick.stop>
<n-icon v-if="playRate === 1" size="22">
<SvgIcon icon="speed-rounded" />
</n-icon>
<n-text v-else class="speed-text">{{ playRate }}x</n-text>
</div>
</template>
<!-- 倍速调整 -->
<div class="slider-content">
<n-slider
v-model:value="playRate"
:tooltip="false"
:min="0.1"
:max="2"
:step="0.1"
:marks="{
0.1: '减速',
1: '正常',
2: '加速',
}"
style="width: 220px"
@update:value="setRate"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
</div>
</n-popover>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon
class="volume hidden"
size="22"
@click.stop="setVolumeMute"
@wheel="changeVolume"
>
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon
v-else-if="playVolume > 0 && playVolume < 0.4"
icon="volume-mute-rounded"
/>
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
:style="{
padding: '10px 0',
width: '50px',
}"
class="slider-content hidden"
@wheel="changeVolume"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放列表 -->
<n-badge
v-if="playMode !== 'fm'"
:value="playList?.length ?? 0"
:show="showPlaylistCount"
:max="999"
:style="{
padding: '10px 0',
width: '50px',
marginRight: showPlaylistCount ? '12px' : null,
}"
class="slider-content"
@wheel="changeVolume"
class="playlist"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放列表 -->
<n-icon
v-if="playMode === 'normal'"
class="playlist"
size="22"
@click.stop="playListShow = !playListShow"
>
<SvgIcon icon="queue-music-rounded" />
</n-icon>
</div>
<n-icon size="22" @click.stop="playListShow = !playListShow">
<SvgIcon icon="queue-music-rounded" />
</n-icon>
</n-badge>
</div>
</Transition>
</div>
<!-- 添加到歌单 -->
<AddPlaylist ref="addPlaylistRef" />
@@ -319,6 +350,7 @@ import {
setVolume,
setVolumeMute,
setRate,
processSpectrum,
} from "@/utils/Player";
import { getSongPlayTime } from "@/utils/timeTools";
import debounce from "@/utils/debounce";
@@ -331,21 +363,24 @@ const data = siteData();
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playList, playListOld, playSongLyric } = storeToRefs(music);
const {
playMode,
playIndex,
playList,
playListOld,
playLoading,
playState,
playListShow,
showPlayBar,
showFullPlayer,
playSongLyricIndex,
playTimeData,
playVolume,
playRate,
playVolume,
playIndex,
playMode,
playSongMode,
playHeartbeatMode,
playSongLyricIndex,
playSongLyric,
} = storeToRefs(music);
const { playLoading, playState, playListShow, showPlayBar, showFullPlayer } = storeToRefs(status);
const { showYrc, bottomLyricShow, showSider } = storeToRefs(settings);
} = storeToRefs(status);
const { showYrc, bottomLyricShow, showSider, showPlaylistCount, showSpectrums, playCoverType } =
storeToRefs(settings);
// 子组件
const addPlaylistRef = ref(null);
@@ -358,16 +393,16 @@ const renderIcon = (icon, isSpecial = false) => {
// 播放模式数据
const playModeOptions = ref([
{
label: "列表循环",
key: "normal",
icon: renderIcon("repeat-list", true),
},
{
label: "单曲循环",
key: "repeat",
icon: renderIcon("repeat-song", true),
},
{
label: "列表循环",
key: "normal",
icon: renderIcon("repeat-list", true),
},
{
label: "随机播放",
key: "random",
@@ -450,6 +485,16 @@ const songTimeSliderUpdate = (val) => {
}
};
// 开启播放器
const openFullPlayer = () => {
if (playMode.value === "dj") {
$message.warning("当前为电台模式,无法开启播放器");
return false;
}
if (showSpectrums.value && typeof $player !== "undefined") processSpectrum($player);
showFullPlayer.value = true;
};
// 上下曲切换
const changePlayIndexDebounce = debounce(async (type, id) => {
// 垃圾桶
@@ -462,6 +507,11 @@ const changePlayIndexDebounce = debounce(async (type, id) => {
// 播放模式切换
const playModeChange = (mode) => {
const modeMap = {
normal: "random",
random: "shuffle",
shuffle: "normal",
};
// 关闭心动模式
if (playHeartbeatMode.value) {
playHeartbeatMode.value = false;
@@ -471,7 +521,11 @@ const playModeChange = (mode) => {
playListOld.value = [];
}
// 切换模式
playSongMode.value = mode;
if (mode) {
playSongMode.value = mode;
} else {
playSongMode.value = modeMap[playSongMode.value] || "normal";
}
};
// 音量条鼠标滚动
@@ -533,12 +587,12 @@ watch(
border-radius: 25px;
.vue-slider-process {
background-color: var(--main-color);
transition: none !important;
// transition: none !important;
}
.vue-slider-dot {
width: 12px !important;
height: 12px !important;
transition: none !important;
// transition: none !important;
}
.vue-slider-dot-handle {
transition: box-shadow 0.3s;
@@ -605,6 +659,33 @@ watch(
transition: opacity 0.3s ease-in-out;
}
}
&.record {
.cover-img {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: playerCoverRotate 18s linear infinite;
background: no-repeat url("/images/pic/record.png?assest") center;
:deep(img) {
width: 40px;
height: 40px;
min-width: 40px;
max-width: 40px;
border-radius: 50%;
box-shadow: 0px 0px 1px 1px rgba(255, 255, 255, 0.06);
}
}
&:hover {
:deep(img) {
transform: none;
filter: brightness(0.5);
}
.open {
transform: scale(0.8);
}
}
}
}
.name {
display: flex;
@@ -671,6 +752,7 @@ watch(
.lrc-text {
margin-top: 2px;
font-size: 13px;
transition: opacity 0.1s ease-in-out;
.space {
margin-right: 4px;
}
@@ -720,18 +802,7 @@ watch(
display: flex;
flex-direction: row;
justify-content: flex-end;
.time {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 4px;
.played {
&::after {
content: "/";
margin: 0 4px;
}
}
}
transition: opacity 0.1s;
.n-icon {
margin-left: 8px;
padding: 8px;
@@ -753,6 +824,18 @@ watch(
transform: scale(1);
}
}
.time {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 4px;
.played {
&::after {
content: "/";
margin: 0 4px;
}
}
}
.speed {
margin-left: 8px;
display: flex;
@@ -774,6 +857,44 @@ watch(
cursor: pointer;
}
}
.playlist {
transition: margin 0.3s;
&.count {
margin-right: 12px;
}
}
:deep(.n-badge-sup) {
background: var(--main-boxshadow-color);
backdrop-filter: blur(20px);
.n-base-slot-machine {
color: var(--main-color);
}
}
}
@media (max-width: 900px) {
.menu {
.time {
display: none;
}
}
}
@media (max-width: 700px) {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.control {
margin-left: auto;
.play-prev,
.play-next {
display: none;
}
}
.menu {
.hidden {
display: none;
}
}
}
}
&.show-bar {

View File

@@ -1,12 +1,16 @@
<!-- 播放器 - 控制面板 -->
<template>
<Transition name="fade" mode="out-in">
<div class="control" @mousemove="controlMove" @mouseenter="controlEnter">
<div
v-show="playerControlShow"
class="control"
@mousemove="controlMove"
@mouseenter="controlEnter"
>
<div class="left">
<!-- 喜欢歌曲 -->
<n-icon
v-if="!music.getPlaySongData.path"
class="favorite"
size="24"
@click.stop="
data.changeLikeList(
@@ -27,7 +31,7 @@
<!-- 添加到歌单 -->
<n-icon
v-if="!music.getPlaySongData.path"
class="favorite"
class="hidden"
size="24"
@click.stop="addPlaylistRef?.openAddToPlaylist(music.getPlaySongData?.id)"
>
@@ -36,7 +40,7 @@
<!-- 下载 -->
<n-icon
v-if="!music.getPlaySongData.path"
class="favorite"
class="hidden"
size="24"
@click.stop="downloadSongRef?.openDownloadModal(music.getPlaySongData)"
>
@@ -69,6 +73,7 @@
<n-button
:loading="playLoading"
:keyboard="false"
:focusable="false"
class="play-control"
strong
secondary
@@ -107,7 +112,7 @@
<!-- MV -->
<n-icon
v-if="music.getPlaySongData.mv"
class="favorite"
class="hidden"
size="22"
@click.stop="
(showFullPlayer = false), router.push(`/videos-player?id=${music.getPlaySongData.mv}`)
@@ -118,6 +123,7 @@
<!-- 评论 -->
<n-icon
v-if="!music.getPlaySongData?.path"
class="hidden"
size="22"
@click.stop="
(showFullPlayer = false), router.push(`/comment?id=${music.getPlaySongData?.id}`)
@@ -125,17 +131,57 @@
>
<SvgIcon icon="comment-text" />
</n-icon>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon
class="volume hidden"
size="22"
@click.stop="setVolumeMute"
@wheel="changeVolume"
>
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon v-else-if="playVolume > 0 && playVolume < 0.4" icon="volume-mute-rounded" />
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
:style="{
'--cover-main-color': `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
'--cover-second-color': `rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
}"
class="slider-content hidden"
@wheel="changeVolume"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放模式 -->
<n-icon v-if="playMode === 'normal'" size="22" @click.stop="togglePlayMode">
<n-icon v-if="playMode === 'normal'" class="hidden" size="22" @click.stop="togglePlayMode">
<SvgIcon
:icon="
playHeartbeatMode
? 'heartbit'
: playSongMode === 'normal'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
"
isSpecial
/>
@@ -157,7 +203,14 @@
import { storeToRefs } from "pinia";
import { musicData, siteStatus, siteData } from "@/stores";
import { useRouter } from "vue-router";
import { playOrPause, fadePlayOrPause, setSeek, changePlayIndex } from "@/utils/Player";
import {
playOrPause,
fadePlayOrPause,
setSeek,
changePlayIndex,
setVolume,
setVolumeMute,
} from "@/utils/Player";
import debounce from "@/utils/debounce";
import VueSlider from "vue-slider-component";
import "vue-slider-component/theme/default.css";
@@ -166,17 +219,22 @@ const router = useRouter();
const data = siteData();
const music = musicData();
const status = siteStatus();
const { playList, playListOld } = storeToRefs(music);
const {
playIndex,
playList,
playListOld,
playMode,
playerControlShow,
controlTimeOut,
playLoading,
playState,
showFullPlayer,
playListShow,
playTimeData,
playMode,
playSongMode,
playHeartbeatMode,
} = storeToRefs(music);
const { playerControlShow, controlTimeOut, playLoading, playState, showFullPlayer, playListShow } =
storeToRefs(status);
playVolume,
coverTheme,
} = storeToRefs(status);
// 子组件
const addPlaylistRef = ref(null);
@@ -225,6 +283,24 @@ const togglePlayMode = () => {
playSongMode.value = modeMap[playSongMode.value] || "normal";
};
// 音量条鼠标滚动
const changeVolume = (e) => {
const deltaY = e.deltaY;
if (deltaY > 0) {
// 向下滚动
if (playVolume.value >= 0) {
playVolume.value = Math.max(playVolume.value - 0.05, 0);
}
} else if (deltaY < 0) {
// 向上滚动
if (playVolume.value < 1) {
playVolume.value = Math.min(playVolume.value + 0.05, 1);
}
}
// 更新音量
setVolume(playVolume.value);
};
// 控制面板移入
const controlEnter = () => {
if (controlTimeOut.value) clearTimeout(controlTimeOut.value);
@@ -346,12 +422,12 @@ const controlMove = (e) => {
background-color: var(--cover-second-color);
.vue-slider-process {
background-color: var(--cover-main-color);
transition: none !important;
// transition: none !important;
}
.vue-slider-dot {
width: 10px !important;
height: 10px !important;
transition: none !important;
// transition: none !important;
.vue-slider-dot-handle {
background-color: var(--cover-main-color);
}
@@ -372,5 +448,40 @@ const controlMove = (e) => {
opacity: 1;
}
}
@media (max-width: 700px) {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
.left,
.right {
opacity: 1;
.hidden {
display: none;
}
}
.center {
width: 100%;
}
}
}
// 音量控制
.slider-content {
padding: 10px 0px;
width: 50px;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--cover-second-color);
.n-slider {
--n-fill-color: var(--cover-main-color);
--n-fill-color-hover: var(--cover-main-color);
--n-handle-color: var(--cover-main-color);
}
.slider-num {
margin-top: 4px;
font-size: 12px;
}
}
</style>

View File

@@ -206,5 +206,18 @@ const { playState } = storeToRefs(status);
display: none;
}
}
@media (max-width: 700px) {
&.record {
.pointer {
width: 12vh;
top: -6vh;
}
.cover-img {
width: 40vh;
height: 40vh;
min-width: 40vh;
}
}
}
}
</style>

View File

@@ -48,7 +48,12 @@
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="privateFmSong?.artists" class="all-ar">
<span v-for="ar in privateFmSong.artists" :key="ar.id" class="ar">
<span
v-for="ar in privateFmSong.artists"
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
>
{{ ar.name }}
</span>
</div>
@@ -71,6 +76,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playMode === 'fm' && playLoading"
:focusable="false"
class="play-control"
color="#efefef"
type="primary"
@@ -136,8 +142,8 @@ const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const router = useRouter();
const { privateFmSong, playMode } = storeToRefs(music);
const { playLoading, playState, coverTheme } = storeToRefs(status);
const { privateFmSong } = storeToRefs(music);
const { playLoading, playState, coverTheme, playMode } = storeToRefs(status);
// 播放暂停
const fmPlayOrPause = () => {

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