Compare commits

..

24 Commits

Author SHA1 Message Date
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
92 changed files with 4888 additions and 2345 deletions

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

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

View File

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

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

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

View File

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

View File

@@ -16,7 +16,7 @@ RUN [ ! -e ".env" ] && cp .env.example .env || true
RUN npm run build
# nginx
FROM nginx:1.20.2-alpine as app
FROM nginx:1.25.3-alpine-slim as app
COPY --from=builder /app/out/renderer /usr/share/nginx/html

View File

@@ -19,9 +19,12 @@
> - 感谢您的尊重与理解
- 本项目采用 [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
@@ -142,9 +138,9 @@ docker-compose up -d
```bash
# 拉取
docker pull imsyy/splayer:2.0.0-beta.5
docker pull imsyy/splayer:latest
# 运行
docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:2.0.0-beta.5
docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
```
以上步骤成功后,将会在本地 [localhost:7899](http://localhost:7899/) 启动,如需更换端口,请自行修改命令行中的端口号
@@ -228,7 +224,6 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:2.0.0-beta.5
- [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)
## 📢 免责声明
@@ -257,7 +252,6 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:2.0.0-beta.5
<details>
<summary>查看目录结构详情</summary>
> ChatGPT 写的,如有错误,请见谅
```dir
@@ -423,4 +417,9 @@ docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:2.0.0-beta.5
│   └── 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')
}

6
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']
@@ -39,12 +40,12 @@ 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']
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NH1: typeof import('naive-ui')['NH1']
NH3: typeof import('naive-ui')['NH3']
NH4: typeof import('naive-ui')['NH4']
@@ -76,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']
@@ -99,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']

View File

@@ -97,9 +97,9 @@ 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",

View File

@@ -121,12 +121,11 @@ class MainProcess {
icon: nativeImage.createFromPath(join(__dirname, "../../public/images/icons/favicon.png")),
// 预加载
webPreferences: {
// devTools: is.dev, //是否开启 DevTools
devTools: is.dev,
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
hardwareAcceleration: true,
nodeIntegration: true,
},
});

View File

@@ -1,9 +1,9 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
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 NodeID3 from "node-id3";
import axios from "axios";
import fs from "fs/promises";
@@ -203,6 +203,8 @@ const mainIpcMain = (win) => {
directory: path,
filename: `${songName}.${songType}`,
});
// 若不为 mp3则不进行元信息写入
if (songType !== "mp3") return true;
// 下载封面
const coverDownload = await download(win, songData.cover, {
directory: path,
@@ -218,10 +220,10 @@ const mainIpcMain = (win) => {
image: coverDownload.getSavePath(),
};
// 保存修改后的元数据
write(songTag, songDownload.getSavePath());
const isSuccess = NodeID3.write(songTag, songDownload.getSavePath());
// 删除封面
await fs.unlink(coverDownload.getSavePath());
return true;
return isSuccess;
} else {
console.log(`目录不存在:${path}`);
return false;

View File

@@ -1,6 +1,8 @@
import { dialog, shell } from "electron";
import { is } from "@electron-toolkit/utils";
import { autoUpdater } from "electron-updater";
import pkg from "electron-updater";
const { autoUpdater } = pkg;
// 更新弹窗
const hasNewVersion = (info) => {

View File

@@ -1,25 +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" 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>
<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>

View File

@@ -1,16 +1,19 @@
{
"name": "splayer",
"version": "2.0.0-beta.5",
"version": "2.0.0",
"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",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
@@ -23,50 +26,50 @@
"build:linux": "npm run build && electron-builder --linux --config"
},
"dependencies": {
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^2.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.14.0",
"axios": "^1.4.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",
"music-metadata": "7.14.0",
"node-id3": "^0.2.6",
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"plyr": "^3.7.8",
"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.36.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.4"
}
}

2479
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: 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

@@ -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"
@@ -224,7 +224,7 @@ onUnmounted(() => {
.sider-all {
height: 100%;
}
@media (max-width: 720px) {
@media (max-width: 900px) {
display: none;
}
}

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

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

@@ -4,6 +4,7 @@
"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",
@@ -89,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,8 +66,9 @@
<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>
<!-- 信息 -->
@@ -286,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,
@@ -356,7 +371,7 @@ const jumpLink = (data, type) => {
top: 0;
opacity: 1;
}
.play {
.play-btn {
opacity: 1;
transform: translateY(0);
&:hover {
@@ -385,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);
@@ -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

@@ -123,15 +123,18 @@
<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);
@@ -158,8 +161,13 @@ const playlistOpen = () => {
// 播放歌曲
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;
// 是否为当前播放歌曲
@@ -239,9 +247,9 @@ const removeSong = async (index) => {
cursor: pointer;
.num,
.play {
width: 30px;
height: 30px;
min-width: 30px;
width: 34px;
height: 34px;
min-width: 34px;
border-radius: 8px;
margin-right: 16px;
display: flex;
@@ -352,6 +360,7 @@ 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;
@@ -380,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,19 +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].updateTime && type === 'dj'" class="update" depth="3">
<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="data[0].playCount && type === 'dj'" class="count" 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="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(
@@ -31,6 +34,7 @@
}"
: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, type)
@@ -40,7 +44,7 @@
<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>
<!-- 封面 -->
@@ -67,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"
@@ -122,15 +129,16 @@
: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"> 电台节目 </n-text>
<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>
@@ -139,12 +147,15 @@
<template v-if="showAlbum && type !== 'dj'">
<n-text
v-if="item.album"
class="album"
@click.stop="item.album !== 'string' ? router.push(`/album?id=${item.album.id}`) : null"
class="album hidden"
@click.stop="
typeof item.album === 'object' ? router.push(`/album?id=${item.album.id}`) : null
"
@dblclick.stop
>
{{ typeof item.album === "string" ? item.album : item.album.name }}
{{ typeof item.album === "object" ? item.album?.name || "未知专辑" : item.album }}
</n-text>
<n-text v-else class="album">未知专辑</n-text>
<n-text v-else class="album hidden">未知专辑</n-text>
</template>
<!-- 操作 -->
<div v-if="type !== 'dj'" class="action">
@@ -164,20 +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" depth="3">
<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" depth="3">
<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
@@ -187,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
@@ -215,28 +252,38 @@
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, playSearch } = 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({
@@ -285,7 +332,8 @@ const props = defineProps({
// 分页数据
const pageNumber = ref(1);
// 右键菜单
// 子组件
const songListDrawerRef = ref(null);
const songListDropdownRef = ref(null);
// 当前索引
@@ -312,6 +360,11 @@ 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 = props.type === "song" ? "normal" : "dj";
// 检查当前页面
@@ -358,6 +411,71 @@ const pageNumberChange = (page) => {
});
};
// 检查是否可执行双击
const checkCanClick = (data, item, index) => {
if (window.innerWidth > 700) return false;
playSong(data, item, index);
};
// 云盘歌曲删除
const delCloudSong = (data, song, index) => {
console.log(data, song, index);
$dialog.warning({
title: "确认删除",
content: `确认从云盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await setCloudDel(song.id);
if (result.code == 200) {
data.splice(index, 1);
$message.success("删除成功");
} else {
$message.error("删除失败,请重试");
}
},
});
};
// 歌单歌曲删除
const deletePlaylistSong = (pid, song, data, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从歌单中移除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
const result = await addSongToPlayList(pid, song?.id, "del");
if (result.status === 200) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
// 本地歌曲删除
const delLocalSong = (data, song, index) => {
$dialog.warning({
title: "确认删除",
content: `确认从本地磁盘中删除 ${song.name}?该操作无法撤销!`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
console.log(data, song, index);
const result = await electron.ipcRenderer.invoke("deleteFile", song?.path);
if (result) {
data.length === 1 ? data.splice(0, 1, "empty") : data.splice(index, 1);
$message.success("歌曲删除成功");
} else {
$message.error("歌曲删除失败,请重试");
}
},
});
};
// 监听歌曲变化
watch(
() => music.getPlaySongData?.id,
@@ -397,16 +515,20 @@ onBeforeUnmount(() => {
.has-cover {
margin-right: 66px;
}
.control {
width: 40px;
}
.update {
width: 80px;
text-align: center;
margin-right: auto;
}
.count {
width: 120px;
text-align: center;
}
.duration {
width: 40px;
width: 50px;
text-align: center;
}
.size {
@@ -447,8 +569,7 @@ onBeforeUnmount(() => {
}
}
}
.num,
.play {
.num {
width: 30px;
height: 30px;
min-width: 30px;
@@ -552,6 +673,9 @@ onBeforeUnmount(() => {
transform: scale(1);
}
}
.more {
display: none;
}
}
.update {
width: 80px;
@@ -562,7 +686,7 @@ onBeforeUnmount(() => {
text-align: center;
}
.duration {
width: 40px;
width: 50px;
text-align: center;
}
.size {
@@ -624,12 +748,49 @@ onBeforeUnmount(() => {
transform: scale(0.9);
}
}
@media (max-width: 700px) {
.song-list-header,
.songs {
.hidden {
display: none;
}
}
.songs {
.num {
font-size: 12px;
width: 28px;
height: 28px;
min-width: 28px;
}
.info {
.title {
.name {
font-size: 15px;
}
}
.artist {
font-size: 12px;
}
}
.action {
width: 60px;
justify-content: flex-end;
.more {
display: inline-block;
margin-left: 12px;
}
}
}
}
}
.loading {
margin: 60px 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
:deep(.n-skeleton) {
&:nth-of-type(1) {
margin-top: 0;
}
height: 80px;
margin-top: 12px;
border-radius: 8px;
}
}
</style>

View File

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

View File

@@ -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);
// 右键菜单数据
@@ -72,7 +71,11 @@ const renderSong = (song, isSong) => {
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 || "未知曲目"]),
isSong
@@ -109,8 +112,8 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
// 当前状态
const isFm = playMode.value === "fm";
const isSong = type === "song";
const isLocalSong = song?.path ? true : false;
const isHasMv = song.mv && song.mv !== 0 ? true : false;
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);
// 生成菜单
@@ -141,7 +144,7 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
{
key: "next-play",
label: "下一首播放",
show: isSong && playMode.value !== "dj" && playSongData.value?.id !== song.id && !isFm,
show: isSong && playMode.value !== "dj" && music.getPlaySongData?.id !== song.id && !isFm,
props: {
onClick: () => {
playMode.value = "song";
@@ -237,7 +240,7 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
show: !isCloud && isUserPlaylist,
props: {
onClick: () => {
deletePlaylistSong(sourceId, song, data, index);
emit("deletePlaylistSong", data, song, index);
},
},
icon: renderIcon("delete"),
@@ -259,7 +262,7 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
show: isCloud,
props: {
onClick: () => {
delCloudSong(data, song, index);
emit("delCloudSong", data, song, index);
},
},
icon: renderIcon("delete"),
@@ -278,10 +281,10 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
{
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"),
@@ -328,65 +331,6 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
}
};
// 云盘歌曲删除
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

@@ -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,13 +32,13 @@
</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"
@@ -48,7 +48,7 @@
>
下载
</n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -2,13 +2,12 @@
<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"
>
@@ -27,6 +26,7 @@
</n-tabs>
<!-- 关闭登录弹窗 -->
<n-button
:focusable="false"
class="close"
strong
secondary

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

@@ -219,6 +219,15 @@ const userMenuSelect = (key) => {
margin-left: 2px;
}
}
@media (max-width: 700px) {
padding: 0;
.avatar {
margin: 0;
}
.user-data {
display: none;
}
}
}
</style>

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,7 +3,7 @@
<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"
@@ -31,15 +31,21 @@
<div class="player">
<!-- 歌曲信息 -->
<div class="info">
<div class="cover" @click.stop="openFullPlayer">
<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,12 +60,12 @@
</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">
@@ -177,6 +183,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playLoading"
:focusable="false"
tag="div"
type="primary"
class="play-control"
@@ -202,7 +209,7 @@
<Transition name="fade" mode="out-in">
<div :key="playMode" class="menu">
<!-- 时间进度 -->
<div class="time">
<div class="time hidden">
<n-text class="played" depth="3">{{ playTimeData.played }}</n-text>
<n-text depth="3">{{ playTimeData.durationTime }}</n-text>
</div>
@@ -214,27 +221,30 @@
trigger="hover"
@select="playModeChange"
>
<div class="mode" @click.stop @dblclick.stop>
<n-icon size="22">
<SvgIcon
:icon="
playHeartbeatMode
? 'heartbit'
: playSongMode === 'normal'
<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'
"
isSpecial
/>
</n-icon>
</div>
? 'shuffle'
: 'repeat-song'
"
isSpecial
/>
</n-icon>
</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>
<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>
@@ -262,7 +272,12 @@
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon class="volume" size="22" @click.stop="setVolumeMute" @wheel="changeVolume">
<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"
@@ -281,7 +296,7 @@
padding: '10px 0',
width: '50px',
}"
class="slider-content"
class="slider-content hidden"
@wheel="changeVolume"
>
<n-slider
@@ -335,6 +350,7 @@ import {
setVolume,
setVolumeMute,
setRate,
processSpectrum,
} from "@/utils/Player";
import { getSongPlayTime } from "@/utils/timeTools";
import debounce from "@/utils/debounce";
@@ -347,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, showPlaylistCount } = storeToRefs(settings);
} = storeToRefs(status);
const { showYrc, bottomLyricShow, showSider, showPlaylistCount, showSpectrums, playCoverType } =
storeToRefs(settings);
// 子组件
const addPlaylistRef = ref(null);
@@ -374,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",
@@ -472,6 +491,7 @@ const openFullPlayer = () => {
$message.warning("当前为电台模式,无法开启播放器");
return false;
}
if (showSpectrums.value && typeof $player !== "undefined") processSpectrum($player);
showFullPlayer.value = true;
};
@@ -487,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;
@@ -496,7 +521,11 @@ const playModeChange = (mode) => {
playListOld.value = [];
}
// 切换模式
playSongMode.value = mode;
if (mode) {
playSongMode.value = mode;
} else {
playSongMode.value = modeMap[playSongMode.value] || "normal";
}
};
// 音量条鼠标滚动
@@ -630,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;
@@ -815,6 +871,31 @@ watch(
}
}
}
@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 {
bottom: 0;

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}`)
@@ -126,16 +132,16 @@
<SvgIcon icon="comment-text" />
</n-icon>
<!-- 播放模式 -->
<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
/>
@@ -166,17 +172,20 @@ 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);
} = storeToRefs(status);
// 子组件
const addPlaylistRef = ref(null);
@@ -372,5 +381,21 @@ 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%;
}
}
}
</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

@@ -76,6 +76,7 @@
<!-- 播放暂停 -->
<n-button
:loading="playMode === 'fm' && playLoading"
:focusable="false"
class="play-control"
color="#efefef"
type="primary"
@@ -141,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 = () => {

View File

@@ -0,0 +1,161 @@
<!-- 播放器 - 音乐频谱 -->
<template>
<div :style="{ opacity: show ? '0.6' : '0.1' }" class="spectrum">
<canvas ref="canvasRef" :style="{ height: height + 'px' }" class="spectrum-line" />
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteStatus } from "@/stores";
const props = defineProps({
show: {
type: Boolean,
default: true,
},
height: {
type: Number,
default: 80,
},
barWidth: {
type: Number,
default: 4,
},
radius: {
type: Number,
default: 2,
},
});
const status = siteStatus();
const { spectrumsData } = storeToRefs(status);
// canvas
const canvasRef = ref(null);
const isKeepDrawing = ref(true);
/**
* 绘制音乐频谱图
* @param {Array} data - 包含音频频谱数据的数组
*/
const drawSpectrum = (data) => {
if (!isKeepDrawing.value) return;
// 设置画布宽度,最大为 1600
canvasRef.value.width = document.body.clientWidth >= 1600 ? 1600 : document.body.clientWidth;
// 设置画布高度
canvasRef.value.height = props.height;
// 获取2D上下文
const ctx = canvasRef.value.getContext("2d");
// 柱形宽度
const barWidth = props.barWidth;
// 圆角半径
const cornerRadius = props.radius;
// 清除画布
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
// 遍历音频频谱数据
for (let i = 0; i < 360; i++) {
// 计算柱形高度
const barHeight = (data[i] / 255) * canvasRef.value.height;
// 计算柱形的 x 和 y 坐标
const x = i * (barWidth * 2);
const y = canvasRef.value.height - barHeight;
// 设置柱形颜色,如果未设置则使用默认颜色
ctx.fillStyle = `rgb(${status.coverTheme?.light?.shadeTwo})` || "#efefef";
// 检查柱形高度是否大于0避免绘制高度为0的柱形
if (barHeight > 0) {
// 调用绘制圆角矩形的函数
roundRect(ctx, x, y, barWidth, barHeight, cornerRadius);
}
}
// 请求下一帧
requestAnimationFrame(() => {
drawSpectrum(spectrumsData.value);
});
};
/**
* 绘制圆角矩形
* @param {CanvasRenderingContext2D} ctx - 2D上下文
* @param {number} x - 矩形左上角 x 坐标
* @param {number} y - 矩形左上角 y 坐标
* @param {number} width - 矩形宽度
* @param {number} height - 矩形高度
* @param {number} radius - 圆角半径
*/
const roundRect = (ctx, x, y, width, height, radius) => {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
};
onMounted(() => {
drawSpectrum(spectrumsData.value);
});
onBeforeUnmount(() => {
isKeepDrawing.value = false;
});
</script>
<style lang="scss" scoped>
.spectrum {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
opacity: 0.6;
z-index: -1;
pointer-events: none;
transition: opacity 0.3s;
mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 10%,
#fff 15%,
#fff 85%,
hsla(0, 0%, 100%, 0.6) 90%,
hsla(0, 0%, 100%, 0)
);
-webkit-mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 10%,
#fff 15%,
#fff 85%,
hsla(0, 0%, 100%, 0.6) 90%,
hsla(0, 0%, 100%, 0)
);
.spectrum-line {
mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 90%,
hsla(0, 0%, 100%, 0.6) 95%,
hsla(0, 0%, 100%, 0)
);
-webkit-mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 90%,
hsla(0, 0%, 100%, 0.6) 95%,
hsla(0, 0%, 100%, 0)
);
}
}
</style>

View File

@@ -2,25 +2,26 @@
<template>
<Transition name="fadeDown" mode="out-in" @before-enter="getSearchHotData">
<n-card
v-if="status.searchInputFocus && !searchValue && (data.searchHistory[0] || searchHotData[0])"
v-if="searchInputFocus && !searchValue && (searchHistory[0] || searchHotData[0])"
class="search-suggestions"
content-style="padding: 0"
@click="emit('closeSearch')"
>
<n-scrollbar class="scrollbar">
<!-- 历史记录 -->
<div v-if="settings.searchHistory && data.searchHistory[0]" class="history">
<div v-if="showSearchHistory && searchHistory[0]" class="history">
<div class="title">
<n-icon>
<SvgIcon icon="history" />
</n-icon>
<n-text>搜索历史</n-text>
<n-icon class="history-delete" depth="3" @click="delSearchHistory">
<n-icon class="history-delete" depth="3" @click.stop="delSearchHistory">
<SvgIcon icon="delete" />
</n-icon>
</div>
<n-space>
<n-flex>
<n-tag
v-for="(item, index) in data.searchHistory"
v-for="(item, index) in searchHistory"
:key="index"
:bordered="false"
round
@@ -28,7 +29,7 @@
>
{{ item }}
</n-tag>
</n-space>
</n-flex>
</div>
<!-- 热搜榜 -->
<div v-if="searchHotData[0]" class="hot-list">
@@ -50,13 +51,13 @@
<n-text class="text">{{ item.searchWord }}</n-text>
<n-tag
v-if="item.iconUrl"
class="tag"
round
size="small"
type="error"
:type="item.iconType == 1 ? 'error' : 'warning'"
:bordered="false"
class="tag"
size="small"
round
>
{{ item.iconType == 1 ? "HOT" : "UP" }}
{{ item.iconType === 1 ? "HOT" : "UP" }}
</n-tag>
<n-text class="hot-num" depth="3">{{ item.score }}</n-text>
</div>
@@ -70,6 +71,7 @@
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData, siteStatus, siteSettings } from "@/stores";
import { getSearchHot } from "@/api/search";
import { getCacheData } from "@/utils/helper";
@@ -77,7 +79,10 @@ import { getCacheData } from "@/utils/helper";
const data = siteData();
const status = siteStatus();
const settings = siteSettings();
const emit = defineEmits(["toSearch", "delSearchHistory"]);
const { searchHistory } = storeToRefs(data);
const { searchInputFocus } = storeToRefs(status);
const { showSearchHistory } = storeToRefs(settings);
const emit = defineEmits(["toSearch", "closeSearch"]);
// 搜索内容
// eslint-disable-next-line no-unused-vars
@@ -117,7 +122,7 @@ const delSearchHistory = () => {
positiveText: "确认",
negativeText: "取消",
onPositiveClick: () => {
data.searchHistory = [];
searchHistory.value = [];
},
});
};
@@ -176,7 +181,7 @@ onBeforeMount(() => {
}
}
}
.n-space {
.n-flex {
margin-top: 8px;
.n-tag {
font-size: 13px;
@@ -267,5 +272,14 @@ onBeforeMount(() => {
}
}
}
@media (max-width: 512px) {
position: fixed;
top: 58px;
border-radius: 0px;
width: 100%;
:deep(.scrollbar) {
max-height: calc(100vh - 58px);
}
}
}
</style>

View File

@@ -4,13 +4,13 @@
<n-input
ref="searchInpRef"
v-model:value="searchInputValue"
:class="status.searchInputFocus ? 'input focus' : 'input'"
:class="searchInputFocus ? 'input focus' : 'input'"
:input-props="{ autoComplete: false }"
:placeholder="searchPlaceholder"
:allow-input="noSideSpace"
placeholder="搜索音乐 / 视频"
round
clearable
@focus="searchInputFocus"
@focus="searchInputToFocus"
@keyup.enter="toSearch(searchInputValue)"
@click.stop
>
@@ -22,16 +22,20 @@
</n-input>
<!-- 搜索框遮罩 -->
<Transition name="fade" mode="out-in">
<div
v-show="status.searchInputFocus"
class="search-mask"
@click.stop="status.searchInputFocus = false"
/>
<div v-show="searchInputFocus" class="search-mask" @click.stop="searchInputFocus = false" />
</Transition>
<!-- 热搜榜及历史 -->
<SearchHot :searchValue="searchInputValue?.trim()" @toSearch="toSearch" />
<SearchHot
:searchValue="searchInputValue?.trim()"
@toSearch="toSearch"
@closeSearch="closeSearch"
/>
<!-- 搜索建议 -->
<SearchSuggestions :searchValue="searchInputValue?.trim()" @toSearch="toSearch" />
<SearchSuggestions
:searchValue="searchInputValue?.trim()"
@toSearch="toSearch"
@closeSearch="closeSearch"
/>
</div>
</template>
@@ -39,6 +43,7 @@
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getSongDetail } from "@/api/song";
import { getSearchDefault } from "@/api/search";
import { siteData, siteStatus, musicData } from "@/stores";
import { addSongToNext, initPlayer } from "@/utils/Player";
import formatData from "@/utils/formatData";
@@ -47,33 +52,65 @@ const router = useRouter();
const music = musicData();
const status = siteStatus();
const data = siteData();
const { searchHistory } = storeToRefs(data);
const { playSongData } = storeToRefs(music);
const { searchInputFocus } = storeToRefs(status);
// 搜索框数据
const searchInpRef = ref(null);
const searchInputValue = ref("");
const searchInterval = ref(null);
const searchRealkeyword = ref(null);
const searchPlaceholder = ref("搜索音乐 / 视频");
// 搜索框输入限制
const noSideSpace = (value) => !value.startsWith(" ");
// 搜索框 focus
const searchInputFocus = () => {
const searchInputToFocus = () => {
searchInpRef.value?.focus();
status.searchInputFocus = true;
searchInputFocus.value = true;
};
// 添加搜索历史
const setSearchHistory = (name) => {
if (!name || !name?.trim()) return false;
const index = data.searchHistory.indexOf(name);
const index = searchHistory.value.indexOf(name);
if (index !== -1) {
data.searchHistory.splice(index, 1);
searchHistory.value.splice(index, 1);
}
data.searchHistory.unshift(name);
if (data.searchHistory.length > 30) {
data.searchHistory.pop();
searchHistory.value.unshift(name);
if (searchHistory.value.length > 30) {
searchHistory.value.pop();
}
};
// 更换搜索框关键词
const updatePlaceholder = async () => {
try {
const result = await getSearchDefault();
searchPlaceholder.value = result.data.showKeyword;
searchRealkeyword.value = result.data.realkeyword;
} catch (error) {
console.error("搜索关键词获取失败:", error);
searchPlaceholder.value = "搜索音乐 / 视频";
}
};
// 更新搜索框关键词
const changePlaceholder = () => {
updatePlaceholder();
// 5分钟
searchInterval.value = setInterval(updatePlaceholder, 5 * 60 * 1000);
};
// 关闭搜索
const closeSearch = () => {
// 取消聚焦状态
status.searchInputFocus = false;
searchInpRef.value?.blur();
};
// 直接播放单曲
const toPlaySong = async (id) => {
try {
@@ -94,10 +131,17 @@ const toPlaySong = async (id) => {
// 前往搜索
const toSearch = (val, type = "song") => {
if (!val) return false;
// 未输入内容且不存在推荐
if (!val && searchPlaceholder.value === "搜索音乐 / 视频") return false;
if (!val && searchPlaceholder.value !== "搜索音乐 / 视频" && searchRealkeyword.value) {
val = searchRealkeyword.value?.trim();
}
// 取消聚焦状态
status.searchInputFocus = false;
searchInpRef.value?.blur();
closeSearch();
// 更新推荐
updatePlaceholder();
// 触发测试
if (Number(val) === 114514) return router.push("/test");
// 判断类型
switch (type) {
// 直接搜索
@@ -147,6 +191,14 @@ const toSearch = (val, type = "song") => {
break;
}
};
onMounted(() => {
changePlaceholder();
});
onBeforeUnmount(() => {
clearInterval(searchInterval.value);
});
</script>
<style lang="scss" scoped>
@@ -172,5 +224,21 @@ const toSearch = (val, type = "song") => {
backdrop-filter: blur(20px);
-webkit-app-region: no-drag;
}
@media (max-width: 700px) {
width: 100%;
margin-right: 12px;
.input {
width: 100%;
&.focus {
width: 100%;
}
}
}
@media (max-width: 512px) {
.search-mask {
background-color: transparent;
backdrop-filter: blur(0);
}
}
}
</style>

View File

@@ -7,13 +7,14 @@
@after-leave="changeSuggestionsHeights"
>
<n-card
v-if="status.searchInputFocus && searchValue"
v-if="searchInputFocus && searchValue"
class="search-suggestions"
content-style="padding: 0"
:style="{
height: `${suggestionsHeights}px`,
border: suggestionsHeights === 0 ? 'none' : null,
}"
@click="emit('closeSearch')"
>
<n-scrollbar class="scrollbar">
<!-- 直接搜索 -->
@@ -61,12 +62,14 @@
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteStatus } from "@/stores";
import { getSearchSuggest } from "@/api/search";
import debounce from "@/utils/debounce";
const status = siteStatus();
const emit = defineEmits(["toSearch"]);
const { searchInputFocus } = storeToRefs(status);
const emit = defineEmits(["toSearch", "closeSearch"]);
// 搜索内容
const props = defineProps({
@@ -233,5 +236,17 @@ watch(
}
}
}
@media (max-width: 512px) {
position: fixed;
top: 58px;
border-radius: 0px;
width: 100%;
max-height: calc(100vh - 58px);
min-height: calc(100vh - 58px);
:deep(.scrollbar) {
max-height: calc(100vh - 58px);
min-height: calc(100vh - 58px);
}
}
}
</style>

View File

@@ -3,6 +3,7 @@
<div class="title-bar">
<n-divider vertical />
<n-button
:focusable="false"
class="bar-icon"
tag="div"
style="margin-left: 0"
@@ -16,14 +17,14 @@
</n-icon>
</template>
</n-button>
<n-button class="bar-icon" tag="div" quaternary circle @click="maxOrRestore">
<n-button :focusable="false" class="bar-icon" tag="div" quaternary circle @click="maxOrRestore">
<template #icon>
<n-icon :depth="2">
<SvgIcon :icon="defaultWindowState ? 'window-restore' : 'window-maximize'" />
</n-icon>
</template>
</n-button>
<n-button class="bar-icon" tag="div" quaternary circle @click="openCloseTip">
<n-button :focusable="false" class="bar-icon" tag="div" quaternary circle @click="openCloseTip">
<template #icon>
<n-icon :depth="2">
<SvgIcon icon="window-close" />
@@ -46,15 +47,15 @@
<n-text class="close-tip">确认关闭软件吗</n-text>
<n-checkbox v-model:checked="closeTipCheckbox"> 记住且不再询问 </n-checkbox>
<template #footer>
<n-space justify="space-between">
<n-flex justify="space-between">
<n-button strong secondary @click="closeCloseTip('cancel')"> 取消 </n-button>
<n-space class="type">
<n-flex class="type">
<n-button strong secondary @click="closeCloseTip('close')"> 退出 </n-button>
<n-button type="primary" strong secondary @click="closeCloseTip('hide')">
最小化
</n-button>
</n-space>
</n-space>
</n-flex>
</n-flex>
</template>
</n-modal>
</div>

View File

@@ -37,6 +37,18 @@ router.beforeEach((to, from, next) => {
}
if (typeof $changeLogin !== "undefined") $changeLogin();
}
}
// 是否为本地功能
else if (to.meta.needLocal) {
if (checkPlatform.electron()) {
next();
} else {
$message.error("客户端独占功能");
if (typeof $loadingBar !== "undefined" && !checkPlatform.electron()) {
$loadingBar.error();
}
next("/403");
}
} else {
next();
}

View File

@@ -112,6 +112,15 @@ const routes = [
},
component: () => import("@/views/Comment.vue"),
},
// 歌曲详情
{
path: "/song",
name: "song",
meta: {
title: "歌曲详情",
},
component: () => import("@/views/Song.vue"),
},
// 最近播放
{
path: "/history",
@@ -244,7 +253,7 @@ const routes = [
name: "local",
meta: {
title: "本地歌曲",
needLogin: true,
needLocal: true,
show: checkPlatform.electron(),
},
component: () => import("@/views/Local/index.vue"),

View File

@@ -3,26 +3,12 @@ import { defineStore } from "pinia";
import { getPersonalFm, setFmToTrash } from "@/api/recommend";
import { changePlayIndex } from "@/utils/Player";
import { isLogin } from "@/utils/auth";
import { siteStatus } from "@/stores";
import formatData from "@/utils/formatData";
const useMusicDataStore = defineStore("musicData", {
state: () => {
return {
// 当前模式
// normal 正常 / fm 私人 FM / dj 电台
playMode: "normal",
// normal 顺序播放 / random 随机播放 / repeat 单曲循环
playSongMode: "normal",
// 是否为心动模式
playHeartbeatMode: false,
// 默认倍速
playRate: 1,
// 默认音量
playVolume: 0.7,
// 静音前音量
playVolumeMute: 0,
// 当前播放索引
playIndex: 0,
// 播放列表
playList: [],
playListOld: [],
@@ -39,16 +25,6 @@ const useMusicDataStore = defineStore("musicData", {
hasRoma: false,
hasYrc: false,
},
// 当前歌曲歌词播放索引
playSongLyricIndex: -1,
// 播放时长数据
playTimeData: {
currentTime: 0,
duration: 0,
bar: 0,
played: "00:00",
durationTime: "00:00",
},
// 本地歌曲目录
localSongPath: [],
// 私人 FM 数据
@@ -60,7 +36,8 @@ const useMusicDataStore = defineStore("musicData", {
getters: {
// 获取当前播放歌曲
getPlaySongData(state) {
return state.playMode === "fm" && state.privateFmSong
const status = siteStatus();
return status.playMode === "fm" && state.privateFmSong
? state.privateFmSong
: state.playSongData;
},
@@ -96,68 +73,9 @@ const useMusicDataStore = defineStore("musicData", {
}
},
// 更改私人FM
// async setPersonalFm(getNext = false) {
// try {
// // 获取私人FM数据
// const getPersonalFmData = async () => {
// const result = await getPersonalFm();
// this.privateFmData = formatData(result.data, "song");
// };
// // 未登录
// // if (!isLogin()) return false;
// // 若不存在fm数据则获取
// if (!this.privateFmData?.length) {
// await getPersonalFmData();
// }
// // 若当前无歌曲
// if (Object.keys(this.privateFmSong).length === 0) {
// this.privateFmSong = this.privateFmData[0];
// }
// // 若上次索引为旧列表且列表只有一首
// if (
// this.privateFmIndex === -1 &&
// this.privateFmData?.length === 1 &&
// !Object.keys(this.privateFmSong)?.length
// ) {
// this.privateFmSong = this.privateFmData[0];
// }
// // 若立即播放
// if (getNext) {
// // 更改播放模式
// this.playMode = "fm";
// // 更改当前fm歌曲
// this.privateFmIndex++;
// // 未登录 - 仅一首
// if (this.privateFmData.length === 1) {
// console.log("只有一首歌,重新加载列表");
// await getPersonalFmData();
// this.privateFmSong = this.privateFmData[0];
// return true;
// }
// if (this.privateFmIndex < this.privateFmData.length - 1) {
// console.log("在歌单内");
// this.privateFmSong = this.privateFmData[this.privateFmIndex];
// } else if (this.privateFmIndex === this.privateFmData.length - 1) {
// console.log("是最后一首,更新列表");
// this.privateFmSong = this.privateFmData[this.privateFmData.length - 1];
// this.privateFmIndex = -1;
// await getPersonalFmData();
// } else {
// $message.error("私人FM出现错误重新加载");
// this.privateFmIndex = 0;
// await this.setPersonalFm(getNext);
// }
// return true;
// }
// } catch (error) {
// this.privateFmSong = {};
// console.error("私人FM加载失败", error);
// $message.error("私人FM出现错误请重试");
// }
// },
// 更改私人FM
async setPersonalFm(getNext = false) {
try {
const status = siteStatus();
// 获取私人FM数据
const getPersonalFmData = async () => {
const result = await getPersonalFm();
@@ -174,7 +92,7 @@ const useMusicDataStore = defineStore("musicData", {
// 若需要播放下一首
if (getNext) {
// 更改播放模式
this.playMode = "fm";
status.playMode = "fm";
// 增加索引
this.privateFmIndex++;
// 判断索引是否在列表范围内
@@ -198,11 +116,12 @@ const useMusicDataStore = defineStore("musicData", {
async setPersonalFmToTrash(id) {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const status = siteStatus();
const result = await setFmToTrash(id);
if (result.code === 200) {
$message.success("已移至垃圾桶");
// 更改播放模式
this.playMode = "fm";
status.playMode = "fm";
// 下一曲
changePlayIndex("next", true);
}

View File

@@ -8,10 +8,11 @@ const useSiteSettingsStore = defineStore("siteSettings", {
closeTip: true, // 关闭软件提醒弹窗
closeType: "hide", // 关闭方式 close 直接关闭 / hide 最小化到任务栏
showTaskbarProgress: false, // 显示歌曲任务栏进度
searchHistory: true, // 搜索历史
showSearchHistory: true, // 搜索历史
autoSignIn: true, // 自动签到
showGithub: true,
showSider: true, // 显示侧边栏
siderShowCover: false, // 侧边栏显示封面
// 主题部分
themeType: "dark",
themeAuto: false,
@@ -31,6 +32,8 @@ const useSiteSettingsStore = defineStore("siteSettings", {
memorySeek: true, // 记忆上次播放位置
playSearch: false, // 是否播放全部搜索结果
showPlaylistCount: true, // 是否显示播放列表数量
showSpectrums: false, // 是否显示音乐频谱
useMusicCache: false, // 是否采用音乐缓存
// 数量部分
loadSize: 50, // 每页加载数量
// 歌词部分

View File

@@ -19,7 +19,7 @@ const useSiteStatusStore = defineStore("siteStatus", {
// 全屏播放器状态
showFullPlayer: false,
// 播放器功能显示
playerControlShow: false,
playerControlShow: true,
controlTimeOut: null,
// 实时播放进度
playSeek: 0,
@@ -30,6 +30,33 @@ const useSiteStatusStore = defineStore("siteStatus", {
coverBackground: null,
// 纯净歌词模式
pureLyricMode: false,
// 音乐频谱数据
spectrumsData: [],
// 当前歌曲歌词播放索引
playSongLyricIndex: -1,
// 播放时长数据
playTimeData: {
currentTime: 0,
duration: 0,
bar: 0,
played: "00:00",
durationTime: "00:00",
},
// 默认倍速
playRate: 1,
// 默认音量
playVolume: 0.7,
// 静音前音量
playVolumeMute: 0,
// 当前播放索引
playIndex: 0,
// 当前模式
// normal 正常 / fm 私人 FM / dj 电台
playMode: "normal",
// normal 顺序播放 / random 随机播放 / repeat 单曲循环
playSongMode: "normal",
// 是否为心动模式
playHeartbeatMode: false,
};
},
getters: {},
@@ -39,7 +66,18 @@ const useSiteStatusStore = defineStore("siteStatus", {
{
key: "siteStatus",
storage: localStorage,
paths: ["asideMenuCollapsed", "pureLyricMode"],
paths: [
"asideMenuCollapsed",
"pureLyricMode",
"playRate",
"playVolume",
"playVolumeMute",
"playIndex",
"playMode",
"playSongMode",
"playHeartbeatMode",
"playTimeData",
],
},
],
});

View File

@@ -97,6 +97,7 @@ body,
.n-tabs {
--n-tab-border-radius: 6px !important;
.n-tabs-rail {
position: relative;
.n-tabs-tab-wrapper {
.n-tabs-tab {
&:hover {

View File

@@ -1,7 +1,7 @@
import { Howl, Howler } from "howler";
import { musicData, siteStatus, siteSettings } from "@/stores";
import { getSongUrl, getSongLyric, songScrobble } from "@/api/song";
import { checkPlatform, getLocalCoverData } from "@/utils/helper";
import { checkPlatform, getLocalCoverData, getBlobUrlFromUrl } from "@/utils/helper";
import { decode as base642Buffer } from "@/utils/base64";
import { getSongPlayTime } from "@/utils/timeTools";
import { getCoverGradient } from "@/utils/cover-color";
@@ -18,19 +18,28 @@ let scrobbleTimeout;
let testNumber = 0;
// 是否结束
let isPlayEnd = true;
// 频谱数据
let spectrumsData = {
audio: null,
analyser: null,
audioCtx: null,
};
// 默认标题
let defaultTitle = document.title;
/**
* 初始化播放器
*/
export const initPlayer = async (playNow = false) => {
try {
// 停止播放当前歌曲
// 停止播放
soundStop();
// 获取基础数据
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playList, playIndex, playMode } = music;
const { playIndex, playMode } = status;
const { playList } = music;
// 当前播放歌曲数据
const playSongData = music.getPlaySongData;
// 若为电台则更改 id
@@ -43,7 +52,7 @@ export const initPlayer = async (playNow = false) => {
}
const cover = isLocalSong ? music.playSongData?.localCover : playSongData?.coverSize;
// 歌词归位
music.playSongLyricIndex = -1;
status.playSongLyricIndex = -1;
// 若为 fm 模式,则清除当前歌曲信息
if (playMode === "fm") music.playSongData = {};
// 在线歌曲
@@ -145,7 +154,6 @@ const getNormalSongUrl = async (id, status, playNow) => {
const url = res.data[0].url.replace(/^http:/, "https:");
// 更改状态
if (playNow && url) status.playState = true;
status.playLoading = false;
return url;
} catch (error) {
status.playLoading = false;
@@ -200,27 +208,38 @@ const getFromUnblockMusic = async (data, status, playNow) => {
* @param {number} seek - 初始播放进度( 默认为 0
*/
export const createPlayer = async (src, autoPlay = true) => {
console.log("播放地址:", src);
try {
// pinia
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playSongSource } = music;
const { playMode } = status;
const { playSongSource, playList } = music;
const { showSpectrums, memorySeek, useMusicCache } = settings;
// 当前播放歌曲数据
const playSongData = music.getPlaySongData;
// 获取播放链接(非电台及云盘歌曲)
const songUrl =
useMusicCache && playMode !== "dj" && !playSongData.pc ? await getBlobUrlFromUrl(src) : src;
console.log("播放地址:", songUrl);
// 初始化播放器
if (player) soundStop();
player = new Howl({
src: [src],
format: ["mp3", "flac"],
src: [songUrl],
format: ["mp3", "flac", "dolby", "webm"],
html5: true,
pool: 10,
preload: true,
volume: music.playVolume,
rate: music.playRate,
preload: "metadata",
volume: status.playVolume,
rate: status.playRate,
});
// 允许跨域
const audioDom = player._sounds[0]._node;
audioDom.crossOrigin = "anonymous";
// 写入播放历史
music.setPlayHistory(playSongData);
// 生成音乐频谱
// 由于浏览器安全策略,无法在此处启动
if (showSpectrums && checkPlatform.electron()) processSpectrum(player);
// 加载完成
player?.once("load", () => {
console.info("🎵 加载完成", player, status.playState);
@@ -230,27 +249,17 @@ export const createPlayer = async (src, autoPlay = true) => {
fadePlayOrPause("play");
}
// 恢复进度(防止播放到结尾时触发 bug
if (
settings.memorySeek &&
music.playTimeData?.duration - music.playTimeData?.currentTime > 2
) {
setSeek(music.playTimeData?.currentTime ?? 0);
if (memorySeek && status.playTimeData?.duration - status.playTimeData?.currentTime > 2) {
setSeek(status.playTimeData?.currentTime ?? 0);
} else {
setSeek();
music.playTimeData.bar = "0";
status.playTimeData.bar = "0";
}
// 取消加载状态
status.playLoading = false;
// 发送歌曲名
if (checkPlatform.electron()) {
const songName = playSongData.name || "未知曲目";
const songArtist =
music.playMode === "dj"
? "电台节目"
: Array.isArray(playSongData.artists)
? playSongData.artists.map((ar) => ar.name).join(" / ")
: playSongData.artists || "未知歌手";
electron.ipcRenderer.send("songNameChange", songName + " - " + songArtist);
electron.ipcRenderer.send("songNameChange", getPlaySongName());
}
// 听歌打卡
if (isLogin() && !playSongData?.path) {
@@ -272,6 +281,8 @@ export const createPlayer = async (src, autoPlay = true) => {
if (checkPlatform.electron()) {
electron.ipcRenderer.send("songStateChange", true);
}
// 更改页面标题
if (!checkPlatform.electron()) document.title = getPlaySongName();
});
// 暂停播放
player?.on("pause", () => {
@@ -283,6 +294,8 @@ export const createPlayer = async (src, autoPlay = true) => {
if (checkPlatform.electron()) {
electron.ipcRenderer.send("songStateChange", false);
}
// 更改页面标题
if (!checkPlatform.electron()) document.title = defaultTitle || "SPlayer";
});
// 结束播放
player?.on("end", () => {
@@ -298,26 +311,37 @@ export const createPlayer = async (src, autoPlay = true) => {
}
});
// 加载失败
player?.on("loaderror", (_, errCode) => {
console.log("错误");
player?.on("loaderror", (id, errCode) => {
console.log("播放出现错误:", id, errCode);
// 更改状态
status.playLoading = false;
status.playState = false;
// https://github.com/goldfire/howler.js?tab=readme-ov-file#onloaderror-function
// 1-用户代理应用户请求中止了获取媒体资源的过程
// 2-某个描述的网络错误导致用户代理在确定资源可用后停止获取媒体资源
// 3-在确定资源可用后,对媒体资源进行解码时发生某种描述错误
// 4-由src属性或分配的媒体提供程序对象指示的媒体资源不合适
if (errCode === 3) {
$message.error("播放出错,媒体进行解码时发生错误");
} else if (errCode === 4) {
$message.error("播放出错,不支持的音频格式");
} else {
$message.error("播放遇到错误");
switch (errCode) {
case 1:
$message.error("播放出错,用户代理中止了获取媒体");
break;
case 2:
$message.error("播放出错,未知的网络错误");
break;
case 3:
$message.error("播放出错,媒体进行解码时发生错误");
break;
case 4:
$message.error("播放出错,不支持的音频格式或媒体资源不合适");
break;
default:
$message.error("播放遇到未知错误");
break;
}
// 下一曲
changePlayIndex();
if (playList.length > 1) {
changePlayIndex();
} else {
status.playState = false;
}
});
// 返回音频对象
return (window.$player = player);
} catch (error) {
console.error("播放遇到错误:" + error);
$message.error("播放遇到错误,请重试");
@@ -332,13 +356,14 @@ export const createPlayer = async (src, autoPlay = true) => {
export const changePlayIndex = async (type = "next", play = false) => {
// pinia
const music = musicData();
const state = siteStatus();
const status = siteStatus();
// 解构音乐数据
const { playMode, playSongMode, playHeartbeatMode, playList } = music;
const { playList } = music;
const { playSongMode, playMode, playHeartbeatMode } = status;
// 清除定时器
cleanAllInterval();
// 歌词归位
music.playSongLyricIndex = -1;
status.playSongLyricIndex = -1;
// 私人FM模式
if (playMode === "fm") {
await music.setPersonalFm(true);
@@ -351,16 +376,16 @@ export const changePlayIndex = async (type = "next", play = false) => {
// 根据播放模式确定要操作的播放列表和其长度
const listLength = playList?.length || 0;
// 根据播放歌曲模式执行不同的操作
if (state.hasNextSong) {
music.playIndex += type === "next" ? 1 : -1;
state.hasNextSong = false;
if (status.hasNextSong) {
status.playIndex += type === "next" ? 1 : -1;
status.hasNextSong = false;
} else {
if (playSongMode === "normal" || playHeartbeatMode) {
// 正常模式
music.playIndex += type === "next" ? 1 : -1;
status.playIndex += type === "next" ? 1 : -1;
} else if (playSongMode === "random") {
// 随机模式
music.playIndex = Math.floor(Math.random() * listLength);
status.playIndex = Math.floor(Math.random() * listLength);
} else if (playSongMode === "repeat") {
// 单曲循环模式
setSeek();
@@ -369,13 +394,13 @@ export const changePlayIndex = async (type = "next", play = false) => {
}
// 检查播放索引是否越界
if (playSongMode !== "repeat") {
if (music.playIndex < 0) {
music.playIndex = listLength - 1;
} else if (music.playIndex >= listLength) {
music.playIndex = 0;
if (status.playIndex < 0) {
status.playIndex = listLength - 1;
} else if (status.playIndex >= listLength) {
status.playIndex = 0;
}
// 赋值当前播放歌曲信息
const songData = playList?.[music.playIndex];
const songData = playList?.[status.playIndex];
if (songData) {
music.playSongData = songData;
// 渐出音乐
@@ -396,16 +421,16 @@ export const changePlayIndex = async (type = "next", play = false) => {
export const addSongToNext = (data, play = false) => {
try {
const music = musicData();
const state = siteStatus();
const status = siteStatus();
// 更改播放模式
state.hasNextSong = true;
status.hasNextSong = true;
// 查找是否存在于播放列表
const index = music.playList.findIndex((v) => v.id === data.id);
// 若存在
if (index !== -1) {
console.log("已存在", index);
// 移动至当前歌曲的下一曲
const currentSongIndex = music.playIndex;
const currentSongIndex = status.playIndex;
const nextSongIndex = currentSongIndex + 1;
// 如果移动的位置不是当前位置,且不是最后一首歌曲
if (index !== currentSongIndex && nextSongIndex < music.playList.length) {
@@ -413,13 +438,13 @@ export const addSongToNext = (data, play = false) => {
music.playList.splice(nextSongIndex, 0, music.playList.splice(index, 1)[0]);
}
// 更新播放索引
if (play) music.playIndex = nextSongIndex;
if (play) status.playIndex = nextSongIndex;
}
// 添加至播放列表
else {
// music.playList.push(data);
music.playList.splice(music.playIndex + 1, 0, data);
if (play) music.playIndex++;
music.playList.splice(status.playIndex + 1, 0, data);
if (play) status.playIndex++;
}
// 是否立即播放
play ? fadePlayOrPause("play") : $message.success("已添加至下一首播放");
@@ -433,9 +458,9 @@ export const addSongToNext = (data, play = false) => {
* @param {String} [type="play"] - 渐入渐出
*/
export const fadePlayOrPause = (type = "play") => {
const status = siteStatus();
const settings = siteSettings();
const duration = settings.songVolumeFade ? 300 : 0;
const music = musicData();
// 渐入
if (type === "play") {
if (player?.playing()) return;
@@ -443,12 +468,12 @@ export const fadePlayOrPause = (type = "play") => {
// 更新播放进度
setAllInterval();
player?.once("play", () => {
player?.fade(0, music.playVolume, duration);
player?.fade(0, status.playVolume, duration);
});
}
// 渐出
else if (type === "pause") {
player?.fade(music.playVolume, 0, duration);
player?.fade(status.playVolume, 0, duration);
player?.once("fade", () => {
player?.pause();
cleanAllInterval();
@@ -480,13 +505,6 @@ export const setVolume = (volume) => {
player?.volume(Number(volume));
};
/**
* 检查是否存在于播放器且正在播放
*/
export const checkPlayer = () => {
return player && player?.playing();
};
/**
* 停止播放器
*/
@@ -500,14 +518,14 @@ export const soundStop = () => {
* 调整静音
*/
export const setVolumeMute = () => {
const music = musicData();
if (music.playVolume > 0) {
music.playVolumeMute = music.playVolume;
music.playVolume = 0;
const status = siteStatus();
if (status.playVolume > 0) {
status.playVolumeMute = status.playVolume;
status.playVolume = 0;
} else {
music.playVolume = music.playVolumeMute;
status.playVolume = status.playVolumeMute;
}
player?.volume(music.playVolume);
player?.volume(status.playVolume);
};
/**
@@ -536,6 +554,7 @@ export const getSeek = () => {
const setAudioTime = () => {
if (player?.playing()) {
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const currentTime = player?.seek();
const duration = player?._duration;
@@ -548,8 +567,8 @@ const setAudioTime = () => {
const lyrics = lrcType ? music.playSongLyric.lrc : music.playSongLyric.yrc;
const lyricsIndex = lyrics?.findIndex((v) => v?.time >= currentTime);
// 赋值数据
music.playTimeData = { currentTime, duration, bar, played, durationTime };
music.playSongLyricIndex = lyricsIndex === -1 ? lyrics.length - 1 : lyricsIndex - 1;
status.playTimeData = { currentTime, duration, bar, played, durationTime };
status.playSongLyricIndex = lyricsIndex === -1 ? lyrics.length - 1 : lyricsIndex - 1;
// 显示进度条
if (checkPlatform.electron() && settings.showTaskbarProgress) {
electron.ipcRenderer.send("setProgressBar", bar);
@@ -624,8 +643,8 @@ const initMediaSession = async (data, cover, islocal, isDj) => {
artist: isDj
? "电台节目"
: islocal
? data.artists
: data.artists?.map((a) => a.name)?.join(" & "),
? data.artists
: data.artists?.map((a) => a.name)?.join(" & "),
album: isDj ? "电台节目" : islocal ? data.album : data.album.name,
artwork: islocal
? [
@@ -687,6 +706,119 @@ const getColorMainColor = async (islocal, cover) => {
}
};
/**
* 生成频谱数据 - 快速傅里叶变换( FFT
* @param {Object} sound - Howler.js 的音频对象
* @returns {void}
*/
export const processSpectrum = (sound) => {
try {
if (!spectrumsData.audioCtx) {
// 断开之前的连接
spectrumsData.audio?.disconnect();
spectrumsData.analyser?.disconnect();
spectrumsData.audioCtx?.close();
// 创建新的连接
spectrumsData.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 获取音频元素
const audioDom = sound._sounds[0]._node;
// 允许跨域请求
audioDom.crossOrigin = "anonymous";
// 创建音频源和分析器
const source = spectrumsData.audioCtx.createMediaElementSource(audioDom);
const analyser = spectrumsData.audioCtx.createAnalyser();
// 频谱分析器 FFT
analyser.fftSize = 1024;
// 连接音频源和分析器,再连接至音频上下文的目标
source.connect(analyser);
analyser.connect(spectrumsData.audioCtx.destination);
// 更新频谱数据
const dataArray = new Uint8Array(analyser.frequencyBinCount);
updateSpectrums(analyser, dataArray);
// 保存当前链接
spectrumsData.audio = source;
spectrumsData.analyser = analyser;
}
} catch (err) {
console.error("音乐频谱生成失败:" + err);
}
};
/**
* 更新音乐频谱数据
* @param {Object} analyser - 音频分析器
* @param {Uint8Array} dataArray - 频谱数据数组
*/
const updateSpectrums = (analyser, dataArray) => {
// pinia
const status = siteStatus();
analyser.getByteFrequencyData(dataArray);
status.spectrumsData = [...dataArray];
// 递归调用,持续更新频谱数据
requestAnimationFrame(() => {
updateSpectrums(analyser, dataArray);
});
};
/**
* 获取当前播放歌曲名
*/
const getPlaySongName = () => {
// pinia
const status = siteStatus();
const music = musicData();
const playSongData = music.getPlaySongData;
// 返回歌曲数据
const songName = playSongData.name || "未知曲目";
const songArtist =
status.playMode === "dj"
? "电台节目"
: Array.isArray(playSongData.artists)
? playSongData.artists.map((ar) => ar.name).join(" / ")
: playSongData.artists || "未知歌手";
return songName + " - " + songArtist;
};
/**
* 播放所有歌曲
* @param {Array} playlist - 包含歌曲信息的数组
* @param {string} mode - 播放模式
*/
export const playAllSongs = async (playlist, mode = "normal") => {
try {
// pinia
const music = musicData();
const status = siteStatus();
if (!playlist) return false;
// 关闭心动模式
status.playHeartbeatMode = false;
// 更改模式和歌单
status.playMode = mode;
music.playList = playlist.slice();
// 是否处于歌单内
const songId = music.getPlaySongData?.id;
const existingIndex = playlist.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
console.log("不在歌单内");
music.playSongData = playlist[0];
status.playIndex = 0;
// 初始化播放器
await initPlayer(true);
} else {
console.log("处于歌单内");
music.playSongData = playlist[existingIndex];
status.playIndex = existingIndex;
// 播放
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
} catch (error) {
console.error("播放全部歌曲出错:", error);
$message.error("播放全部歌曲出现错误");
}
};
/*
* 清除定时器
*/

View File

@@ -11,6 +11,7 @@ const globalEvents = (router) => {
// 显示播放器
electron.ipcRenderer.on("showPlayer", () => {
const status = siteStatus();
if (status.playMode === "dj") return false;
status.showFullPlayer = true;
});
// 播放或暂停

View File

@@ -1,5 +1,5 @@
import { playOrPause, setVolume } from "@/utils/Player";
import { musicData } from "@/stores";
import { siteStatus } from "@/stores";
/**
* 全局快捷键监听
@@ -21,12 +21,12 @@ const globalShortcut = (e, router) => {
// 调整音量
if (e.code === "ArrowUp" || e.code === "ArrowDown") {
const music = musicData();
const volume = music.playVolume;
const status = siteStatus();
const volume = status.playVolume;
const delta = e.code === "ArrowUp" ? 0.1 : -0.1;
const newVolume = Math.min(1, Math.max(0, volume + delta));
setVolume(newVolume);
music.playVolume = newVolume;
status.playVolume = newVolume;
}
};

View File

@@ -344,7 +344,7 @@ export const downloadFile = async (data, song, path = null) => {
* 将字节数格式化为可读的大小字符串。
* @param {number} bytes - 要格式化的字节数
* @param {number} [decimals=2] - 小数点位数
* @returns {string} - 格式化后的大小字符串(例如,"10 KB"
* @returns {string} - 格式化后的大小字符串("10 KB"
*/
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 K";
@@ -354,3 +354,33 @@ export const formatBytes = (bytes, decimals = 2) => {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};
/**
* 获取音频文件的 Blob 链接
* @param {string} url - 音频文件的网络链接
*/
// 上次生成的 BlobUrl
let lastBlobUrl = null;
export const getBlobUrlFromUrl = async (url) => {
try {
// 清理过期的 Blob 链接
if (lastBlobUrl) URL.revokeObjectURL(lastBlobUrl);
// 是否为网络链接
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("blob:")) {
return url;
}
// 获取音频文件数据
const response = await fetch(url);
// 检查请求是否成功
if (!response.ok) {
throw new Error("获取音频资源失败:", response.statusText);
}
const blob = await response.blob();
// 转换为本地 Blob 链接
lastBlobUrl = URL.createObjectURL(blob);
return lastBlobUrl;
} catch (error) {
console.error("获取 Blob 链接遇到错误:" + error);
throw error;
}
};

View File

@@ -10,17 +10,7 @@
</div>
</n-h4>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<SongList
v-if="artistHotSongs"
:data="artistHotSongs"
:showPagination="false"
:showTitle="false"
/>
<div v-else class="loading">
<n-skeleton :repeat="10" text />
</div>
</Transition>
<SongList :data="artistHotSongs" :showPagination="false" :showTitle="false" />
</div>
</template>
@@ -90,13 +80,5 @@ onBeforeMount(async () => {
font-size: 14px;
}
}
:deep(.n-skeleton) {
&:nth-of-type(1) {
margin-top: 0;
}
height: 70px;
margin-top: 12px;
border-radius: 8px;
}
}
</style>

View File

@@ -38,7 +38,7 @@
{{ artistData.identify }}
</n-text>
<!-- 数量 -->
<n-space class="num">
<n-flex class="num">
<div
v-if="artistData.size?.music"
class="num-item"
@@ -69,7 +69,7 @@
</n-icon>
<n-text depth="3">{{ artistData.size.mv }}</n-text>
</div>
</n-space>
</n-flex>
<!-- 简介 -->
<n-ellipsis
v-if="artistData?.description"
@@ -82,7 +82,7 @@
</n-ellipsis>
<n-text v-else class="description">竟然没有简介</n-text>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-flex class="menu" justify="space-between">
<n-button size="large" round strong secondary @click="likeOrDislike(artistId)">
<template #icon>
<n-icon>
@@ -91,7 +91,7 @@
</template>
{{ isLikeOrDislike(artistId) ? "关注歌手" : "取消关注" }}
</n-button>
</n-space>
</n-flex>
</div>
</div>
<div v-else class="detail">
@@ -326,6 +326,33 @@ onBeforeMount(async () => {
.tabs {
margin-bottom: 20px;
}
@media (max-width: 700px) {
.detail {
display: flex;
flex-direction: column;
align-items: center;
.cover {
width: 200px;
height: 200px;
min-width: 200px;
margin: 0;
}
.data {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20px;
.name {
font-size: 26px;
}
.identify {
font-size: 16px;
margin-left: 0;
}
}
}
}
}
.title {
display: flex;

View File

@@ -15,9 +15,19 @@
</div>
</n-progress>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-space class="left">
<n-button type="primary" class="play" circle strong secondary @click="playAllSongs">
<n-flex class="menu" justify="space-between">
<n-flex class="left">
<n-button
:disabled="userCloudData?.length === 0"
:focusable="false"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs(userCloudData)"
>
<template #icon>
<n-icon size="32">
<SvgIcon icon="play-arrow-rounded" />
@@ -34,8 +44,8 @@
</n-button>
<!-- 歌曲上传弹窗 -->
<UpCloudSong ref="upCloudSongRef" @getUserCloudData="getUserCloudData" />
</n-space>
<n-space class="right">
</n-flex>
<n-flex class="right">
<!-- 模糊搜索 -->
<Transition name="fade" mode="out-in">
<n-input
@@ -54,8 +64,8 @@
</template>
</n-input>
</Transition>
</n-space>
</n-space>
</n-flex>
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="userCloudData !== 'empty'" class="list">
@@ -82,17 +92,14 @@
</template>
<script setup>
import { storeToRefs } from "pinia";
import { musicData, indexedDBData } from "@/stores";
import { indexedDBData } from "@/stores";
import { getUserCloud } from "@/api/cloud";
import { fuzzySearch } from "@/utils/helper";
import { fadePlayOrPause, initPlayer } from "@/utils/Player";
import { playAllSongs } from "@/utils/Player";
import debounce from "@/utils/debounce";
import formatData from "@/utils/formatData";
const music = musicData();
const indexedDB = indexedDBData();
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
// 云盘数据
const userCloudSpace = ref([]);
@@ -104,10 +111,9 @@ const searchValue = ref(null);
const searchData = ref([]);
// 获取用户云盘缓存数据
const getUserCloudDataList = async () => {
await indexedDB.getfilesDB("userCloudList").then((res) => {
userCloudData.value = res;
});
const getUserCloudDataCatch = async () => {
const result = await indexedDB.getfilesDB("userCloudList");
userCloudData.value = result;
};
// 获取用户云盘列表
@@ -117,7 +123,6 @@ const getUserCloudData = async (isOnce = false) => {
let offset = 0;
let totalCount = null;
let resultArr = [];
userCloudData.value = [];
// 获取数据
while (totalCount === null || offset < totalCount) {
const res = await getUserCloud(100, offset);
@@ -160,43 +165,18 @@ const localSearch = debounce((val) => {
searchData.value = result;
}, 300);
// 播放歌单全部歌曲
const playAllSongs = async () => {
if (!userCloudData.value || !Object.keys(userCloudData.value).length) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = userCloudData.value.slice();
// 是否处于歌单内
const songId = playSongData.value?.id;
const existingIndex = userCloudData.value.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
playSongData.value = userCloudData.value[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
} else {
playSongData.value = userCloudData.value[existingIndex];
playIndex.value = existingIndex;
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
// 云盘扩容
const goBuy = () => {
window.open("https://music.163.com/#/store/product/detail?id=34001");
};
onMounted(async () => {
await getUserCloudDataList();
await getUserCloudDataCatch();
await getUserCloudData();
});
onMounted(() => {
window.$refreshCloudList = getUserCloudDataList;
window.$refreshCloudCatch = getUserCloudDataCatch;
});
</script>

View File

@@ -12,8 +12,17 @@
</Transition>
</div>
<!-- 操作 -->
<n-space class="control">
<n-button size="large" tag="div" round strong secondary @click="playAllSongs">
<n-flex class="control">
<n-button
:disabled="dailySongsData.data?.length === 0"
:focusable="false"
size="large"
tag="div"
round
strong
secondary
@click="playAllSongs(dailySongsData.data)"
>
<template #icon>
<n-icon>
<SvgIcon icon="play-arrow-rounded" />
@@ -30,7 +39,7 @@
</template>
</n-button>
</n-dropdown>
</n-space>
</n-flex>
</div>
<!-- 列表 -->
<SongList :data="dailySongsData.data" />
@@ -40,14 +49,12 @@
<script setup>
import { NIcon } from "naive-ui";
import { storeToRefs } from "pinia";
import { musicData, siteData } from "@/stores";
import { fadePlayOrPause, initPlayer } from "@/utils/Player";
import { siteData } from "@/stores";
import { playAllSongs } from "@/utils/Player";
import SvgIcon from "@/components/Global/SvgIcon";
const data = siteData();
const music = musicData();
const { dailySongsData } = storeToRefs(data);
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
const showTime = ref(false);
const showTimeOut = ref(null);
@@ -83,34 +90,6 @@ const moreOptions = computed(() => [
},
]);
// 播放歌单全部歌曲
const playAllSongs = async () => {
if (!dailySongsData.value.data) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = dailySongsData.value.data.slice();
// 是否处于歌单内
const songId = music.getPlaySongData?.id;
const existingIndex = dailySongsData.value.data.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
console.log("不在歌单内");
playSongData.value = dailySongsData.value.data[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
} else {
console.log("处于歌单内");
playSongData.value = dailySongsData.value.data[existingIndex];
playIndex.value = existingIndex;
// 播放
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
onMounted(() => {
showTimeOut.value = setTimeout(() => {
showTime.value = true;

View File

@@ -3,7 +3,7 @@
<div class="dsc-artists">
<div class="menu">
<!-- 字母分类 -->
<n-space class="initial">
<n-flex class="initial">
<n-tag
v-for="item in artistInitials"
:key="item"
@@ -14,9 +14,9 @@
>
{{ item.value }}
</n-tag>
</n-space>
</n-flex>
<!-- 地区分类 -->
<n-space class="category">
<n-flex class="category">
<n-tag
v-for="(item, index) in artistTypeNames"
:key="item"
@@ -31,10 +31,10 @@
>
{{ item }}
</n-tag>
</n-space>
</n-flex>
</div>
<MainCover :data="artistsData" columns="3 s:4 m:5 l:6" type="artist" />
<n-space v-if="arHasMore" justify="center">
<n-flex v-if="arHasMore" justify="center">
<n-button
:loading="arIsLoading"
class="load-more"
@@ -46,7 +46,7 @@
>
加载更多
</n-button>
</n-space>
</n-flex>
</div>
</template>

View File

@@ -1,8 +1,8 @@
<!-- 发现 - 最新音乐 -->
<template>
<div class="dsc-new">
<n-space class="menu" justify="space-between">
<n-space class="type">
<n-flex class="menu" justify="space-between">
<n-flex class="type">
<n-tag
v-for="(item, index) in newTypeNames"
:key="index"
@@ -14,8 +14,8 @@
>
{{ item }}
</n-tag>
</n-space>
<n-space class="area">
</n-flex>
<n-flex class="area">
<n-tag
v-for="(item, index) in newAreaNames"
:key="index"
@@ -27,8 +27,8 @@
>
{{ item.value }}
</n-tag>
</n-space>
</n-space>
</n-flex>
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="newTypeChoose === 0" class="new-album">

View File

@@ -2,7 +2,7 @@
<template>
<div class="dsc-playlists">
<!-- 菜单 -->
<n-space class="menu" align="center" justify="space-between">
<n-flex class="menu" align="center" justify="space-between">
<!-- 分类选择 -->
<n-button
class="cat"
@@ -20,12 +20,12 @@
{{ catName }}
</n-button>
<Transition name="fade" mode="out-in">
<n-space v-if="getHaveHqPlaylists(data.plCatList.hqCatList, catName)" align="center">
<n-flex v-if="getHaveHqPlaylists(data.plCatList.hqCatList, catName)" align="center">
<n-text>精品歌单</n-text>
<n-switch v-model:value="hqPlOpen" :round="false" @update:value="hqPlOpenChange" />
</n-space>
</n-flex>
</Transition>
</n-space>
</n-flex>
<!-- 列表 -->
<MainCover :data="allPlData" />
<!-- 分页 -->
@@ -37,7 +37,7 @@
/>
<!-- 加载更多 -->
<Transition name="fade" mode="out-in">
<n-space justify="center">
<n-flex justify="center">
<n-button
v-if="hqPlOpen && plHasMore"
:loading="plHasLoading"
@@ -55,7 +55,7 @@
>
加载更多
</n-button>
</n-space>
</n-flex>
</Transition>
<!-- 分类切换 -->
<n-modal v-model:show="catChangeShow" :bordered="false" preset="card">
@@ -80,7 +80,7 @@
<template #prefix>
<n-text class="type"> {{ cat }} </n-text>
</template>
<n-space>
<n-flex>
<n-tag
v-for="item in data.plCatList.catList.filter((v) => v.category == key)"
:key="item"
@@ -98,7 +98,7 @@
</n-icon>
</template>
</n-tag>
</n-space>
</n-flex>
</n-list-item>
</n-list>
</div>

View File

@@ -7,7 +7,7 @@
</n-h1>
<Transition name="fade" mode="out-in">
<div v-if="historyPlaylist?.length" class="list">
<n-space class="menu">
<n-flex class="menu">
<n-button round strong secondary @click="cleanHistory">
<template #icon>
<n-icon>
@@ -16,7 +16,7 @@
</template>
清空列表
</n-button>
</n-space>
</n-flex>
<SongList :data="historyPlaylist" :showCover="false" />
<n-divider class="tip" dashed>
<n-text :depth="3"> 最多展示 500 条播放历史 </n-text>

View File

@@ -22,11 +22,7 @@
</n-gi>
<!-- 喜欢的音乐 -->
<n-gi>
<SpecialCover
:data="likeSongsCoverData"
:showIcon="false"
@click="jumpPage('like-songs')"
/>
<SpecialCover :data="likeSongsCoverData" @click="jumpPage('like-songs')" />
</n-gi>
</n-grid>
<PrivateFm class="rec-fm" />
@@ -35,7 +31,7 @@
<div v-for="(item, index) in recommendData" :key="index" class="rec-public">
<n-h3 class="title" prefix="bar" @click="item.to ? router.push(item.to) : null">
<n-text class="name">{{ item.name }}</n-text>
<n-icon class="more" depth="3">
<n-icon v-if="item.to" class="more" depth="3">
<SvgIcon icon="chevron-right" />
</n-icon>
</n-h3>
@@ -53,7 +49,15 @@
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getGreetings } from "@/utils/timeTools";
import { getDailyRec, getPersonalized, getTopArtists, getNewAlbum } from "@/api/recommend";
import {
getDailyRec,
getPersonalized,
getRadarPlaylist,
getTopArtists,
getNewAlbum,
} from "@/api/recommend";
import { allMv } from "@/api/video";
import { getDjRecommend } from "@/api/dj";
import { siteData, siteSettings } from "@/stores";
import { getCacheData } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
@@ -84,6 +88,7 @@ const dailySongsCoverData = computed(() => {
// 喜欢的音乐
const likeSongsCoverData = computed(() => {
const likeSongsCover = {
id: 1024,
name: "喜欢的音乐",
desc: "发现你独特的音乐品味",
};
@@ -98,12 +103,19 @@ const likeSongsCoverData = computed(() => {
// 个性化推荐数据
const recommendData = ref({
playlist: {
name: "推荐歌单",
name: isLogin() ? "专属歌单" : "推荐歌单",
loadingNum: 12,
columns: showSider.value ? undefined : "2 s:3 m:4 l:5 xl:6",
data: [],
to: "/discover/playlists",
},
radar: {
name: "雷达歌单",
loadingNum: 6,
columns: showSider.value ? undefined : "2 s:3 m:4 l:5 xl:6",
data: [],
to: "/discover/playlists",
},
artist: {
name: "歌手推荐",
type: "artist",
@@ -115,10 +127,17 @@ const recommendData = ref({
mv: {
name: "推荐 MV",
type: "mv",
columns: "1 s:2 m:3 l:4 xl:5",
loadingNum: 2,
columns: "2 s:2 m:3 l:4 xl:5",
loadingNum: 12,
data: [],
},
dj: {
name: "推荐播客",
type: "dj",
loadingNum: 6,
data: [],
to: "/dj-hot",
},
album: {
name: "新碟上架",
type: "album",
@@ -131,31 +150,43 @@ const recommendData = ref({
// 获取个性化推荐数据
const getRecommendData = async () => {
try {
const [playlistRes, artistRes, mvRes, albumRes] = await Promise.allSettled([
const [playlistRes, radarRes, artistRes, mvRes, djRes, albumRes] = await Promise.allSettled([
// 歌单
isLogin()
? getCacheData("recPl-P", 5, getDailyRec, "resource")
: getCacheData("recPl", 5, getPersonalized),
// 雷达歌单
getCacheData("recRadar", 30, getRadarPlaylist),
// 歌手
getCacheData("recAr", 5, getTopArtists),
// MV
getCacheData("recMv", 5, getPersonalized, "mv"),
getCacheData("recMv", 5, allMv),
// 电台
getCacheData("recDj", 5, getDjRecommend),
// 专辑
getCacheData("recAl", 5, getNewAlbum),
]);
// 检查请求状态
playlistRes.status === "fulfilled" &&
(recommendData.value.playlist.data = formatData(
playlistRes.value.result || playlistRes.value.recommend,
isLogin()
? playlistRes.value.recommend.filter((playlist) => {
return !playlist.name.includes("私人雷达");
})
: playlistRes.value.result,
));
radarRes.status === "fulfilled" &&
(recommendData.value.radar.data = formatData(radarRes.value));
artistRes.status === "fulfilled" &&
(recommendData.value.artist.data = formatData(artistRes.value.artists, "artist"));
mvRes.status === "fulfilled" &&
(recommendData.value.mv.data = formatData(mvRes.value.result, "mv"));
(recommendData.value.mv.data = formatData(mvRes.value.data, "mv"));
djRes.status === "fulfilled" &&
(recommendData.value.dj.data = formatData(djRes.value.djRadios, "dj"));
albumRes.status === "fulfilled" &&
(recommendData.value.album.data = formatData(albumRes.value.albums, "album"));
// 检查是否有任何请求失败
const anyRejected = [playlistRes, artistRes, mvRes, albumRes].some(
const anyRejected = [playlistRes, radarRes, artistRes, mvRes, albumRes].some(
(res) => res.status === "rejected",
);
if (anyRejected) {
@@ -240,6 +271,14 @@ onBeforeMount(() => {
margin-left: 20px;
max-width: calc(50% - 10px);
}
@media (max-width: 700px) {
flex-direction: column;
.rec-fm {
margin-left: 0;
margin-top: 20px;
max-width: 100%;
}
}
}
}
</style>

View File

@@ -101,6 +101,7 @@ watch(
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
.num-item {
display: flex;

View File

@@ -3,7 +3,7 @@
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.playlists?.length" class="pl-list">
<!-- 分类 -->
<n-space class="type">
<n-flex class="type">
<n-tag
v-for="(item, index) in ['我创建的', '我收藏的']"
:key="index"
@@ -14,7 +14,7 @@
>
{{ item }}
</n-tag>
</n-space>
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="plTypeChoose === 0" class="list">

View File

@@ -42,7 +42,7 @@
</n-text>
</div>
<!-- 标签 -->
<n-space v-if="albumDetail?.tags" class="tags">
<n-flex v-if="albumDetail?.tags" class="tags">
<n-tag
v-for="(item, index) in albumDetail.tags"
:key="index"
@@ -58,9 +58,9 @@
>
{{ item }}
</n-tag>
</n-space>
</n-flex>
<!-- 数量 -->
<n-space class="num">
<n-flex class="num">
<div v-if="albumDetail.count" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
@@ -79,7 +79,7 @@
</n-icon>
<n-text depth="3">{{ getTimestampTime(albumDetail.publishTime) }} 发布</n-text>
</div>
</n-space>
</n-flex>
<!-- 简介 -->
<n-ellipsis
v-if="albumDetail.description"
@@ -101,17 +101,18 @@
</div>
</Transition>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-space class="left">
<n-flex class="menu" justify="space-between">
<n-flex class="left">
<n-button
:disabled="albumData === 'empty'"
:focusable="false"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs"
@click="playAllSongs(albumData)"
>
<template #icon>
<n-icon size="32">
@@ -119,7 +120,16 @@
</n-icon>
</template>
</n-button>
<n-button size="large" tag="div" round strong secondary @click="likeOrDislike(albumId)">
<n-button
:focusable="false"
class="like"
size="large"
tag="div"
round
strong
secondary
@click="likeOrDislike(albumId)"
>
<template #icon>
<n-icon>
<SvgIcon
@@ -130,7 +140,7 @@
{{ isLikeOrDislike(albumId) ? "收藏专辑" : "取消收藏" }}
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" tag="div" circle strong secondary>
<n-button :focusable="false" class="more" size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
@@ -138,8 +148,8 @@
</template>
</n-button>
</n-dropdown>
</n-space>
<n-space class="right">
</n-flex>
<n-flex class="right">
<!-- 模糊搜索 -->
<Transition name="fade" mode="out-in">
<n-input
@@ -158,8 +168,8 @@
</template>
</n-input>
</Transition>
</n-space>
</n-space>
</n-flex>
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<SongList v-if="!searchValue" :data="albumData" :sourceId="albumId" :showAlbum="false" />
@@ -185,7 +195,9 @@
</div>
<div v-else class="title">
<n-text class="key">参数不完整</n-text>
<n-button class="back" strong secondary @click="router.go(-1)"> 返回上一页 </n-button>
<n-button :focusable="false" class="back" strong secondary @click="router.go(-1)">
返回上一页
</n-button>
</div>
</template>
@@ -193,11 +205,12 @@
import { NIcon } from "naive-ui";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { musicData, siteData } from "@/stores";
import { siteData } from "@/stores";
import { getSongDetail } from "@/api/song";
import { getAlbumDetail, likeAlbum } from "@/api/album";
import { formatNumber, fuzzySearch } from "@/utils/helper";
import { getTimestampTime } from "@/utils/timeTools";
import { fadePlayOrPause, initPlayer } from "@/utils/Player";
import { playAllSongs } from "@/utils/Player";
import { isLogin } from "@/utils/auth";
import debounce from "@/utils/debounce";
import formatData from "@/utils/formatData";
@@ -205,9 +218,7 @@ import SvgIcon from "@/components/Global/SvgIcon";
const router = useRouter();
const data = siteData();
const music = musicData();
const { userLikeData } = storeToRefs(data);
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
// 专辑 ID
const albumId = ref(router.currentRoute.value.query.id || null);
@@ -253,35 +264,9 @@ const getAlbumAllData = async (id, justDetail = false) => {
// 是否终止
if (justDetail) return true;
// 全部歌曲
albumData.value = formatData(detail.songs, "song");
};
// 播放专辑全部歌曲
const playAllSongs = async () => {
if (!albumData.value) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = albumData.value.slice();
// 是否处于专辑内
const songId = playSongData.value?.id;
const existingIndex = albumData.value.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
console.log("不在专辑内");
playSongData.value = albumData.value[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
} else {
console.log("处于专辑内");
playSongData.value = albumData.value[existingIndex];
playIndex.value = existingIndex;
// 播放
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
const ids = detail.songs.map((song) => song.id).join(",");
const songsDetail = await getSongDetail(ids);
albumData.value = formatData(songsDetail.songs, "song");
};
// 歌曲模糊搜索
@@ -389,6 +374,7 @@ onBeforeMount(() => {
.name {
font-size: 30px;
font-weight: bold;
-webkit-line-clamp: 2;
}
.alia {
margin-top: 4px;
@@ -468,9 +454,11 @@ onBeforeMount(() => {
}
}
.menu {
flex-wrap: nowrap;
align-items: center;
margin: 26px 0;
.left {
flex-wrap: nowrap;
align-items: center;
.play {
--n-width: 46px;
@@ -493,6 +481,84 @@ onBeforeMount(() => {
}
}
}
@media (max-width: 700px) {
.detail {
.cover {
width: 140px;
height: 140px;
min-width: 140px;
}
.data {
.name {
font-size: 20px;
margin-bottom: 4px;
}
.alia {
font-size: 16px;
}
.creator {
.n-avatar {
width: 20px;
height: 20px;
margin-right: 6px;
}
.nickname {
font-size: 12px;
}
.create-time {
margin-left: 6px;
font-size: 12px;
}
}
.tags {
.pl-tags {
font-size: 12px;
padding: 0 12px;
}
}
.num,
.description {
display: none !important;
}
}
}
.menu {
margin: 20px 0;
.left {
.play {
--n-width: 40px;
--n-height: 40px;
.n-icon {
font-size: 22px !important;
}
}
.like {
--n-height: 36px;
--n-font-size: 13px;
--n-padding: 0 16px;
--n-icon-size: 18px;
:deep(.n-button__icon) {
margin: 0;
}
:deep(.n-button__content) {
display: none;
}
}
.more {
--n-height: 36px;
--n-font-size: 13px;
--n-icon-size: 18px;
}
}
.right {
.search {
height: 36px;
width: 130px;
font-size: 13px;
}
}
}
}
}
.title {
display: flex;

View File

@@ -72,7 +72,7 @@
</n-ellipsis>
<n-text v-else class="description">太懒了吧连简介都没写</n-text>
<!-- 数量 -->
<n-space class="num">
<n-flex class="num">
<div v-if="djDetail?.count" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
@@ -85,7 +85,7 @@
</n-icon>
<n-text depth="3">{{ getTimestampTime(djDetail.updateTime) }} 更新</n-text>
</div>
</n-space>
</n-flex>
</div>
</div>
<div v-else class="detail">
@@ -96,17 +96,18 @@
</div>
</Transition>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-space class="left">
<n-flex class="menu" justify="space-between">
<n-flex class="left">
<n-button
:disabled="djData === 'empty'"
:focusable="false"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs"
@click="playAllSongs(djData, 'dj')"
>
<template #icon>
<n-icon size="32">
@@ -114,7 +115,16 @@
</n-icon>
</template>
</n-button>
<n-button size="large" tag="div" round strong secondary @click="likeOrDislike(djId)">
<n-button
:focusable="false"
class="like"
size="large"
tag="div"
round
strong
secondary
@click="likeOrDislike(djId)"
>
<template #icon>
<n-icon>
<SvgIcon
@@ -125,7 +135,7 @@
{{ isLikeOrDislike(djId) ? "订阅电台" : "取消订阅" }}
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" tag="div" circle strong secondary>
<n-button :focusable="false" class="more" size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
@@ -133,8 +143,8 @@
</template>
</n-button>
</n-dropdown>
</n-space>
<n-space class="right">
</n-flex>
<n-flex class="right">
<!-- 模糊搜索 -->
<Transition name="fade" mode="out-in">
<n-input
@@ -153,8 +163,8 @@
</template>
</n-input>
</Transition>
</n-space>
</n-space>
</n-flex>
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="djData !== 'empty'" class="list">
@@ -189,7 +199,9 @@
</div>
<div v-else class="title">
<n-text class="key">参数不完整</n-text>
<n-button class="back" strong secondary @click="router.go(-1)"> 返回上一页 </n-button>
<n-button :focusable="false" class="back" strong secondary @click="router.go(-1)">
返回上一页
</n-button>
</div>
</template>
@@ -197,23 +209,21 @@
import { NIcon } from "naive-ui";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { musicData, siteData, siteSettings } from "@/stores";
import { siteData, siteSettings } from "@/stores";
import { getDjDetail, getDjProgram, likeDj } from "@/api/dj";
import { fuzzySearch } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
import { getTimestampTime } from "@/utils/timeTools";
import { fadePlayOrPause, initPlayer } from "@/utils/Player";
import { playAllSongs } from "@/utils/Player";
import debounce from "@/utils/debounce";
import formatData from "@/utils/formatData";
import SvgIcon from "@/components/Global/SvgIcon";
const router = useRouter();
const data = siteData();
const music = musicData();
const settings = siteSettings();
const { userLikeData } = storeToRefs(data);
const { loadSize } = storeToRefs(settings);
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
// 电台数据
const djId = ref(router.currentRoute.value.query.id);
@@ -280,34 +290,6 @@ const getDjProgramData = async (id, limit = loadSize.value, offset = 0) => {
}
};
// 播放电台全部节目
const playAllSongs = async () => {
if (!djData.value) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和电台
playMode.value = "dj";
playList.value = djData.value.slice();
// 是否处于电台内
const songId = music.getPlaySongData?.id;
const existingIndex = djData.value.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
console.log("不在电台内");
playSongData.value = djData.value[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
} else {
console.log("处于电台内");
playSongData.value = djData.value[existingIndex];
playIndex.value = existingIndex;
// 播放
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
};
// 节目模糊搜索
const localSearch = debounce((val) => {
const searchValue = val?.trim();
@@ -431,6 +413,7 @@ onMounted(async () => {
font-size: 30px;
font-weight: bold;
margin-bottom: 12px;
-webkit-line-clamp: 2;
}
.creator {
display: flex;
@@ -527,6 +510,81 @@ onMounted(async () => {
}
}
}
@media (max-width: 700px) {
.detail {
.cover {
width: 140px;
height: 140px;
min-width: 140px;
}
.data {
.name {
font-size: 20px;
margin-bottom: 4px;
}
.creator {
.n-avatar {
width: 20px;
height: 20px;
margin-right: 6px;
}
.nickname {
font-size: 12px;
}
.create-time {
margin-left: 6px;
font-size: 12px;
}
}
.tags {
.pl-tags {
font-size: 12px;
padding: 0 12px;
}
}
.num,
.description {
display: none !important;
}
}
}
.menu {
margin: 20px 0;
.left {
.play {
--n-width: 40px;
--n-height: 40px;
.n-icon {
font-size: 22px !important;
}
}
.like {
--n-height: 36px;
--n-font-size: 13px;
--n-padding: 0 16px;
--n-icon-size: 18px;
:deep(.n-button__icon) {
margin: 0;
}
:deep(.n-button__content) {
display: none;
}
}
.more {
--n-height: 36px;
--n-font-size: 13px;
--n-icon-size: 18px;
}
}
.right {
.search {
height: 36px;
width: 130px;
font-size: 13px;
}
}
}
}
}
.title {
display: flex;

View File

@@ -48,7 +48,7 @@
</n-text>
</div>
<!-- 标签 -->
<n-space v-if="playListDetail?.tags" class="tags">
<n-flex v-if="playListDetail?.tags" class="tags">
<n-tag
v-for="(item, index) in playListDetail.tags"
:key="index"
@@ -64,9 +64,9 @@
>
{{ item }}
</n-tag>
</n-space>
</n-flex>
<!-- 数量 -->
<n-space class="num">
<n-flex class="num">
<div v-if="playListDetail.count" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
@@ -85,7 +85,7 @@
</n-icon>
<n-text depth="3">{{ getTimestampTime(playListDetail.updateTime) }}</n-text>
</div>
</n-space>
</n-flex>
<!-- 简介 -->
<n-ellipsis
v-if="playListDetail.description"
@@ -108,17 +108,18 @@
</Transition>
<!-- 功能区 -->
<Transition name="fade" mode="out-in">
<n-space :key="isUserPLayList" class="menu" justify="space-between">
<n-space class="left">
<n-flex :key="isUserPLayList" class="menu" justify="space-between">
<n-flex class="left">
<n-button
:disabled="playListData === 'empty'"
:disabled="playListData === null || playListData === 'empty' || loadingMsg !== null"
:focusable="false"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs"
@click="playAllSongs(playListData)"
>
<template #icon>
<n-icon size="32">
@@ -127,23 +128,9 @@
</template>
</n-button>
<n-button
v-if="isUserPLayList"
size="large"
tag="div"
round
strong
secondary
@click="playlistUpdateRef?.openUpdateModal(playListDetail)"
>
<template #icon>
<n-icon>
<SvgIcon icon="edit" />
</n-icon>
</template>
编辑歌单
</n-button>
<n-button
v-else
v-if="!isUserPLayList"
:focusable="false"
class="like"
size="large"
tag="div"
round
@@ -162,8 +149,34 @@
</template>
{{ isLikeOrDislike(playlistId) ? "收藏歌单" : "取消收藏" }}
</n-button>
<n-button
v-else
:focusable="false"
class="like"
size="large"
tag="div"
round
strong
secondary
@click="playlistUpdateRef?.openUpdateModal(playListDetail)"
>
<template #icon>
<n-icon>
<SvgIcon icon="edit" />
</n-icon>
</template>
编辑歌单
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" tag="div" circle strong secondary>
<n-button
:focusable="false"
class="more"
size="large"
tag="div"
circle
strong
secondary
>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
@@ -171,8 +184,8 @@
</template>
</n-button>
</n-dropdown>
</n-space>
<n-space class="right">
</n-flex>
<n-flex class="right">
<!-- 模糊搜索 -->
<Transition name="fade" mode="out-in">
<n-input
@@ -191,8 +204,8 @@
</template>
</n-input>
</Transition>
</n-space>
</n-space>
</n-flex>
</n-flex>
</Transition>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
@@ -216,7 +229,9 @@
</div>
<div v-else class="title">
<n-text class="key">参数不完整</n-text>
<n-button class="back" strong secondary @click="router.go(-1)"> 返回上一页 </n-button>
<n-button :focusable="false" class="back" strong secondary @click="router.go(-1)">
返回上一页
</n-button>
</div>
</template>
@@ -224,7 +239,7 @@
import { NIcon } from "naive-ui";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { musicData, siteData } from "@/stores";
import { siteData } from "@/stores";
import {
getPlayListDetail,
getAllPlayList,
@@ -236,16 +251,14 @@ import { getSongDetail } from "@/api/song";
import { formatNumber, fuzzySearch } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
import { getTimestampTime } from "@/utils/timeTools";
import { fadePlayOrPause, initPlayer } from "@/utils/Player";
import { playAllSongs } from "@/utils/Player";
import debounce from "@/utils/debounce";
import formatData from "@/utils/formatData";
import SvgIcon from "@/components/Global/SvgIcon";
const router = useRouter();
const data = siteData();
const music = musicData();
const { userLikeData, userData } = storeToRefs(data);
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
// 歌单 ID
const playlistId = ref(
@@ -258,6 +271,7 @@ const playlistId = ref(
const playlistUpdateRef = ref(null);
// 歌单数据
const loadingMsg = ref(null);
const isUserPLayList = ref(false);
const playListDetail = ref(null);
const playListData = ref(null);
@@ -315,64 +329,69 @@ const changeMoreOptions = (detail) => {
// 获取歌单信息
const getPlayListDetailData = async (id, justDetail = false) => {
if (!id) return false;
// 清空数据
playListDetail.value = null;
if (!justDetail) playListData.value = null;
// 获取数据
const detail = await getPlayListDetail(id);
// 基础信息
playListDetail.value = formatData(detail.playlist, "playlist", true)[0];
// 更改更多操作数据
changeMoreOptions(detail.playlist);
// 是否终止
if (justDetail) return true;
// 是否为用户歌单
isUserPLayList.value = detail.playlist.userId === userData.value?.userId;
// 判断登录
if (isLogin() && isUserPLayList.value) {
if (!detail.privileges) {
playListData.value = "empty";
return false;
try {
if (!id) return false;
// 清空数据
playListDetail.value = null;
if (!justDetail) playListData.value = null;
// 获取数据
const detail = await getPlayListDetail(id);
// 基础信息
playListDetail.value = formatData(detail.playlist, "playlist", true)[0];
// 更改更多操作数据
changeMoreOptions(detail.playlist);
// 是否终止
if (justDetail) return true;
// 是否为超大歌单
if (detail.playlist.trackCount >= 800) {
await getBigPlayListData(id, detail.playlist.trackCount);
return true;
}
const ids = detail.privileges.map((song) => song.id).join(",");
const songsDetail = await getSongDetail(ids);
console.log(songsDetail);
playListData.value = formatData(songsDetail.songs, "song");
} else {
const limit = detail.playlist.trackCount || 0;
const songsDetail = await getAllPlayList(id, limit);
console.log(songsDetail);
playListData.value = formatData(songsDetail.songs, "song");
// 是否为用户歌单
isUserPLayList.value = detail.playlist.userId === userData.value?.userId;
// 判断登录并获取歌曲
let songsDetail = null;
if (isLogin() && isUserPLayList.value) {
if (!detail.privileges) {
playListData.value = "empty";
return false;
}
const ids = detail.privileges.map((song) => song.id).join(",");
songsDetail = await getSongDetail(ids);
} else {
const limit = detail.playlist.trackCount || 0;
songsDetail = await getAllPlayList(id, limit);
}
// 写入歌单数据
if (!songsDetail.songs) {
playListData.value = "error";
} else {
playListData.value = formatData(songsDetail.songs, "song");
}
} catch (error) {
console.error("获取歌单信息出错:", error);
$message.error("获取歌单信息出错");
}
};
// 播放歌单全部歌曲
const playAllSongs = async () => {
if (!playListData.value) return false;
// 关闭心动模式
playHeartbeatMode.value = false;
// 更改模式和歌单
playMode.value = "normal";
playList.value = playListData.value.slice();
// 是否处于歌单内
const songId = music.getPlaySongData?.id;
const existingIndex = playListData.value.findIndex((song) => song.id === songId);
// 若不处于
if (existingIndex === -1 || !songId) {
console.log("不在歌单内");
playSongData.value = playListData.value[0];
playIndex.value = 0;
// 初始化播放器
await initPlayer(true);
} else {
console.log("处于歌单内");
playSongData.value = playListData.value[existingIndex];
playIndex.value = existingIndex;
// 播放
fadePlayOrPause();
// 获取超大歌单数据
const getBigPlayListData = async (id, count) => {
console.log(id, count);
loadingMsg.value = $message.loading("该歌单歌曲数量过多,请稍等", {
duration: 0,
});
// 循环获取
let offset = 0;
playListData.value = [];
while ((count === null || offset < count) && loadingMsg.value) {
const songsDetail = await getAllPlayList(id, 800, offset);
const albumsData = formatData(songsDetail.songs, "song");
playListData.value = playListData.value.concat(albumsData);
offset += 800;
}
$message.info("已开始播放", { showIcon: false });
// 关闭加载提示
loadingMsg.value?.destroy();
loadingMsg.value = null;
};
// 歌曲模糊搜索
@@ -473,6 +492,11 @@ watch(
onBeforeMount(async () => {
await getPlayListDetailData(playlistId.value);
});
onBeforeUnmount(() => {
loadingMsg.value?.destroy();
loadingMsg.value = null;
});
</script>
<style lang="scss" scoped>
@@ -525,6 +549,7 @@ onBeforeMount(async () => {
font-size: 30px;
font-weight: bold;
margin-bottom: 12px;
-webkit-line-clamp: 2;
}
.creator {
display: flex;
@@ -599,9 +624,11 @@ onBeforeMount(async () => {
}
}
.menu {
flex-wrap: nowrap;
align-items: center;
margin: 26px 0;
.left {
flex-wrap: nowrap;
align-items: center;
.play {
--n-width: 46px;
@@ -624,6 +651,82 @@ onBeforeMount(async () => {
}
}
}
@media (max-width: 700px) {
.detail {
.cover {
width: 140px;
height: 140px;
min-width: 140px;
}
.data {
.name {
font-size: 20px;
margin-bottom: 4px;
}
.creator {
.n-avatar {
width: 20px;
height: 20px;
margin-right: 6px;
}
.nickname {
font-size: 12px;
}
.create-time {
margin-left: 6px;
font-size: 12px;
}
}
.tags {
.pl-tags {
font-size: 12px;
padding: 0 12px;
}
}
.num,
.description {
display: none !important;
}
}
}
.menu {
margin: 20px 0;
.left {
.play {
--n-width: 40px;
--n-height: 40px;
.n-icon {
font-size: 22px !important;
}
}
.like {
--n-height: 36px;
--n-font-size: 13px;
--n-padding: 0 16px;
--n-icon-size: 18px;
:deep(.n-button__icon) {
margin: 0;
}
:deep(.n-button__content) {
display: none;
}
}
.more {
--n-height: 36px;
--n-font-size: 13px;
--n-icon-size: 18px;
}
}
.right {
.search {
height: 36px;
width: 130px;
font-size: 13px;
}
}
}
}
}
.title {
display: flex;

View File

@@ -5,7 +5,7 @@
v-if="allAlbumData"
:style="{
height: `calc(100vh - ${
Object.keys(music.playSongData)?.length && status.showPlayBar ? 380 : 300
Object.keys(music.playSongData)?.length && status.showPlayBar ? 445 : 365
}px)`,
}"
class="local-album"

View File

@@ -6,7 +6,7 @@
class="local-artists"
:style="{
height: `calc(100vh - ${
Object.keys(music.playSongData)?.length && status.showPlayBar ? 380 : 300
Object.keys(music.playSongData)?.length && status.showPlayBar ? 445 : 365
}px)`,
}"
type="card"

View File

@@ -3,7 +3,7 @@
<div class="local">
<n-h1 class="title">本地歌曲</n-h1>
<!-- 数据统计 -->
<n-space class="num">
<n-flex class="num">
<!-- 总数 -->
<div class="num-item">
<n-icon size="18">
@@ -25,42 +25,69 @@
/>
GB
</div>
</n-space>
</n-flex>
<!-- 功能区 -->
<n-flex class="menu" justify="space-between">
<n-flex class="left">
<n-button
:disabled="!localSongList?.length"
:focusable="false"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs(localSongList)"
>
<template #icon>
<n-icon size="32">
<SvgIcon icon="play-arrow-rounded" />
</n-icon>
</template>
</n-button>
<!-- 目录管理 -->
<n-button
:focusable="false"
class="local-path"
tag="div"
round
strong
secondary
@click="localPathShow = true"
>
<template #icon>
<n-icon>
<SvgIcon icon="folder-cog" />
</n-icon>
</template>
目录管理
</n-button>
</n-flex>
<n-flex class="right">
<!-- 模糊搜索 -->
<n-input
v-if="localSongList?.length"
v-model:value="searchValue"
:input-props="{ autoComplete: false }"
class="search"
placeholder="搜索"
clearable
@input="localSearch"
>
<template #prefix>
<n-icon size="18">
<SvgIcon icon="search-rounded" />
</n-icon>
</template>
</n-input>
</n-flex>
</n-flex>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="local-songs"> 歌曲 </n-tab>
<n-tab name="local-artists"> 歌手 </n-tab>
<n-tab name="local-albums"> 专辑 </n-tab>
<template #suffix>
<!-- 模糊搜索 -->
<div v-if="localSongList?.length" class="search">
<n-input
v-model:value="searchValue"
:input-props="{ autoComplete: false }"
class="local-search"
placeholder="搜索"
clearable
@input="localSearch"
>
<template #prefix>
<n-icon size="18">
<SvgIcon icon="search-rounded" />
</n-icon>
</template>
</n-input>
</div>
<!-- 目录管理 -->
<div class="local-path">
<n-button strong secondary @click="localPathShow = true">
<template #icon>
<n-icon>
<SvgIcon icon="folder-cog" />
</n-icon>
</template>
目录管理
</n-button>
</div>
</template>
</n-tabs>
<!-- 路由页面 -->
<Transition name="fade" mode="out-in">
@@ -153,7 +180,7 @@
</n-list-item>
</n-list>
<template #footer>
<n-space justify="center">
<n-flex justify="center">
<n-button class="add-path" strong secondary @click="changeLocalPath">
<template #icon>
<n-icon>
@@ -162,7 +189,7 @@
</template>
添加文件夹
</n-button>
</n-space>
</n-flex>
</template>
</n-modal>
</div>
@@ -172,6 +199,7 @@
import { musicData, indexedDBData } from "@/stores";
import { useRouter } from "vue-router";
import { fuzzySearch } from "@/utils/helper";
import { playAllSongs } from "@/utils/Player";
import debounce from "@/utils/debounce";
const indexedDB = indexedDBData();
@@ -335,26 +363,41 @@ onBeforeMount(async () => {
}
}
}
.tabs {
margin-bottom: 20px;
.search {
height: 100%;
margin-right: 16px;
.local-search {
.menu {
flex-wrap: nowrap;
align-items: center;
margin: 20px 0;
.left {
flex-wrap: nowrap;
align-items: center;
.play {
--n-width: 46px;
--n-height: 46px;
}
.local-path {
--n-height: 100%;
height: 40px;
}
}
.right {
.search {
height: 40px;
width: 130px;
display: flex;
align-items: center;
height: 100%;
border-radius: 6px;
}
}
.local-path {
height: 100%;
:deep(.n-button) {
--n-height: 100%;
--n-border-radius: 6px;
border-radius: 40px;
transition:
width 0.3s,
background-color 0.3s;
&.n-input--focus {
width: 200px;
}
}
}
}
.tabs {
margin-bottom: 20px;
}
}
.local-list {
:deep(.n-list-item__prefix) {

View File

@@ -6,7 +6,7 @@
<Transition name="fade" mode="out-in">
<div v-if="videoData" class="detail">
<n-text class="title">{{ videoData.name }}</n-text>
<n-space class="detail-tag">
<n-flex class="detail-tag">
<!-- 播放量 -->
<div v-if="videoData?.playCount" class="tag-item">
<n-icon depth="3" size="18">
@@ -33,7 +33,7 @@
</n-icon>
<n-text depth="3">{{ videoData.publishTime }}</n-text>
</div>
</n-space>
</n-flex>
</div>
<div v-else class="detail">
<n-skeleton :repeat="2" text round />
@@ -111,7 +111,7 @@
{{ videoData.desc }}
</n-ellipsis>
<n-text v-else>该视频暂无简介</n-text>
<n-space v-if="videoData?.videoGroup" class="video-tag">
<n-flex v-if="videoData?.videoGroup" class="video-tag">
<n-tag
v-for="(item, index) in videoData?.videoGroup"
:key="index"
@@ -121,7 +121,7 @@
>
{{ item.name }}
</n-tag>
</n-space>
</n-flex>
<n-divider id="to-comments" />
</div>
<div v-else class="content">
@@ -636,5 +636,25 @@ onBeforeUnmount(() => {
margin-top: 36px;
}
}
@media (max-width: 700px) {
.player {
width: 100%;
.detail {
height: 60px;
display: flex;
flex-direction: column;
justify-content: space-around;
margin-bottom: 20px;
.title {
display: inline-block;
margin: 0;
-webkit-line-clamp: 2;
}
}
}
.video-more {
display: none;
}
}
}
</style>

View File

@@ -7,7 +7,7 @@
<n-text depth="3">的相关搜索</n-text>
</div>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="line" @update:value="tabChange">
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="sea-songs"> 单曲 </n-tab>
<n-tab name="sea-artists"> 歌手 </n-tab>
<n-tab name="sea-albums"> 专辑 </n-tab>

View File

@@ -10,14 +10,14 @@
</n-icon>
<n-text class="author-text" depth="3">{{ packageJson.author }}</n-text>
</div>
<n-text class="version" depth="3">v&nbsp;{{ packageJson.version }}</n-text>
<n-text class="version" depth="3">{{ packageJson.version }}</n-text>
</div>
</n-h1>
<!-- 导航栏 -->
<n-tabs
ref="setTabsRef"
v-model:value="setTabsValue"
type="line"
type="segment"
@update:value="settingTabChange"
>
<n-tab name="setTab1"> 常规 </n-tab>
@@ -31,9 +31,7 @@
<n-scrollbar
ref="setScrollRef"
:style="{
height: `calc(100vh - ${
Object.keys(music.getPlaySongData)?.length && showPlayBar ? 328 : 248
}px)`,
height: `calc(100vh - ${music.getPlaySongData?.id && showPlayBar ? 328 : 248}px)`,
}"
class="all-set"
@scroll="allSetScroll"
@@ -78,9 +76,16 @@
</div>
<n-switch v-model:value="showSider" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">
侧边栏展示封面
<n-text class="tip">侧边栏歌单是否展示歌单封面</n-text>
</div>
<n-switch v-model:value="siderShowCover" :disabled="!showSider" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">显示搜索历史</div>
<n-switch v-model:value="searchHistory" :round="false" />
<n-switch v-model:value="showSearchHistory" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">
@@ -191,7 +196,9 @@
</div>
<div v-else class="set-type">
<n-h3 prefix="bar"> 系统 </n-h3>
<n-text>该设置项为桌面端独占功能</n-text>
<n-card class="set-item">
<div class="name">该设置项为桌面端独占功能</div>
</n-card>
</div>
<!-- 播放 -->
<div class="set-type">
@@ -212,6 +219,13 @@
</div>
<n-switch v-model:value="memorySeek" :disabled="autoPlay" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">
音乐资源自动缓存
<n-text class="tip"> 可能会造成加载缓慢将在下一首播放或刷新时生效 </n-text>
</div>
<n-switch v-model:value="useMusicCache" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">音乐渐入渐出</div>
<n-switch v-model:value="songVolumeFade" :round="false" />
@@ -273,8 +287,8 @@
playerBackgroundType === "animation"
? "流体效果,较消耗性能,请谨慎开启"
: playerBackgroundType === "blur"
? "将封面模糊处理为背景"
: "提取封面主色为渐变色"
? "将封面模糊处理为背景"
: "提取封面主色为渐变色"
}}
</n-text>
</div>
@@ -317,6 +331,29 @@
:round="false"
/>
</n-card>
<n-card class="set-item">
<div class="name">
<div class="dev">
显示音乐频谱
<n-tag :bordered="false" round size="small" type="warning">
开发中
<template #icon>
<n-icon>
<SvgIcon icon="code" />
</n-icon>
</template>
</n-tag>
</div>
<n-text class="tip">
{{
showSpectrums
? "开启音乐频谱会极大影响性能,如遇问题请关闭"
: "是否在播放器底部显示音乐频谱"
}}
</n-text>
</div>
<n-switch v-model:value="showSpectrums" :round="false" />
</n-card>
</div>
<!-- 歌词 -->
<div class="set-type">
@@ -401,7 +438,7 @@
<n-card class="set-item">
<div class="name">
<div class="dev">
是否显示逐字歌词
显示逐字歌词
<n-tag :bordered="false" round size="small" type="warning">
开发中
<template #icon>
@@ -418,7 +455,7 @@
<n-card class="set-item">
<div class="name">
<div class="dev">
是否显示逐字歌词动画
显示逐字歌词动画
<n-tag :bordered="false" round size="small" type="warning">
开发中
<template #icon>
@@ -462,7 +499,7 @@
默认下载文件夹
<n-text class="tip">{{ downloadPath || "不设置则会每次选择保存位置" }}</n-text>
</div>
<n-space>
<n-flex>
<Transition name="fade" mode="out-in">
<n-button
v-if="downloadPath"
@@ -477,14 +514,14 @@
<n-button :disabled="!checkPlatform.electron()" strong secondary @click="choosePath">
更改
</n-button>
</n-space>
</n-flex>
</n-card>
</div>
<!-- 其他 -->
<div class="set-type">
<n-h3 prefix="bar"> 其他 </n-h3>
<n-card class="set-item">
<div class="name">是否显示 GitHub 仓库按钮</div>
<div class="name">显示 GitHub 仓库按钮</div>
<n-switch v-model:value="showGithub" :round="false" />
</n-card>
<n-card class="set-item">
@@ -562,7 +599,7 @@ const {
lrcMousePause,
lyricsFontSize,
lyricsBlur,
searchHistory,
showSearchHistory,
autoSignIn,
bottomLyricShow,
downloadPath,
@@ -571,6 +608,9 @@ const {
playCoverType,
playSearch,
showPlaylistCount,
showSpectrums,
siderShowCover,
useMusicCache,
} = storeToRefs(settings);
// 标签页数据
@@ -721,6 +761,12 @@ const resetApp = () => {
margin-left: 6px;
}
}
.version {
&::before {
content: "v";
margin-right: 2px;
}
}
}
}
.n-tabs {

View File

@@ -1,4 +1,45 @@
<!-- 单曲页面 -->
<template>
<div class="song">单曲页面 - 待完成</div>
<div class="song">
单曲页面 - 待完成
{{ songDetail }}
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { getSongDetail } from "@/api/song";
import formatData from "@/utils/formatData";
const router = useRouter();
// 歌曲信息
const songId = ref(router.currentRoute.value.query.id);
const songDetail = ref(null);
// 检查是否具有视频 id
const isHasSongId = (id) => {
if (!id) {
$message.error("参数不完整");
return router.go(-1);
}
};
// 获取歌曲详情
const getSongDetailData = async (id) => {
try {
const detail = await getSongDetail(id);
const data = formatData(detail?.songs?.[0], "song");
songDetail.value = data?.[0] ?? null;
} catch (error) {
console.error("获取歌曲详情失败:", error);
}
};
onMounted(() => {
// 若无 id
isHasSongId(songId.value);
// 获取歌曲详情
getSongDetailData(songId.value);
});
</script>

View File

@@ -1,3 +1,92 @@
<template>
<div class="test">测试页面</div>
<div class="test">
<n-h1>测试页面</n-h1>
<n-card title="频谱数据" style="margin-bottom: 20px">
<n-scrollbar style="max-height: 120px">
{{ status.spectrumsData }}
</n-scrollbar>
</n-card>
<n-card title="频谱图">
<canvas ref="canvasRef" class="avBars" style="width: 100%" />
</n-card>
</div>
</template>
<script setup>
import { siteStatus } from "@/stores";
const status = siteStatus();
const canvasRef = ref(null);
const drawSpectrum = (data) => {
canvasRef.value.width = document.body.clientWidth >= 1600 ? 1600 : document.body.clientWidth;
canvasRef.value.height = 80;
const ctx = canvasRef.value.getContext("2d");
const barWidth = 6;
const cornerRadius = 3; // 圆角半径
// 清除画布
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
for (let i = 0; i < 360; i++) {
const barHeight = (data[i] / 255) * canvasRef.value.height;
const x = i * (barWidth * 2);
const y = canvasRef.value.height - barHeight;
ctx.fillStyle = `rgb(${status.coverTheme?.light?.shadeTwo})` || "#efefef";
// ctx.fillRect(x, y, barWidth, barHeight);
// 检查柱形高度是否大于0
if (barHeight > 0) {
roundRect(ctx, x, y, barWidth, barHeight, cornerRadius);
}
}
requestAnimationFrame(() => {
drawSpectrum(status.spectrumsData);
});
};
// 辅助函数:绘制圆角矩形
const roundRect = (ctx, x, y, width, height, radius) => {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
};
// watch(
// () => status.spectrumsData,
// (val) => drawSpectrum(val),
// );
onMounted(() => {
drawSpectrum(status.spectrumsData);
});
</script>
<style lang="scss" scoped>
.avBars {
mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0)
);
-webkit-mask: linear-gradient(
90deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0)
);
}
</style>