Compare commits

..

12 Commits

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

12
.dockerignore Normal file
View File

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

View File

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

5
.npmrc
View File

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

29
Dockerfile Normal file
View File

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

235
README.md
View File

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

5
components.d.ts vendored
View File

@@ -27,11 +27,11 @@ declare module 'vue' {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
@@ -44,6 +44,7 @@ declare module 'vue' {
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']
@@ -67,6 +68,7 @@ declare module 'vue' {
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
@@ -83,6 +85,7 @@ declare module 'vue' {
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NVirtualList: typeof import('naive-ui')['NVirtualList']
Pagination: typeof import('./src/components/Global/Pagination.vue')['default']
PlayerControl: typeof import('./src/components/Player/PlayerControl.vue')['default']
PlayerCover: typeof import('./src/components/Player/PlayerCover.vue')['default']

12
docker-compose.yml Normal file
View File

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

View File

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

View File

@@ -106,12 +106,22 @@ export default defineConfig(({ mode }) => {
background_color: "#efefef",
icons: [
{
src: "/images/logo/favicon.png",
sizes: "200x200",
src: "/images/icons/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "/images/logo/favicon_512.png",
src: "/images/icons/favicon-96x96.png",
sizes: "96x96",
type: "image/png",
},
{
src: "/images/icons/favicon-256x256.png",
sizes: "256x256",
type: "image/png",
},
{
src: "/images/icons/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
},
@@ -147,12 +157,6 @@ export default defineConfig(({ mode }) => {
},
},
sourcemap: false,
win: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
linux: {
icon: resolve(__dirname, "/public/images/logo/favicon.png"),
},
},
},
};

View File

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

View File

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

View File

@@ -3,16 +3,17 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="%RENDERER_VITE_SITE_LOGO%" />
<link rel="apple-touch-icon" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="bookmark" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<link rel="apple-touch-icon-precomposed" sizes="200x200" href="%RENDERER_VITE_SITE_APPLE_LOGO%" />
<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>

28
nginx.conf Normal file
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "splayer",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
@@ -15,7 +15,7 @@
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"start": "electron-vite preview",
"dev": "chcp 65001 && electron-vite dev --watch",
"dev": "electron-vite dev --watch",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
@@ -26,7 +26,7 @@
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^2.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "git+https://github.com/imsyy/NeteaseCloudMusicApi.git",
"NeteaseCloudMusicApi": "^4.14.0",
"axios": "^1.4.0",
"colorthief": "^2.4.0",
"electron-dl": "^3.5.1",
@@ -42,7 +42,6 @@
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"plyr": "^3.7.8",
"qrcode.vue": "^3.4.1",
"screenfull": "^6.0.2",
"vue-router": "^4.2.4",
"vue-slider-component": "4.1.0-beta.7"
@@ -59,7 +58,7 @@
"electron-vite": "^1.0.29",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"naive-ui": "^2.35.0",
"naive-ui": "^2.36.0",
"prettier": "^3.0.2",
"sass": "^1.66.1",
"terser": "^5.19.2",

87
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ dependencies:
specifier: ^0.2.7
version: 0.2.7
NeteaseCloudMusicApi:
specifier: git+https://github.com/imsyy/NeteaseCloudMusicApi.git
version: github.com/imsyy/NeteaseCloudMusicApi/fa5c733895bfe6cbf55380c2d7072f367bf6a09b
specifier: ^4.14.0
version: 4.14.0
axios:
specifier: ^1.4.0
version: 1.4.0
@@ -62,9 +62,6 @@ dependencies:
plyr:
specifier: ^3.7.8
version: 3.7.8
qrcode.vue:
specifier: ^3.4.1
version: 3.4.1(vue@3.3.4)
screenfull:
specifier: ^6.0.2
version: 6.0.2
@@ -110,8 +107,8 @@ devDependencies:
specifier: ^9.17.0
version: 9.17.0(eslint@8.47.0)
naive-ui:
specifier: ^2.35.0
version: 2.35.0(vue@3.3.4)
specifier: ^2.36.0
version: 2.36.0(vue@3.3.4)
prettier:
specifier: ^3.0.2
version: 3.0.2
@@ -2240,6 +2237,29 @@ packages:
requiresBuild: true
dev: true
/NeteaseCloudMusicApi@4.14.0:
resolution: {integrity: sha512-NUnojWdggaybe9fzI5xUkvEAIWw3p4PXpf0fbcqw1tvpsSjGY+RsCcBb6GA2VuEnVbiMLxN7WBAKxvivi/6mqQ==}
engines: {node: '>=12'}
hasBin: true
dependencies:
axios: 1.4.0
crypto-js: 4.2.0
express: 4.18.2
express-fileupload: 1.4.0
md5: 2.3.0
music-metadata: 7.13.4
node-forge: 1.3.1
pac-proxy-agent: 7.0.1
qrcode: 1.5.3
safe-decode-uri-component: 1.2.1
tunnel: 0.0.6
xml2js: 0.6.2
yargs: 17.7.2
transitivePeerDependencies:
- debug
- supports-color
dev: false
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -5180,8 +5200,8 @@ packages:
- supports-color
dev: false
/naive-ui@2.35.0(vue@3.3.4):
resolution: {integrity: sha512-PdnLpOip1LQaKs5+rXLZoPDPQkTq26TnHWeABvUA2eOQjtHxE4+TQvj0Jq/W8clM2On/7jptoGmenLt48G3Bhg==}
/naive-ui@2.36.0(vue@3.3.4):
resolution: {integrity: sha512-r1ydtEm1Ryf/aWpbLCf32mQAGK99jd1eXgpkCtIomcBRZeAtusfy6zCtIpCppoCuIKM3BW5DMafhVxilubk/lQ==}
peerDependencies:
vue: ^3.0.0
dependencies:
@@ -5198,12 +5218,12 @@ packages:
highlight.js: 11.8.0
lodash: 4.17.21
lodash-es: 4.17.21
seemly: 0.3.6
seemly: 0.3.8
treemate: 0.3.11
vdirs: 0.1.8(vue@3.3.4)
vooks: 0.2.12(vue@3.3.4)
vue: 3.3.4
vueuc: 0.4.51(vue@3.3.4)
vueuc: 0.4.54(vue@3.3.4)
dev: true
/nanoid@3.3.6:
@@ -5676,14 +5696,6 @@ packages:
escape-goat: 2.1.1
dev: false
/qrcode.vue@3.4.1(vue@3.3.4):
resolution: {integrity: sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==}
peerDependencies:
vue: ^3.0.0
dependencies:
vue: 3.3.4
dev: false
/qrcode@1.5.3:
resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
engines: {node: '>=10.13.0'}
@@ -6013,8 +6025,8 @@ packages:
resolution: {integrity: sha512-4AsO/FrViE/iDNEPaAQlb77tf0csuq27EsVpy6ett584EcRTp6pTDLoGWVxCD77y5iU5FauOvhsI4o1APwPoSQ==}
dev: true
/seemly@0.3.6:
resolution: {integrity: sha512-lEV5VB8BUKTo/AfktXJcy+JeXns26ylbMkIUco8CYREsQijuz4mrXres2Q+vMLdwkuLxJdIPQ8IlCIxLYm71Yw==}
/seemly@0.3.8:
resolution: {integrity: sha512-MW8Qs6vbzo0pHmDpFSYPna+lwpZ6Zk1ancbajw/7E8TKtHdV+1DfZZD+kKJEhG/cAoB/i+LiT+5msZOqj0DwRA==}
dev: true
/semver-compare@1.0.0:
@@ -6980,8 +6992,8 @@ packages:
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
/vueuc@0.4.51(vue@3.3.4):
resolution: {integrity: sha512-pLiMChM4f+W8czlIClGvGBYo656lc2Y0/mXFSCydcSmnCR1izlKPGMgiYBGjbY9FDkFG8a2HEVz7t0DNzBWbDw==}
/vueuc@0.4.54(vue@3.3.4):
resolution: {integrity: sha512-2LED7h1BSnCRPBI6AlSIf+1Yte1shN+Vb2gpspO5wHI7zWzbcq4bAu2f9nFh5yXIUKdzqmLvzRsOXDl4TrDyCw==}
peerDependencies:
vue: ^3.0.11
dependencies:
@@ -6989,7 +7001,7 @@ packages:
'@juggle/resize-observer': 3.4.0
css-render: 0.15.12
evtd: 0.2.4
seemly: 0.3.6
seemly: 0.3.8
vdirs: 0.1.8(vue@3.3.4)
vooks: 0.2.12(vue@3.3.4)
vue: 3.3.4
@@ -7305,30 +7317,3 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
dev: true
github.com/imsyy/NeteaseCloudMusicApi/fa5c733895bfe6cbf55380c2d7072f367bf6a09b:
resolution: {tarball: https://codeload.github.com/imsyy/NeteaseCloudMusicApi/tar.gz/fa5c733895bfe6cbf55380c2d7072f367bf6a09b}
name: NeteaseCloudMusicApi
version: 4.13.8
engines: {node: '>=12'}
hasBin: true
prepare: true
requiresBuild: true
dependencies:
axios: 1.4.0
crypto-js: 4.2.0
express: 4.18.2
express-fileupload: 1.4.0
md5: 2.3.0
music-metadata: 7.13.4
node-forge: 1.3.1
pac-proxy-agent: 7.0.1
qrcode: 1.5.3
safe-decode-uri-component: 1.2.1
tunnel: 0.0.6
xml2js: 0.6.2
yargs: 17.7.2
transitivePeerDependencies:
- debug
- supports-color
dev: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

View File

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

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

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

View File

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

View File

@@ -1,6 +1,7 @@
{
"album": "M12 11a1 1 0 0 0-1 1a1 1 0 0 0 1 1a1 1 0 0 0 1-1a1 1 0 0 0-1-1m0 5.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5s4.5 2 4.5 4.5s-2 4.5-4.5 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"chevron-up": "M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6l1.41 1.41Z",
"chevron-down": "M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z",
"play": "M9.525 18.025q-.5.325-1.012.038T8 17.175V6.825q0-.6.513-.888t1.012.038l8.15 5.175q.45.3.45.85t-.45.85l-8.15 5.175Z",
"play-circle": "M10 16.5v-9l6 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z",
"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",
@@ -48,6 +49,7 @@
"favorite-rounded": "M12 20.325q-.35 0-.713-.125t-.637-.4l-1.725-1.575q-2.65-2.425-4.788-4.813T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.538t2.5-.562q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125Z",
"history": "M12 21q-3.15 0-5.575-1.913T3.275 14.2q-.1-.375.15-.687t.675-.363q.4-.05.725.15t.45.6q.6 2.25 2.475 3.675T12 19q2.925 0 4.963-2.038T19 12q0-2.925-2.038-4.963T12 5q-1.725 0-3.225.8T6.25 8H8q.425 0 .713.288T9 9q0 .425-.288.713T8 10H4q-.425 0-.713-.288T3 9V5q0-.425.288-.713T4 4q.425 0 .713.288T5 5v1.35q1.275-1.6 3.113-2.475T12 3q1.875 0 3.513.713t2.85 1.924q1.212 1.213 1.925 2.85T21 12q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 21Zm1-9.4l2.5 2.5q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.8-2.8q-.15-.15-.225-.337T11 11.975V8q0-.425.288-.713T12 7q.425 0 .713.288T13 8v3.6Z",
"delete": "M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12Z",
"delete-sweep": "M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3zM14 5h-3l-1-1H6L5 5H2v2h12z",
"fire": "M17.66 11.2c-.23-.3-.51-.56-.77-.82c-.67-.6-1.43-1.03-2.07-1.66C13.33 7.26 13 4.85 13.95 3c-.95.23-1.78.75-2.49 1.32c-2.59 2.08-3.61 5.75-2.39 8.9c.04.1.08.2.08.33c0 .22-.15.42-.35.5c-.23.1-.47.04-.66-.12a.58.58 0 0 1-.14-.17c-1.13-1.43-1.31-3.48-.55-5.12C5.78 10 4.87 12.3 5 14.47c.06.5.12 1 .29 1.5c.14.6.41 1.2.71 1.73c1.08 1.73 2.95 2.97 4.96 3.22c2.14.27 4.43-.12 6.07-1.6c1.83-1.66 2.47-4.32 1.53-6.6l-.13-.26c-.21-.46-.77-1.26-.77-1.26m-3.16 6.3c-.28.24-.74.5-1.1.6c-1.12.4-2.24-.16-2.9-.82c1.19-.28 1.9-1.16 2.11-2.05c.17-.8-.15-1.46-.28-2.23c-.12-.74-.1-1.37.17-2.06c.19.38.39.76.63 1.06c.77 1 1.98 1.44 2.24 2.8c.04.14.06.28.06.43c.03.82-.33 1.72-.93 2.27Z",
"search-rounded": "M9.5 16q-2.725 0-4.612-1.888T3 9.5q0-2.725 1.888-4.612T9.5 3q2.725 0 4.612 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16Zm0-2q1.875 0 3.188-1.313T14 9.5q0-1.875-1.313-3.188T9.5 5Q7.625 5 6.312 6.313T5 9.5q0 1.875 1.313 3.188T9.5 14Z",
"search-off": "m7 17.7l1.4 1.425q.15.15.35.15t.35-.15q.15-.15.15-.363T9.1 18.4L7.7 17l1.425-1.425q.15-.15.15-.35t-.15-.35q-.15-.15-.35-.15t-.35.15L7 16.3l-1.425-1.425q-.15-.15-.35-.15t-.35.15q-.15.15-.15.35t.15.35L6.3 17l-1.425 1.425q-.15.15-.15.35t.15.35q.15.15.35.15t.35-.15L7 17.7ZM7 22q-2.075 0-3.538-1.463T2 17q0-2.075 1.463-3.538T7 12q2.075 0 3.538 1.463T12 17q0 2.075-1.463 3.538T7 22Zm7.2-7.4q-.3-.325-.638-.663T12.9 13.3q.95-.6 1.525-1.6T15 9.5q0-1.875-1.313-3.188T10.5 5Q8.625 5 7.312 6.313T6 9.5q0 .15.013.288t.037.287q-.45.05-.987.2t-.963.35q-.05-.275-.075-.55T4 9.5q0-2.725 1.888-4.612T10.5 3q2.725 0 4.612 1.888T17 9.5q0 1.075-.338 2.038t-.937 1.762l5.575 5.6q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275l-5.7-5.7Z",

View File

@@ -64,11 +64,15 @@
<div class="cover-content">
<n-text class="name">{{ item.name }}</n-text>
<!-- 创建者 -->
<n-text v-if="item.creator" class="creator" depth="3">
<n-text v-if="item?.creator" class="creator" depth="3">
{{ item.creator?.nickname || item.creator || "未知" }}
</n-text>
<!-- 电台简介 -->
<n-text v-if="item?.rcmdText" class="creator" depth="3">
{{ item.rcmdText || "未知简介" }}
</n-text>
<!-- 歌手 -->
<div v-if="item.artists" class="artists">
<div v-if="item.artists && type !== 'dj'" class="artists">
<n-text
v-for="ar in item.artists"
:key="ar.id"
@@ -198,6 +202,14 @@ const jumpLink = (data, type) => {
},
});
break;
case "dj":
router.push({
path: "/dj",
query: {
id: data?.id,
},
});
break;
default:
break;
}
@@ -260,7 +272,9 @@ const jumpLink = (data, type) => {
top: -80px;
left: 0;
z-index: 1;
width: 100%;
padding: 6px 10px;
box-sizing: border-box;
background: -webkit-linear-gradient(top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
opacity: 0;
transition:

View File

@@ -140,12 +140,12 @@ const menuOptions = computed(() => [
RouterLink,
{
to: {
name: "record",
name: "dj-hot",
},
},
() => ["播客电台"],
),
key: "record",
key: "dj-hot",
icon: renderIcon("record"),
},
{

View File

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

View File

@@ -5,9 +5,13 @@
<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">
歌曲
{{ 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>
<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>
</div>
@@ -29,7 +33,7 @@
hoverable
@dblclick.stop="playSong(data, item, songsIndex + index)"
@contextmenu="
songListDropdownRef?.openDropdown($event, data, item, songsIndex + index, sourceId)
songListDropdownRef?.openDropdown($event, data, item, songsIndex + index, sourceId, type)
"
>
<!-- 序号 -->
@@ -122,6 +126,9 @@
{{ ar.name }}
</n-text>
</div>
<div v-else-if="type === 'dj'" class="artist">
<n-text class="ar"> 电台节目 </n-text>
</div>
<div v-else class="artist">
<n-text class="ar"> {{ item.artists || "未知艺术家" }} </n-text>
</div>
@@ -129,7 +136,7 @@
<n-text v-if="item.alia" class="alia" depth="3">{{ item.alia }}</n-text>
</div>
<!-- 专辑 -->
<template v-if="showAlbum">
<template v-if="showAlbum && type !== 'dj'">
<n-text
v-if="item.album"
class="album"
@@ -140,7 +147,7 @@
<n-text v-else class="album">未知专辑</n-text>
</template>
<!-- 操作 -->
<div class="action">
<div v-if="type !== 'dj'" class="action">
<!-- 喜欢歌曲 -->
<n-icon
:depth="dataStore.getSongIsLike(item?.id) ? 0 : 3"
@@ -158,6 +165,14 @@
/>
</n-icon>
</div>
<!-- 更新日期 -->
<n-text v-if="type === 'dj' && item.updateTime" class="update" depth="3">
{{ getTimestampTime(item.updateTime, false) }}
</n-text>
<!-- 播放量 -->
<n-text v-if="type === 'dj' && item.playCount" class="count" depth="3">
{{ item.playCount }}次
</n-text>
<!-- 时长 -->
<n-text v-if="item.duration" class="duration" depth="3">{{ item.duration }}</n-text>
<n-text v-else class="duration"> -- </n-text>
@@ -212,18 +227,24 @@ import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { siteData, siteSettings, musicData } from "@/stores";
import { initPlayer, fadePlayOrPause, addSongToNext } from "@/utils/Player";
import { getTimestampTime } from "@/utils/timeTools";
const router = useRouter();
const music = musicData();
const dataStore = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(dataStore);
const { loadSize } = storeToRefs(settings);
const { loadSize, playSearch } = storeToRefs(settings);
const { playList, playIndex, playSongData, playSongSource, playHeartbeatMode, playMode } =
storeToRefs(music);
// eslint-disable-next-line no-unused-vars
const props = defineProps({
// 列表类型
type: {
type: String,
default: "song",
},
// 列表数据
data: {
type: [Array, String],
@@ -292,7 +313,7 @@ const checkHasPlaying = (isScoll = null) => {
const playSong = async (data, song, index) => {
console.log(data, song, index);
// 更改模式
playMode.value = "normal";
playMode.value = props.type === "song" ? "normal" : "dj";
// 检查当前页面
const isPage = router.currentRoute.value.matched?.[0].path || null;
// 是否关闭心动模式
@@ -303,7 +324,12 @@ const playSong = async (data, song, index) => {
fadePlayOrPause();
} else {
// 若为特殊状态
if (isPage === "/search" || isPage === "/history" || playHeartbeatMode.value) {
if (
(isPage === "/search" && !playSearch.value) ||
isPage === "/history" ||
playHeartbeatMode.value
) {
console.log("仅播放当前歌曲");
addSongToNext(song, true);
} else {
// 添加播放列表
@@ -314,7 +340,7 @@ const playSong = async (data, song, index) => {
console.log("与当前播放歌曲不一致");
playSongData.value = song;
// 初始化播放器
initPlayer(true);
await initPlayer(true);
}
// 附加来源
playSongSource.value = Number(props.sourceId);
@@ -371,6 +397,14 @@ onBeforeUnmount(() => {
.has-cover {
margin-right: 66px;
}
.update {
width: 80px;
text-align: center;
}
.count {
width: 120px;
text-align: center;
}
.duration {
width: 40px;
text-align: center;
@@ -519,6 +553,14 @@ onBeforeUnmount(() => {
}
}
}
.update {
width: 80px;
text-align: center;
}
.count {
width: 120px;
text-align: center;
}
.duration {
width: 40px;
text-align: center;

View File

@@ -64,7 +64,7 @@ const renderIcon = (icon, size, translate = 0) => {
};
// 歌曲信息
const renderSong = (song) => {
const renderSong = (song, isSong) => {
return () =>
h(
"div",
@@ -75,26 +75,28 @@ const renderSong = (song) => {
h(NImage, { src: song?.coverSize?.s || song?.cover, class: "cover" }),
h("div", { class: "song-detail" }, [
h(NText, { class: "name" }, () => [song?.name || "未知曲目"]),
song.artists && Array.isArray(song.artists)
? h(
"div",
{ class: "all-ar" },
song.artists.map((ar) =>
h(NText, { key: ar.id, class: "ar", depth: 3 }, () => [ar.name]),
),
)
: h(
"div",
{ class: "all-ar" },
h(NText, { class: "ar", depth: 3 }, () => [song.artists || "未知艺术家"]),
),
isSong
? song.artists && Array.isArray(song.artists)
? h(
"div",
{ class: "all-ar" },
song.artists.map((ar) =>
h(NText, { key: ar.id, class: "ar", depth: 3 }, () => [ar.name]),
),
)
: h(
"div",
{ class: "all-ar" },
h(NText, { class: "ar", depth: 3 }, () => [song.artists || "未知艺术家"]),
)
: h(NText, { class: "ar", depth: 3 }, () => ["电台节目"]),
]),
],
);
};
// 打开右键菜单
const openDropdown = (e, data, song, index, sourceId) => {
const openDropdown = (e, data, song, index, sourceId, type) => {
try {
e.preventDefault();
dropdownShow.value = false;
@@ -106,7 +108,9 @@ const openDropdown = (e, data, song, index, sourceId) => {
);
// 当前状态
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 isCloud = router.currentRoute.value.name === "cloud";
const isUserPlaylist = sourceId !== 0 && userPlaylistsData.some((pl) => pl.id == sourceId);
// 生成菜单
@@ -117,7 +121,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
key: "song-data",
type: "render",
show: !isLocalSong,
render: renderSong(song),
render: renderSong(song, isSong),
},
{
key: "line-song",
@@ -137,9 +141,10 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "next-play",
label: "下一首播放",
show: playSongData.value?.id !== song.id && !isFm,
show: isSong && playMode.value !== "dj" && playSongData.value?.id !== song.id && !isFm,
props: {
onClick: () => {
playMode.value = "song";
addSongToNext(song);
},
},
@@ -148,7 +153,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "add-pl",
label: "添加到歌单",
show: song?.path ? false : true,
show: isSong && !isLocalSong,
props: {
onClick: () => {
addPlaylistRef.value?.openAddToPlaylist(song?.id);
@@ -159,7 +164,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "comment",
label: "查看评论",
show: song?.path ? false : true,
show: isSong && !isLocalSong,
props: {
onClick: () => {
router.push({
@@ -175,7 +180,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "mv",
label: "观看 MV",
show: song.mv && song.mv !== 0 ? true : false,
show: isSong && isHasMv,
props: {
onClick: () => {
router.push({
@@ -196,7 +201,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
children: [
{
key: "copy",
label: "复制歌曲 ID",
label: `复制${isSong ? "歌曲" : "节目"} ID`,
props: {
onClick: () => {
const songId = song?.id?.toString();
@@ -207,11 +212,13 @@ const openDropdown = (e, data, song, index, sourceId) => {
},
{
key: "share",
label: "分享歌曲链接",
label: `分享${isSong ? "歌曲" : "节目"}链接`,
props: {
onClick: () => {
const shareUrl = `https://music.163.com/song?id=${song?.id?.toString()}`;
copyData(shareUrl, "复制歌曲链接");
const shareUrl = isSong
? `https://music.163.com/song?id=${song?.id?.toString()}`
: `https://music.163.com/#/dj?id=${song?.id?.toString()}`;
copyData(shareUrl, `复制${isSong ? "歌曲" : "节目"}链接`);
},
},
icon: renderIcon("share"),
@@ -301,7 +308,7 @@ const openDropdown = (e, data, song, index, sourceId) => {
{
key: "download",
label: "下载歌曲",
show: !isLocalSong,
show: isSong && !isLocalSong,
props: {
onClick: () => {
downloadSongRef.value?.openDownloadModal(song);

View File

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

View File

@@ -59,7 +59,7 @@ import { isLogin } from "@/utils/auth";
import { useRouter } from "vue-router";
import { siteData, siteSettings } from "@/stores";
import { getSongDetail, getSongDownload } from "@/api/song";
import { downloadFile } from "@/utils/helper";
import { downloadFile, checkPlatform } from "@/utils/helper";
import formatData from "@/utils/formatData";
const router = useRouter();
@@ -97,7 +97,7 @@ const toSongDownload = async (song, br) => {
// 获取下载数据
const result = await getSongDownload(song?.id, br);
// 开始下载
if (!downloadPath.value) {
if (!downloadPath.value && checkPlatform.electron()) {
$notification["warning"]({
content: "缺少配置",
meta: "请前往设置页配置默认下载目录",

View File

@@ -14,7 +14,7 @@
>
<div class="login-content">
<div class="title">
<img class="logo" src="/images/logo/favicon.png?asset" alt="logo" />
<img class="logo" src="/images/icons/favicon.png?asset" alt="logo" />
</div>
<!-- 登录方式 -->
<n-tabs class="login-tabs" default-value="login-qr" type="segment" animated>
@@ -45,12 +45,15 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { siteData, siteSettings } from "@/stores";
import { getLoginState, refreshLogin } from "@/api/login";
import { setCookies, toLogout, isLogin } from "@/utils/auth";
import userSignIn from "@/utils/userSignIn";
const data = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(data);
const { autoSignIn } = storeToRefs(settings);
// 登录数据
const loginModalShow = ref(false);
@@ -83,6 +86,9 @@ const setLoginData = async (loginData) => {
setCookies(loginData.cookie);
// 获取用户信息
await data.setUserProfile();
await data.setDailySongsData();
// 签到
if (autoSignIn.value) await userSignIn();
// 更改状态
data.userLoginStatus = true;
$message.success("登录成功");

View File

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

View File

@@ -6,7 +6,7 @@
:class="['logo', status.asideMenuCollapsed ? 'collapsed' : null]"
@click="router.push('/')"
>
<n-avatar class="logo-img" src="/images/logo/favicon.png?asset" />
<n-avatar class="logo-img" src="/images/icons/favicon.png?asset" />
<Transition name="fade" mode="out-in">
<n-text v-if="!status.asideMenuCollapsed && showSider" class="site-name">
{{ siteName }}

View File

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

View File

@@ -7,7 +7,7 @@
'no-sider': !showSider,
}"
content-style="padding: 0"
@dblclick.stop="showFullPlayer = true"
@dblclick.stop="openFullPlayer"
>
<!-- 进度条 -->
<vue-slider
@@ -31,7 +31,7 @@
<div class="player">
<!-- 歌曲信息 -->
<div class="info">
<div class="cover" @click.stop="showFullPlayer = true">
<div class="cover" @click.stop="openFullPlayer">
<Transition name="fade" mode="out-in">
<n-image
:key="music.getPlaySongData?.id"
@@ -62,9 +62,12 @@
</div>
<div class="song-info">
<div class="name">
<n-text class="text">{{ music.getPlaySongData?.name || "未知曲目" }}</n-text>
<n-text class="text">
{{ music.getPlaySongData?.name || "未知曲目" }}
</n-text>
<!-- 喜欢歌曲 -->
<n-icon
v-if="playMode !== 'dj'"
class="favorite"
@click.stop="
data.changeLikeList(
@@ -85,7 +88,7 @@
</n-icon>
<!-- 更多操作 -->
<n-dropdown
v-if="!music.getPlaySongData?.path"
v-if="playMode !== 'dj' && !music.getPlaySongData?.path"
:options="songMoreOptions"
:show-arrow="true"
placement="top-start"
@@ -100,7 +103,8 @@
<!-- 歌手 -->
<div
v-if="
((!playState || !bottomLyricShow) && playSongLyric.lrc?.length) ||
((!playState || !bottomLyricShow || playMode === 'dj') &&
playSongLyric.lrc?.length) ||
playSongLyricIndex === -1
"
class="artist"
@@ -119,6 +123,7 @@
{{ ar.name }}
</n-text>
</template>
<div v-else-if="playMode === 'dj'" class="ar">电台节目</div>
<n-text v-else class="ar">
{{ music.getPlaySongData?.artists || "未知艺术家" }}
</n-text>
@@ -194,110 +199,121 @@
</n-icon>
</div>
<!-- 功能区 -->
<div class="menu">
<!-- 时间进度 -->
<div class="time">
<n-text class="played" depth="3">{{ playTimeData.played }}</n-text>
<n-text depth="3">{{ playTimeData.durationTime }}</n-text>
</div>
<!-- 播放模式 -->
<n-dropdown
v-if="playMode === 'normal'"
:options="playModeOptions"
:show-arrow="true"
trigger="hover"
@select="playModeChange"
>
<div class="mode" @click.stop @dblclick.stop>
<n-icon size="22">
<SvgIcon
:icon="
playHeartbeatMode
? 'heartbit'
: playSongMode === 'normal'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
"
isSpecial
/>
</n-icon>
<Transition name="fade" mode="out-in">
<div :key="playMode" class="menu">
<!-- 时间进度 -->
<div class="time">
<n-text class="played" depth="3">{{ playTimeData.played }}</n-text>
<n-text depth="3">{{ playTimeData.durationTime }}</n-text>
</div>
</n-dropdown>
<!-- 倍速 -->
<n-popover :show-arrow="false" trigger="hover" placement="top-end" raw>
<template #trigger>
<div class="speed" @click.stop="(playRate = 1), setRate(1)" @dblclick.stop>
<n-icon v-if="playRate === 1" size="22">
<SvgIcon icon="speed-rounded" />
</n-icon>
<n-text v-else class="speed-text">{{ playRate }}x</n-text>
</div>
</template>
<!-- 倍速调整 -->
<div class="slider-content">
<n-slider
v-model:value="playRate"
:tooltip="false"
:min="0.1"
:max="2"
:step="0.1"
:marks="{
0.1: '减速',
1: '正常',
2: '加速',
}"
style="width: 220px"
@update:value="setRate"
/>
</div>
</n-popover>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon class="volume" size="22" @click.stop="setVolumeMute" @wheel="changeVolume">
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon v-else-if="playVolume > 0 && playVolume < 0.4" icon="volume-mute-rounded" />
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
:style="{
padding: '10px 0',
width: '50px',
}"
class="slider-content"
@wheel="changeVolume"
<!-- 播放模式 -->
<n-dropdown
v-if="playMode !== 'fm'"
:options="playModeOptions"
:show-arrow="true"
trigger="hover"
@select="playModeChange"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放列表 -->
<n-icon
v-if="playMode === 'normal'"
class="playlist"
size="22"
@click.stop="playListShow = !playListShow"
>
<SvgIcon icon="queue-music-rounded" />
</n-icon>
</div>
<div class="mode" @click.stop @dblclick.stop>
<n-icon size="22">
<SvgIcon
:icon="
playHeartbeatMode
? 'heartbit'
: playSongMode === 'normal'
? 'repeat-list'
: playSongMode === 'random'
? 'shuffle'
: 'repeat-song'
"
isSpecial
/>
</n-icon>
</div>
</n-dropdown>
<!-- 倍速 -->
<n-popover :show-arrow="false" trigger="hover" placement="top-end" raw>
<template #trigger>
<div class="speed" @click.stop="(playRate = 1), setRate(1)" @dblclick.stop>
<n-icon v-if="playRate === 1" size="22">
<SvgIcon icon="speed-rounded" />
</n-icon>
<n-text v-else class="speed-text">{{ playRate }}x</n-text>
</div>
</template>
<!-- 倍速调整 -->
<div class="slider-content">
<n-slider
v-model:value="playRate"
:tooltip="false"
:min="0.1"
:max="2"
:step="0.1"
:marks="{
0.1: '减速',
1: '正常',
2: '加速',
}"
style="width: 220px"
@update:value="setRate"
/>
</div>
</n-popover>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon class="volume" size="22" @click.stop="setVolumeMute" @wheel="changeVolume">
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon
v-else-if="playVolume > 0 && playVolume < 0.4"
icon="volume-mute-rounded"
/>
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
:style="{
padding: '10px 0',
width: '50px',
}"
class="slider-content"
@wheel="changeVolume"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放列表 -->
<n-badge
v-if="playMode !== 'fm'"
:value="playList?.length ?? 0"
:show="showPlaylistCount"
:max="999"
:style="{
marginRight: showPlaylistCount ? '12px' : null,
}"
class="playlist"
>
<n-icon size="22" @click.stop="playListShow = !playListShow">
<SvgIcon icon="queue-music-rounded" />
</n-icon>
</n-badge>
</div>
</Transition>
</div>
<!-- 添加到歌单 -->
<AddPlaylist ref="addPlaylistRef" />
@@ -345,7 +361,7 @@ const {
playSongLyric,
} = storeToRefs(music);
const { playLoading, playState, playListShow, showPlayBar, showFullPlayer } = storeToRefs(status);
const { showYrc, bottomLyricShow, showSider } = storeToRefs(settings);
const { showYrc, bottomLyricShow, showSider, showPlaylistCount } = storeToRefs(settings);
// 子组件
const addPlaylistRef = ref(null);
@@ -450,6 +466,15 @@ const songTimeSliderUpdate = (val) => {
}
};
// 开启播放器
const openFullPlayer = () => {
if (playMode.value === "dj") {
$message.warning("当前为电台模式,无法开启播放器");
return false;
}
showFullPlayer.value = true;
};
// 上下曲切换
const changePlayIndexDebounce = debounce(async (type, id) => {
// 垃圾桶
@@ -533,12 +558,12 @@ watch(
border-radius: 25px;
.vue-slider-process {
background-color: var(--main-color);
transition: none !important;
// transition: none !important;
}
.vue-slider-dot {
width: 12px !important;
height: 12px !important;
transition: none !important;
// transition: none !important;
}
.vue-slider-dot-handle {
transition: box-shadow 0.3s;
@@ -671,6 +696,7 @@ watch(
.lrc-text {
margin-top: 2px;
font-size: 13px;
transition: opacity 0.1s ease-in-out;
.space {
margin-right: 4px;
}
@@ -720,18 +746,7 @@ watch(
display: flex;
flex-direction: row;
justify-content: flex-end;
.time {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 4px;
.played {
&::after {
content: "/";
margin: 0 4px;
}
}
}
transition: opacity 0.1s;
.n-icon {
margin-left: 8px;
padding: 8px;
@@ -753,6 +768,18 @@ watch(
transform: scale(1);
}
}
.time {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 4px;
.played {
&::after {
content: "/";
margin: 0 4px;
}
}
}
.speed {
margin-left: 8px;
display: flex;
@@ -774,6 +801,19 @@ watch(
cursor: pointer;
}
}
.playlist {
transition: margin 0.3s;
&.count {
margin-right: 12px;
}
}
:deep(.n-badge-sup) {
background: var(--main-boxshadow-color);
backdrop-filter: blur(20px);
.n-base-slot-machine {
color: var(--main-color);
}
}
}
}
&.show-bar {

View File

@@ -346,12 +346,12 @@ const controlMove = (e) => {
background-color: var(--cover-second-color);
.vue-slider-process {
background-color: var(--cover-main-color);
transition: none !important;
// transition: none !important;
}
.vue-slider-dot {
width: 10px !important;
height: 10px !important;
transition: none !important;
// transition: none !important;
.vue-slider-dot-handle {
background-color: var(--cover-main-color);
}

View File

@@ -48,7 +48,12 @@
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="privateFmSong?.artists" class="all-ar">
<span v-for="ar in privateFmSong.artists" :key="ar.id" class="ar">
<span
v-for="ar in privateFmSong.artists"
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
>
{{ ar.name }}
</span>
</div>

View File

@@ -53,7 +53,7 @@ const searchInpRef = ref(null);
const searchInputValue = ref("");
// 搜索框输入限制
const noSideSpace = (value) => !value.startsWith(" ") && !value.endsWith(" ");
const noSideSpace = (value) => !value.startsWith(" ");
// 搜索框 focus
const searchInputFocus = () => {
@@ -63,6 +63,7 @@ const searchInputFocus = () => {
// 添加搜索历史
const setSearchHistory = (name) => {
if (!name || !name?.trim()) return false;
const index = data.searchHistory.indexOf(name);
if (index !== -1) {
data.searchHistory.splice(index, 1);

View File

@@ -1,5 +1,4 @@
import { checkPlatform } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
const routes = [
// 首页
@@ -9,7 +8,7 @@ const routes = [
meta: {
title: "主页",
},
component: () => import("@/views/home.vue"),
component: () => import("@/views/Home.vue"),
},
// 搜索
{
@@ -46,6 +45,11 @@ const routes = [
name: "sea-playlists",
component: () => import("@/views/Search/playlists.vue"),
},
{
path: "djs",
name: "sea-djs",
component: () => import("@/views/Search/djs.vue"),
},
],
},
// 发现音乐
@@ -87,7 +91,17 @@ const routes = [
meta: {
title: "视频播放器",
},
component: () => import("@/views/player.vue"),
component: () => import("@/views/Player.vue"),
},
// 每日推荐
{
path: "/daily-songs",
name: "daily-songs",
meta: {
title: "每日推荐",
needLogin: true,
},
component: () => import("@/views/DailySongs.vue"),
},
// 评论
{
@@ -96,7 +110,7 @@ const routes = [
meta: {
title: "评论",
},
component: () => import("@/views/comment.vue"),
component: () => import("@/views/Comment.vue"),
},
// 最近播放
{
@@ -105,7 +119,7 @@ const routes = [
meta: {
title: "最近播放",
},
component: () => import("@/views/history.vue"),
component: () => import("@/views/History.vue"),
},
// 我的云盘
{
@@ -113,17 +127,9 @@ const routes = [
name: "cloud",
meta: {
title: "我的云盘",
needLogin: true,
},
component: () => import("@/views/cloud.vue"),
beforeEnter: (_, __, next) => {
if (isLogin()) {
next();
} else {
if (typeof $changeLogin !== "undefined") $changeLogin();
$message.error("请登录后使用");
$loadingBar.error();
}
},
component: () => import("@/views/Cloud.vue"),
},
// 歌单
{
@@ -140,17 +146,9 @@ const routes = [
name: "like-songs",
meta: {
title: "歌单",
needLogin: true,
},
component: () => import("@/views/List/playlist.vue"),
beforeEnter: (_, __, next) => {
if (isLogin()) {
next();
} else {
if (typeof $changeLogin !== "undefined") $changeLogin();
$message.error("请登录后使用");
$loadingBar.error();
}
},
},
// 专辑
{
@@ -161,6 +159,15 @@ const routes = [
},
component: () => import("@/views/List/album.vue"),
},
// 播客电台
{
path: "/dj",
name: "dj",
meta: {
title: "播客电台",
},
component: () => import("@/views/List/dj.vue"),
},
// 歌手
{
path: "/artist",
@@ -199,17 +206,9 @@ const routes = [
name: "like",
meta: {
title: "我的收藏",
needLogin: true,
},
component: () => import("@/views/Like/index.vue"),
beforeEnter: (_, __, next) => {
if (isLogin()) {
next();
} else {
if (typeof $changeLogin !== "undefined") $changeLogin();
$message.error("请登录后使用");
$loadingBar.error();
}
},
redirect: "/like/albums",
children: [
{
@@ -232,6 +231,11 @@ const routes = [
name: "like-playlists",
component: () => import("@/views/Like/playlists.vue"),
},
{
path: "djs",
name: "like-djs",
component: () => import("@/views/Like/djs.vue"),
},
],
},
// 本地歌曲
@@ -240,16 +244,10 @@ const routes = [
name: "local",
meta: {
title: "本地歌曲",
needLogin: true,
show: checkPlatform.electron(),
},
component: () => import("@/views/Local/index.vue"),
beforeEnter: (to, from, next) => {
if (checkPlatform.electron()) {
next();
} else {
next("/403");
}
},
redirect: "/local/songs",
children: [
{
@@ -271,20 +269,21 @@ const routes = [
},
// 播客
{
path: "/record",
name: "record",
path: "/dj-hot",
name: "dj-hot",
meta: {
title: "播客",
title: "热门播客",
},
component: () => import("@/views/Record/index.vue"),
redirect: "/record/hot",
children: [
{
path: "hot",
name: "record-hot",
component: () => import("@/views/Record/hot.vue"),
},
],
component: () => import("@/views/Dj/index.vue"),
},
// 播客 -分类
{
path: "/dj-type",
name: "dj-type",
meta: {
title: "播客分类",
},
component: () => import("@/views/Dj/type.vue"),
},
// 全局设置
{
@@ -302,7 +301,7 @@ const routes = [
meta: {
title: "全局设置",
},
component: () => import("@/views/song.vue"),
component: () => import("@/views/Song.vue"),
},
// 测试页面
{
@@ -311,7 +310,7 @@ const routes = [
meta: {
title: "测试页面",
},
component: () => import("@/views/test.vue"),
component: () => import("@/views/Test.vue"),
},
// 状态页
{

View File

@@ -9,7 +9,7 @@ const useMusicDataStore = defineStore("musicData", {
state: () => {
return {
// 当前模式
// normal 正常 / fm 私人 FM
// normal 正常 / fm 私人 FM / dj 电台
playMode: "normal",
// normal 顺序播放 / random 随机播放 / repeat 单曲循环
playSongMode: "normal",

View File

@@ -12,8 +12,10 @@ import {
getUserArtist,
getUserAlbum,
getUserMv,
getUserDj,
} from "@/api/user";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
import throttle from "@/utils/throttle";
const useSiteDataStore = defineStore("siteData", {
@@ -34,6 +36,7 @@ const useSiteDataStore = defineStore("siteData", {
artists: [],
albums: [],
mvs: [],
djs: [],
},
// 每日推荐
dailySongsData: {
@@ -56,38 +59,31 @@ const useSiteDataStore = defineStore("siteData", {
},
actions: {
// 获取每日推荐
async setDailySongsData() {
async setDailySongsData(refresh = false) {
try {
if (!isLogin()) {
this.dailySongsData = { timestamp: null, data: [] };
return false;
}
const data = this.dailySongsData.data;
const songsData = this.dailySongsData.data;
const timestamp = this.dailySongsData.timestamp;
if (data[0] && timestamp) {
console.log("触发日推缓存");
const currentTime = new Date().getTime();
const storedTime = parseInt(timestamp, 10);
const nextDay6AM = new Date(storedTime);
nextDay6AM.setHours(6, 0, 0, 0);
if (currentTime <= nextDay6AM.getTime()) {
return true;
}
// 下一天六点
const nextDay6AM = new Date(timestamp);
nextDay6AM.setDate(nextDay6AM.getDate() + 1);
nextDay6AM.setHours(6, 0, 0, 0);
// 是否小于今日 6:00
const originalHour = new Date(timestamp).getHours();
const isAfter6AM =
new Date(timestamp).toDateString() === new Date().toDateString() && originalHour >= 6;
if (!refresh && songsData?.[0] && isAfter6AM && timestamp <= nextDay6AM.getTime()) {
console.log("日推缓存未过期,不更新");
return true;
} else {
const res = await getDailyRec();
const data = res.data.dailySongs;
const currentTime = new Date().getTime();
const formatData = data.map((v) => {
return {
id: v.id,
name: v.name,
artist: v.ar,
album: v.al,
cover: v.al.picUrl.replace(/^http:/, "https:"),
reason: v?.reason,
};
});
this.dailySongsData = { timestamp: currentTime, data: formatData };
const songsData = formatData(res.data.dailySongs, "song");
console.log("日推缓存不存在或已过期", songsData);
this.dailySongsData = { timestamp: new Date().getTime(), data: songsData };
if (refresh) $message.success("日推更新成功");
}
} catch (error) {
showError(error, "每日推荐加载失败");
@@ -131,11 +127,11 @@ const useSiteDataStore = defineStore("siteData", {
this.setUserLikeArtists(),
this.setUserLikeAlbums(),
this.setUserLikeMvs(),
this.setUserLikeDjs(),
];
await Promise.all(allUserLikeResult);
} catch (error) {
console.error("用户信息加载失败", error);
$message.error("用户信息加载失败");
showError(error, "用户信息加载失败");
}
},
// 获取用户喜欢歌曲
@@ -147,8 +143,7 @@ const useSiteDataStore = defineStore("siteData", {
this.userLikeData.songs = res.ids;
});
} catch (error) {
console.error("用户喜欢歌曲加载失败", error);
$message.error("用户喜欢歌曲加载失败");
showError(error, "用户喜欢歌曲加载失败");
}
},
// 获取用户喜欢歌单
@@ -160,11 +155,10 @@ const useSiteDataStore = defineStore("siteData", {
const number = createdPlaylistCount + subPlaylistCount ?? 50;
// 获取数据
getUserPlaylist(this.userData.userId, number).then((res) => {
this.userLikeData.playlists = res.playlist;
this.userLikeData.playlists = formatData(res.playlist);
});
} catch (error) {
console.error("用户喜欢歌单加载失败", error);
$message.error("用户喜欢歌单加载失败");
showError(error, "用户喜欢歌单加载失败");
}
},
// 更改用户喜欢歌手
@@ -173,11 +167,10 @@ const useSiteDataStore = defineStore("siteData", {
if (!isLogin()) return false;
// 获取数据
getUserArtist().then((res) => {
this.userLikeData.artists = res.data;
this.userLikeData.artists = formatData(res.data, "artist");
});
} catch (error) {
console.error("用户喜欢歌手加载失败", error);
$message.error("用户喜欢歌手加载失败");
showError(error, "用户喜欢歌手加载失败");
}
},
// 更改用户喜欢专辑
@@ -191,15 +184,13 @@ const useSiteDataStore = defineStore("siteData", {
// 获取数据
while (totalCount === null || offset < totalCount) {
const res = await getUserAlbum(50, offset);
res.data.forEach((v) => {
this.userLikeData.albums.push(v);
});
const albumsData = formatData(res.data, "album");
this.userLikeData.albums = this.userLikeData.albums.concat(albumsData);
totalCount = res.count;
offset += 50;
}
} catch (error) {
console.error("用户喜欢专辑加载失败", error);
$message.error("用户喜欢专辑加载失败");
showError(error, "用户喜欢专辑加载失败");
}
},
// 更改用户喜欢视频
@@ -208,11 +199,22 @@ const useSiteDataStore = defineStore("siteData", {
if (!isLogin()) return false;
// 获取数据
getUserMv().then((res) => {
this.userLikeData.mvs = res.data;
this.userLikeData.mvs = formatData(res.data, "mv");
});
} catch (error) {
console.error("用户喜欢歌手加载失败", error);
$message.error("用户喜欢歌手加载失败");
showError(error, "用户喜欢歌手加载失败");
}
},
// 更改用户喜欢电台
async setUserLikeDjs() {
try {
if (!isLogin()) return false;
// 获取数据
getUserDj().then((res) => {
this.userLikeData.djs = formatData(res.djRadios, "dj");
});
} catch (error) {
showError(error, "用户喜欢电台加载失败");
}
},
// 查找歌曲是否处于喜欢列表
@@ -236,7 +238,7 @@ const useSiteDataStore = defineStore("siteData", {
// 输出错误
const showError = (error, msg, show = true) => {
console.error(msg, error);
if (show) $message.error(msg);
if (show && typeof $message !== "undefined") $message.error(msg);
};
// 移入移除喜欢列表

View File

@@ -29,6 +29,8 @@ const useSiteSettingsStore = defineStore("siteSettings", {
bottomLyricShow: true, // 底栏歌词显示
playerBackgroundType: "blur", // 播放器背景类别 animation 流动 / blur 模糊
memorySeek: true, // 记忆上次播放位置
playSearch: false, // 是否播放全部搜索结果
showPlaylistCount: true, // 是否显示播放列表数量
// 数量部分
loadSize: 50, // 每页加载数量
// 歌词部分

View File

@@ -106,3 +106,24 @@
transform: rotate(360deg);
}
}
@keyframes fade-spacing {
0% {
opacity: 0;
}
100% {
opacity: 1;
letter-spacing: 12px;
}
}
@keyframes fade-down {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -168,3 +168,16 @@ body,
backdrop-filter: blur(16px);
}
}
// layout-toggle-bar
.n-layout-toggle-bar {
height: 44px !important;
top: calc(50% - 22px) !important;
.n-layout-toggle-bar__top,
.n-layout-toggle-bar__bottom {
height: 24px !important;
}
.n-layout-toggle-bar__bottom {
top: 20px !important;
}
}

View File

@@ -30,9 +30,11 @@ export const initPlayer = async (playNow = false) => {
const music = musicData();
const status = siteStatus();
const settings = siteSettings();
const { playList, playIndex } = music;
const { playList, playIndex, playMode } = music;
// 当前播放歌曲数据
const playSongData = music.getPlaySongData;
// 若为电台则更改 id
playSongData.id = playMode === "dj" ? playSongData.mainTrackId : playSongData.id;
// 是否为本地歌曲
const isLocalSong = playSongData?.path ? true : false;
// 获取封面
@@ -42,6 +44,8 @@ export const initPlayer = async (playNow = false) => {
const cover = isLocalSong ? music.playSongData?.localCover : playSongData?.coverSize;
// 歌词归位
music.playSongLyricIndex = -1;
// 若为 fm 模式,则清除当前歌曲信息
if (playMode === "fm") music.playSongData = {};
// 在线歌曲
if (!isLocalSong) {
// 获取歌曲信息
@@ -57,7 +61,7 @@ export const initPlayer = async (playNow = false) => {
createPlayer(url);
}
// 无法正常获取播放地址
else if (checkPlatform.electron() && settings.useUnmServer) {
else if (checkPlatform.electron() && playMode !== "dj" && settings.useUnmServer) {
const url = await getFromUnblockMusic(playSongData, status, playNow);
if (url) {
status.playUseOtherSource = true;
@@ -98,9 +102,9 @@ export const initPlayer = async (playNow = false) => {
}
}
// 获取歌词
getSongLyricData(isLocalSong, playSongData);
if (playMode !== "dj") getSongLyricData(isLocalSong, playSongData);
// 初始化媒体会话控制
initMediaSession(playSongData, isLocalSong, cover);
initMediaSession(playSongData, cover, isLocalSong, playMode === "dj");
// 获取图片主色
getColorMainColor(isLocalSong, cover);
} catch (error) {
@@ -239,13 +243,14 @@ export const createPlayer = async (src, autoPlay = true) => {
status.playLoading = false;
// 发送歌曲名
if (checkPlatform.electron()) {
const songName =
playSongData.name +
" - " +
(Array.isArray(playSongData.artists)
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);
: playSongData.artists || "未知歌手";
electron.ipcRenderer.send("songNameChange", songName + " - " + songArtist);
}
// 听歌打卡
if (isLogin() && !playSongData?.path) {
@@ -611,13 +616,17 @@ const getSongLyricData = async (islocal, data) => {
* @param {string} islocal - 是否为本地歌曲
* @param {string} cover - 封面图像的URL或数据
*/
const initMediaSession = async (data, islocal, cover) => {
const initMediaSession = async (data, cover, islocal, isDj) => {
if ("mediaSession" in navigator) {
// 歌曲信息
navigator.mediaSession.metadata = new MediaMetadata({
title: data.name,
artist: islocal ? data.artists : data.artists?.map((a) => a.name)?.join(" & "),
album: islocal ? data.album : data.album.name,
artist: isDj
? "电台节目"
: islocal
? data.artists
: data.artists?.map((a) => a.name)?.join(" & "),
album: isDj ? "电台节目" : islocal ? data.album : data.album.name,
artwork: islocal
? [
{

View File

@@ -47,12 +47,13 @@ export const isLogin = () => {
/**
* 退出用户登录
*/
export const toLogout = (show = true) => {
export const toLogout = async (show = true) => {
const data = siteData();
// 去除 cookie
logOut();
await logOut();
removeCookie("MUSIC_U");
removeCookie("__csrf");
sessionStorage.clear();
// 去除用户信息
data.userLoginStatus = false;
data.userData = {};
@@ -62,5 +63,9 @@ export const toLogout = (show = true) => {
albums: [],
mvs: [],
};
data.dailySongsData = {
timestamp: null,
data: [],
};
if (show) $message.success("成功退出登录");
};

View File

@@ -20,6 +20,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
(v.picUrl ||
v.coverUrl ||
v.coverImgUrl ||
v.imgurl ||
v.cover ||
(v.album && v.album.picUrl) ||
(v.al && (v.al.picUrl || v.al.xInfo?.picUrl)));
@@ -108,6 +109,24 @@ const formatData = (data, type = "playlist", noTracks = false) => {
duration: v.duration || v.durationms,
playCount: v.playCount || v.playTime,
};
// dj
case "dj":
return {
id: v.id || v.vid,
mainTrackId: v.mainTrackId,
name: v.name,
creator: v.dj,
count: v.programCount,
desc: v.copywriter || v.lastProgramName || v.desc,
cover,
coverSize,
tags: { id: v.categoryId, name: v.category },
rcmdText: v.rcmdtext || v.rcmdText,
playCount: v.playCount || v.listenerCount,
createTime: v.createTime,
updateTime: v.lastProgramCreateTime || v.scheduledPublishTime,
duration: getSongTime(v.duration),
};
default:
return null;
}

View File

@@ -22,8 +22,10 @@ const globalEvents = (router) => {
changePlayIndex(val, true);
});
// 全局设置
electron.ipcRenderer.on("setting", () => {
electron.ipcRenderer.on("open-setting", () => {
if (router) router.push("/setting");
const status = siteStatus();
status.showFullPlayer = false;
});
};

View File

@@ -58,9 +58,8 @@ export const getCacheData = async (key, time, request, params) => {
* @returns {Promise<string>} 返回封面的 Blob URL如果没有封面数据则返回默认 URL
*/
export const getLocalCoverData = async (path, isAlbum = false) => {
let blobUrl = null;
try {
let blobUrl = null;
const coverData = await electron.ipcRenderer.invoke("getMusicCover", path);
if (coverData) {
// 将 Uint8Array 数据转换为 Blob

View File

@@ -92,7 +92,7 @@ const parseLrc = (lyrics, isTrim = true) => {
// 匹配时间轴和歌词文本的正则表达式
const regex = /^\[([^\]]+)\]\s*(.+?)\s*$/;
// 匹配歌曲信息的正则表达式
const infoRegex = /\].*[:-]/;
// const infoRegex = /\].*[:-]/;
// 将歌词字符串按行分割为数组
const lines = lyrics.split("\n");
// 对每一行进行转换
@@ -102,7 +102,7 @@ const parseLrc = (lyrics, isTrim = true) => {
// 转换时间轴和歌词文本为对象
.map((line) => {
// 过滤掉包含信息的文本
if (infoRegex.test(line)) return null;
// if (infoRegex.test(line)) return null;
// 继续解析
const [, time, text] = line.match(regex);
const parts = time.split(":");
@@ -154,11 +154,11 @@ const parseYrc = (lyrics) => {
return null;
}
// 去除歌曲信息
const contentInfoReg = /\s*[^:\n]*[:]\s*.+/;
const contentFilter = content.replace(/\(\d+,\d+,\d+\)/g, "");
if (!contentFilter || contentInfoReg.test(contentFilter)) {
return null;
}
// const contentInfoReg = /\s*[^:\n]*[:]\s*.+/;
// const contentFilter = content.replace(/\(\d+,\d+,\d+\)/g, "");
// if (!contentFilter || contentInfoReg.test(contentFilter)) {
// return null;
// }
// 对歌词内容中的时间戳和歌词内容分离
const contentArray = content
.split(/(\([1-9]\d*,[1-9]\d*,\d*\)[^(]*)/g)

View File

@@ -70,7 +70,6 @@ axios.interceptors.response.use(
break;
default:
// 处理其他状态码或错误条件
$canNotConnect(error);
console.error("未处理的错误:", error.message);
}
// 继续传递错误

View File

@@ -44,12 +44,12 @@ export const getSongTime = (mss) => {
* @param {number} mss - 时间戳
* @returns {string} - 日期字符串
*/
export const getTimestampTime = (mss) => {
export const getTimestampTime = (mss, showYear = true) => {
const date = new Date(parseInt(mss));
const y = date.getFullYear();
const m = `0${date.getMonth() + 1}`.slice(-2);
const d = `0${date.getDate()}`.slice(-2);
return `${y}-${m}-${d}`;
return showYear ? `${y}-${m}-${d}` : `${m}-${d}`;
};
/**

47
src/utils/userSignIn.js Normal file
View File

@@ -0,0 +1,47 @@
import { userDailySignin } from "@/api/user";
import { siteSettings } from "@/stores";
import { isLogin } from "@/utils/auth";
/**
* 用户签到
* https://github.com/Binaryify/NeteaseCloudMusicApi/issues/1387
* 云贝签到本质上就是 Android 客户端每日签到
*/
const userSignIn = async () => {
const settings = siteSettings();
try {
if (!isLogin()) return false;
const today = new Date().toLocaleDateString();
const lastSignInDate = sessionStorage.getItem("lastSignInDate");
if (lastSignInDate !== today) {
const result = await userDailySignin(1);
console.log("签到结果:", result);
sessionStorage.setItem("lastSignInDate", today);
if (result.status === 400) {
return console.log("重复签到");
}
$notification["success"]({
content: "签到通知",
meta: "🎉 每日签到成功",
duration: 3000,
});
} else {
console.log("今日已签到");
}
} catch (error) {
if (error.request.status === 400) {
console.log("重复签到");
sessionStorage.setItem("lastSignInDate", new Date().toLocaleDateString());
return false;
}
settings.autoSignIn = false;
console.error("签到过程中发生错误:", error);
$notification["error"]({
content: "签到通知",
meta: "签到过程中发生错误,已关闭自动签到,详细信息可查看控制台输出,请及时向开发者报告",
duration: 8000,
});
}
};
export default userSignIn;

View File

@@ -58,20 +58,25 @@
</n-space>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<SongList v-if="!searchValue" :data="userCloudData" :showPrivilege="false" />
<SongList v-else-if="searchData?.length" :data="searchData" :showPrivilege="false" />
<n-empty
v-else
:description="`搜不到关于 ${searchValue} 的任何歌曲呀`"
style="margin-top: 60px"
size="large"
>
<template #icon>
<n-icon>
<SvgIcon icon="search-off" />
</n-icon>
</template>
</n-empty>
<div v-if="userCloudData !== 'empty'" class="list">
<Transition name="fade" mode="out-in">
<SongList v-if="!searchValue" :data="userCloudData" :showPrivilege="false" />
<SongList v-else-if="searchData?.length" :data="searchData" :showPrivilege="false" />
<n-empty
v-else
:description="`搜不到关于 ${searchValue} 的任何歌曲呀`"
style="margin-top: 60px"
size="large"
>
<template #icon>
<n-icon>
<SvgIcon icon="search-off" />
</n-icon>
</template>
</n-empty>
</Transition>
</div>
<n-empty v-else class="empty" description="你还有任何歌曲,快去上传吧" />
</Transition>
</div>
</template>
@@ -87,7 +92,7 @@ import formatData from "@/utils/formatData";
const music = musicData();
const indexedDB = indexedDBData();
const { playList, playIndex, playSongData, playHeartbeatMode } = storeToRefs(music);
const { playList, playIndex, playSongData, playHeartbeatMode, playMode } = storeToRefs(music);
//
const userCloudSpace = ref([]);
@@ -125,6 +130,11 @@ const getUserCloudData = async (isOnce = false) => {
(res.size / Math.pow(1024, 3)).toFixed(2),
(res.maxSize / Math.pow(1024, 3)).toFixed(0),
];
if (res.count === 0) {
console.log("云盘为空");
userCloudData.value = "empty";
return false;
}
if (isOnce) break;
}
//
@@ -155,18 +165,21 @@ 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 isHas = userCloudData.value.some((song) => song.id === songId);
const existingIndex = userCloudData.value.findIndex((song) => song.id === songId);
//
if (!isHas) {
playList.value = userCloudData.value;
if (existingIndex === -1 || !songId) {
playSongData.value = userCloudData.value[0];
playIndex.value = 0;
//
initPlayer(true);
await initPlayer(true);
} else {
//
playSongData.value = userCloudData.value[existingIndex];
playIndex.value = existingIndex;
fadePlayOrPause();
}
$message.info("已开始播放", { showIcon: false });
@@ -177,9 +190,9 @@ const goBuy = () => {
window.open("https://music.163.com/#/store/product/detail?id=34001");
};
onBeforeMount(() => {
getUserCloudDataList();
getUserCloudData();
onMounted(async () => {
await getUserCloudDataList();
await getUserCloudData();
});
onMounted(() => {

151
src/views/DailySongs.vue Normal file
View File

@@ -0,0 +1,151 @@
<!-- 每日推荐 -->
<template>
<div class="daily-songs">
<div class="title">
<n-text class="name">每日推荐</n-text>
<div class="tip">
<Transition name="fade" mode="out-in">
<n-text :key="showTime" depth="3">
根据你的音乐口味 ·
{{ showTime && updatedTime ? "更新于 " + updatedTime : "每日 6:00 更新" }}
</n-text>
</Transition>
</div>
<!-- 操作 -->
<n-space class="control">
<n-button size="large" tag="div" round strong secondary @click="playAllSongs">
<template #icon>
<n-icon>
<SvgIcon icon="play-arrow-rounded" />
</n-icon>
</template>
播放全部
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
</n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</div>
<!-- 列表 -->
<SongList :data="dailySongsData.data" />
</div>
</template>
<script setup>
import { NIcon } from "naive-ui";
import { storeToRefs } from "pinia";
import { musicData, siteData } from "@/stores";
import { fadePlayOrPause, initPlayer } 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);
// 图标渲染
const renderIcon = (icon) => {
return () => h(NIcon, null, { default: () => h(SvgIcon, { icon }, null) });
};
// 获取更新时间戳
const updatedTime = computed(() => {
const timestamp = dailySongsData.value.timestamp;
if (!timestamp) return null;
const date = new Date(timestamp);
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month}/${day} ${hours}:${minutes}`;
});
// 更多操作数据
const moreOptions = computed(() => [
{
label: "更新日推",
key: "refresh",
props: {
onclick: async () => {
await data.setDailySongsData(true);
},
},
icon: renderIcon("refresh"),
},
]);
// 播放歌单全部歌曲
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;
}, 1500);
});
onBeforeUnmount(() => {
clearTimeout(showTimeOut.value);
});
</script>
<style lang="scss" scoped>
.daily-songs {
.title {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 30vh;
margin-bottom: 20px;
.name {
font-size: 55px;
font-weight: bold;
animation: fade-spacing 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.tip {
font-size: 16px;
margin-top: 6px;
opacity: 0;
animation: fade-down 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
animation-delay: 0.5s;
}
.control {
margin-top: 30px;
}
}
}
</style>

176
src/views/Dj/index.vue Normal file
View File

@@ -0,0 +1,176 @@
<!-- 播客 / 电台 -->
<template>
<div class="dj">
<!-- 分类 -->
<Transition name="fade" mode="out-in">
<n-grid
v-if="djCatData"
:collapsed="gridCollapsed"
class="dj-cat"
x-gap="20"
y-gap="20"
cols="3 s:4 m:5 l:6 xl:7"
responsive="screen"
>
<n-gi v-for="(item, index) in djCatData" :key="index">
<n-card
class="cat"
hoverable
@click="
router.push({
path: '/dj-type',
query: {
type: item.id,
name: item.name,
},
})
"
>
<n-text>{{ item.name }}</n-text>
</n-card>
</n-gi>
<n-gi class="suffix" suffix #="{ overflow }">
<n-card class="cat" hoverable @click="gridCollapsed = !gridCollapsed">
<n-icon size="24" depth="3">
<SvgIcon :icon="overflow ? 'chevron-down' : 'chevron-up'" />
</n-icon>
<n-text>{{ overflow ? "查看全部" : "收起标签" }}</n-text>
</n-card>
</n-gi>
</n-grid>
<n-grid
v-else
class="dj-cat"
x-gap="20"
y-gap="20"
cols="2 s:3 m:4 l:5 xl:6"
responsive="screen"
collapsed
>
<n-gi v-for="item in 20" :key="item">
<n-card class="cat" hoverable>
<n-skeleton style="height: 20px" text round />
</n-card>
</n-gi>
</n-grid>
</Transition>
<!-- 推荐 -->
<div class="recommend">
<!-- 热门推荐 -->
<n-h3 class="title" prefix="bar">
<n-text class="name">热门推荐</n-text>
</n-h3>
<MainCover :data="djRecommendData" type="dj" />
<!-- 分类推荐 -->
<n-grid x-gap="20" y-gap="20" :cols="2">
<n-gi>
<div class="light-green" />
</n-gi>
</n-grid>
<div v-for="(item, index) in djCatRecData" :key="index" class="type">
<n-h3 class="title" prefix="bar" @click="router.push(`/dj-type?type=${item.categoryId}`)">
<n-text class="name">{{ item.categoryName }}</n-text>
<n-icon class="more" depth="3">
<SvgIcon icon="chevron-right" />
</n-icon>
</n-h3>
<MainCover :data="formatData(item.radios, 'dj')" type="dj" />
</div>
</div>
</div>
</template>
<script setup>
import { getDjCatelist, getDjRecommend, getDjCategoryRec } from "@/api/dj";
import { getCacheData } from "@/utils/helper";
import { useRouter } from "vue-router";
import formatData from "@/utils/formatData";
const router = useRouter();
// 栅格数据
const gridCollapsed = ref(true);
// 电台数据
const djCatData = ref(null);
const djRecommendData = ref(null);
const djCatRecData = ref(null);
// 获取电台分类
const getDjCatelistData = async () => {
try {
const result = await getCacheData("djCat", 30, getDjCatelist);
// 解构赋值
djCatData.value = result.categories.map(({ name, id }) => ({ name, id }));
} catch (error) {
console.error("获取电台分类出现错误", error);
$message.error("电台分类获取失败,请重试");
}
};
// 获取推荐电台
const getDjRecommendData = async () => {
try {
const [recRes, catRecRes] = await Promise.all([getDjRecommend(), getDjCategoryRec()]);
console.log(recRes, catRecRes);
djRecommendData.value = formatData(recRes.djRadios, "dj");
djCatRecData.value = catRecRes.data;
} catch (error) {
console.error("获取推荐电台出错:", error);
$message.error("获取推荐电台出现错误");
}
};
onBeforeMount(async () => {
await getDjCatelistData();
await getDjRecommendData();
});
</script>
<style lang="scss" scoped>
.dj {
.dj-cat {
.cat {
height: 48px;
border-radius: 8px;
cursor: pointer;
:deep(.n-card__content) {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.n-icon {
margin-right: 4px;
}
}
}
.recommend {
margin-top: 20px;
.title {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
margin-left: 2px;
margin-top: 28px;
padding-left: 16px;
cursor: pointer;
.more {
font-size: 26px;
opacity: 0;
transform: translateX(4px);
transition:
opacity 0.3s,
transform 0.3s;
}
&:hover {
.more {
opacity: 1;
transform: translateX(0);
}
}
}
}
}
</style>

78
src/views/Dj/type.vue Normal file
View File

@@ -0,0 +1,78 @@
<!-- 播客 - 分类 -->
<template>
<div class="dj-type">
<div class="type-title">
<n-button class="back" secondary strong round @click="router.push('/dj-hot')">
<template #icon>
<n-icon size="24" depth="3">
<SvgIcon icon="chevron-left" />
</n-icon>
</template>
返回全部
</n-button>
<n-h1 class="title">{{ djName }}</n-h1>
</div>
<!-- 分类 -->
<n-tabs class="tabs" type="segment" animated>
<!-- 类别热门 -->
<n-tab-pane name="type-hot" tab="热门" display-directive="show">
<MainCover :data="djHotData" type="dj" />
</n-tab-pane>
<n-tab-pane name="type-rec" tab="推荐" display-directive="show">
<MainCover :data="djRecData" type="dj" />
</n-tab-pane>
</n-tabs>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { getRadioHot, getRecType } from "@/api/dj";
import formatData from "@/utils/formatData";
const router = useRouter();
// 电台数据
const djId = ref(router.currentRoute.value.query.type);
const djName = ref(router.currentRoute.value.query.name || "未知分类");
const djHotData = ref(null);
const djRecData = ref(null);
// 获取电台分类数据
const getRadioTypeData = async (id) => {
try {
const [recHot, recRec] = await Promise.all([getRadioHot(id), getRecType(id)]);
console.log(recHot, recRec);
// 热门数据
djHotData.value = formatData(recHot.djRadios, "dj");
// 推荐数据
djRecData.value = formatData(recRec.djRadios, "dj");
} catch (error) {
console.error("获取电台分类出错:", error);
$message.error("获取电台分类出现错误");
}
};
onMounted(async () => {
await getRadioTypeData(djId.value);
});
</script>
<style lang="scss" scoped>
.dj-type {
.title {
margin: 16px 0 20px 0;
font-size: 36px;
font-weight: bold;
.back {
font-size: 16px;
font-weight: normal;
}
}
.tabs {
:deep(.n-tab-pane) {
padding-top: 20px;
}
}
}
</style>

View File

@@ -8,14 +8,6 @@
<Transition name="fade" mode="out-in">
<div v-if="historyPlaylist?.length" class="list">
<n-space class="menu">
<n-button type="primary" round strong secondary @click="playAllSongs">
<template #icon>
<n-icon>
<SvgIcon icon="play-arrow-rounded" />
</n-icon>
</template>
播放全部
</n-button>
<n-button round strong secondary @click="cleanHistory">
<template #icon>
<n-icon>
@@ -38,19 +30,9 @@
<script setup>
import { storeToRefs } from "pinia";
import { musicData } from "@/stores";
import { fadePlayOrPause } from "@/utils/Player";
const music = musicData();
const { playList, historyPlaylist, playIndex } = storeToRefs(music);
//
const playAllSongs = async () => {
console.log(historyPlaylist.value);
playList.value = historyPlaylist.value;
playIndex.value = 0;
fadePlayOrPause();
$message.info("已开始播放", { showIcon: false });
};
const { historyPlaylist } = storeToRefs(music);
//
const cleanHistory = () => {

View File

@@ -54,13 +54,15 @@ import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getGreetings } from "@/utils/timeTools";
import { getDailyRec, getPersonalized, getTopArtists, getNewAlbum } from "@/api/recommend";
import { siteData } from "@/stores";
import { siteData, siteSettings } from "@/stores";
import { getCacheData } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
const data = siteData();
const router = useRouter();
const settings = siteSettings();
const { showSider } = storeToRefs(settings);
const { userLikeData, dailySongsData } = storeToRefs(data);
//
@@ -71,7 +73,8 @@ const dailySongsCoverData = computed(() => {
};
if (isLogin() && dailySongsData.value.data?.length) {
const randomIndex = Math.floor(Math.random() * dailySongsData.value.data.length);
dailySongsCover.cover = dailySongsData.value.data[randomIndex]?.cover + "?param=100y100";
dailySongsCover.cover =
dailySongsData.value.data[randomIndex]?.coverSize?.s || "/images/pic/like.jpg";
return dailySongsCover;
}
dailySongsCover.cover = "/images/pic/cover-2.jpg";
@@ -85,8 +88,7 @@ const likeSongsCoverData = computed(() => {
desc: "发现你独特的音乐品味",
};
if (isLogin() && userLikeData.value.playlists?.length) {
const coverUrl = userLikeData.value.playlists[0].coverImgUrl;
likeSongsCover.cover = coverUrl + "?param=100y100";
likeSongsCover.cover = userLikeData.value.playlists[0]?.coverSize?.s || "/images/pic/like.jpg";
return likeSongsCover;
}
likeSongsCover.cover = "/images/pic/like.jpg";
@@ -98,13 +100,14 @@ const recommendData = ref({
playlist: {
name: "推荐歌单",
loadingNum: 12,
columns: showSider.value ? undefined : "2 s:3 m:4 l:5 xl:6",
data: [],
to: "/discover/playlists",
},
artist: {
name: "歌手推荐",
type: "artist",
columns: "3 s:4 m:5 l:6",
columns: showSider.value ? "3 s:4 m:5 l:6" : "3 sm:4 m:5 l:6",
loadingNum: 6,
data: [],
to: "/discover/artists",
@@ -177,7 +180,7 @@ const jumpPage = (key, id) => {
router.push("/like-songs");
break;
case "daily-songs":
$message.warning("施工中( 新建文件夹 ");
router.push("/daily-songs");
break;
default:
break;

View File

@@ -3,7 +3,7 @@
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.albums?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeAlbumsData" type="album" />
<MainCover :data="userLikeData.albums" type="album" />
</div>
<n-empty
v-else
@@ -19,13 +19,7 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理专辑数据
const likeAlbumsData = computed(() => {
return formatData(userLikeData.value.albums, "album");
});
</script>

View File

@@ -3,7 +3,7 @@
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.artists?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeArtistsData" type="artist" columns="3 s:4 m:5 l:6" />
<MainCover :data="userLikeData.artists" type="artist" columns="3 s:4 m:5 l:6" />
</div>
<n-empty
v-else
@@ -19,13 +19,7 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理歌手数据
const likeArtistsData = computed(() => {
return formatData(userLikeData.value.artists, "artist");
});
</script>

25
src/views/Like/djs.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<div class="like-videos">
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.djs?.length" class="list">
<!-- 列表 -->
<MainCover :data="userLikeData.djs" type="dj" />
</div>
<n-empty
v-else
description="当前暂无收藏电台"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
const data = siteData();
const { userLikeData } = storeToRefs(data);
</script>

View File

@@ -1,12 +1,56 @@
<template>
<div class="like">
<n-h1 class="title">我的收藏</n-h1>
<!-- 数据统计 -->
<div class="num">
<!-- 专辑 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="album" />
</n-icon>
<n-number-animation :from="0" :to="userLikeData.albums?.length ?? 0" />
张专辑
</div>
<!-- 歌单 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="queue-music-rounded" />
</n-icon>
<n-number-animation :from="0" :to="userLikeData.playlists?.length ?? 0" />
个歌单
</div>
<!-- 歌手 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="account-music" />
</n-icon>
<n-number-animation :from="0" :to="userLikeData.artists?.length ?? 0" />
位歌手
</div>
<!-- 视频 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="video" />
</n-icon>
<n-number-animation :from="0" :to="userLikeData.mvs?.length ?? 0" />
个视频
</div>
<!-- 电台 -->
<div class="num-item">
<n-icon size="18">
<SvgIcon icon="record" />
</n-icon>
<n-number-animation :from="0" :to="userLikeData.djs?.length ?? 0" />
个电台
</div>
</div>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="like-albums"> 专辑 </n-tab>
<n-tab name="like-playlists"> 歌单 </n-tab>
<n-tab name="like-artists"> 歌手 </n-tab>
<n-tab name="like-videos"> 视频 </n-tab>
<n-tab name="like-djs"> 电台 </n-tab>
</n-tabs>
<!-- 路由页面 -->
<router-view v-slot="{ Component }">
@@ -20,9 +64,13 @@
</template>
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { useRouter } from "vue-router";
const router = useRouter();
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 默认选中
const tabValue = ref(router.currentRoute.value?.name ?? "like-albums");
@@ -34,6 +82,12 @@ const tabChange = (val) => {
path: `/like/${routerPath}`,
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
(val) => (tabValue.value = val?.name ?? "like-playlists"),
);
</script>
<style lang="scss" scoped>
@@ -43,6 +97,30 @@ const tabChange = (val) => {
font-size: 36px;
font-weight: bold;
}
.num {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
.num-item {
display: flex;
flex-direction: row;
align-items: center;
&::after {
content: "/";
margin: 0 8px;
opacity: 0.6;
}
&:last-child {
&::after {
display: none;
}
}
.n-icon {
margin-right: 4px;
}
}
}
.tabs {
margin-bottom: 20px;
}

View File

@@ -8,8 +8,7 @@
v-for="(item, index) in ['我创建的', '我收藏的']"
:key="index"
:bordered="false"
:type="index === plTypeChoose ? 'primary' : 'default'"
class="tag"
:class="['tag', { choose: index === plTypeChoose }]"
round
@click="plTypeChange(index)"
>
@@ -20,12 +19,16 @@
<Transition name="fade" mode="out-in">
<div v-if="plTypeChoose === 0" class="list">
<MainCover
:data="likePlaylistsData.filter((playlist) => playlist.userId === userData?.userId)"
:data="
userLikeData.playlists.filter((playlist) => playlist.userId === userData?.userId)
"
/>
</div>
<div v-else class="list">
<MainCover
:data="likePlaylistsData.filter((playlist) => playlist.userId !== userData?.userId)"
:data="
userLikeData.playlists.filter((playlist) => playlist.userId !== userData?.userId)
"
/>
</div>
</Transition>
@@ -45,7 +48,6 @@
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import { useRouter } from "vue-router";
import formatData from "@/utils/formatData";
const router = useRouter();
const data = siteData();
@@ -54,11 +56,6 @@ const { userData, userLikeData } = storeToRefs(data);
// 默认分类
const plTypeChoose = ref(Number(router.currentRoute.value.query?.type) || 0);
// 处理视频数据
const likePlaylistsData = computed(() => {
return formatData(userLikeData.value.playlists);
});
// 默认分类变化
const plTypeChange = (type) => {
router.push({
@@ -100,6 +97,10 @@ watch(
&:active {
transform: scale(0.95);
}
&.choose {
background-color: var(--main-second-color);
color: var(--main-color);
}
}
}
}

View File

@@ -3,7 +3,7 @@
<Transition name="fade" mode="out-in">
<div v-if="userLikeData.mvs?.length" class="list">
<!-- 列表 -->
<MainCover :data="likeVideosData" columns="1 s:2 m:3 l:4 xl:5" type="mv" />
<MainCover :data="userLikeData.mvs" columns="1 s:2 m:3 l:4 xl:5" type="mv" />
</div>
<n-empty
v-else
@@ -19,13 +19,7 @@
<script setup>
import { storeToRefs } from "pinia";
import { siteData } from "@/stores";
import formatData from "@/utils/formatData";
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 处理视频数据
const likeVideosData = computed(() => {
return formatData(userLikeData.value.mvs, "mv");
});
</script>

View File

@@ -273,7 +273,7 @@ const playAllSongs = async () => {
playSongData.value = albumData.value[0];
playIndex.value = 0;
// 初始化播放器
initPlayer(true);
await initPlayer(true);
} else {
console.log("处于专辑内");
playSongData.value = albumData.value[existingIndex];

544
src/views/List/dj.vue Normal file
View File

@@ -0,0 +1,544 @@
<!-- 电台播客页面 -->
<template>
<div v-if="djId" class="dj">
<Transition name="fade" mode="out-in">
<div v-if="djDetail && Object.keys(djDetail)?.length" class="detail">
<div class="cover">
<!-- 封面 -->
<n-image
:src="djDetail.coverSize.l"
:previewed-img-props="{ style: { borderRadius: '8px' } }"
:preview-src="djDetail.cover"
class="cover-img"
show-toolbar-tooltip
@load="
(e) => {
e.target.style.opacity = 1;
}
"
>
<template #placeholder>
<div class="cover-loading">
<img class="loading-img" src="/images/pic/song.jpg?assest" alt="song" />
</div>
</template>
</n-image>
<!-- 封面背板 -->
<n-image :src="djDetail.coverSize.m" class="cover-shadow" preview-disabled />
</div>
<div class="data">
<!-- 名称 -->
<n-text class="name">
{{ djDetail.name || "未知电台" }}
</n-text>
<div class="creator">
<n-avatar
:src="(djDetail.creator?.avatarUrl + '?param=300y$300').replace(/^http:/, 'https:')"
fallback-src="/images/pic/avatar.jpg?assest"
round
/>
<n-text class="nickname">{{ djDetail.creator?.nickname || "未知创建者" }}</n-text>
<n-text v-if="djDetail.createTime" class="create-time" depth="3">
{{ getTimestampTime(djDetail.createTime) }} 创建
</n-text>
<!-- 标签 -->
<n-tag
v-if="djDetail?.tags"
:bordered="false"
class="tags"
round
@click="
router.push({
path: '/dj-type',
query: {
type: djDetail.tags.id,
name: djDetail.tags.name,
},
})
"
>
{{ djDetail.tags.name }}
</n-tag>
</div>
<!-- 简介 -->
<n-ellipsis
v-if="djDetail.desc"
:tooltip="false"
class="description"
expand-trigger="click"
line-clamp="2"
>
<n-text depth="3">{{ djDetail.desc }}</n-text>
</n-ellipsis>
<n-text v-else class="description">太懒了吧连简介都没写</n-text>
<!-- 数量 -->
<n-space class="num">
<div v-if="djDetail?.count" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
</n-icon>
<n-text depth="3">{{ djDetail.count }}</n-text>
</div>
<div v-if="djDetail?.updateTime" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="clock" />
</n-icon>
<n-text depth="3">{{ getTimestampTime(djDetail.updateTime) }} 更新</n-text>
</div>
</n-space>
</div>
</div>
<div v-else class="detail">
<n-skeleton class="cover" />
<div class="data">
<n-skeleton :repeat="4" text />
</div>
</div>
</Transition>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-space class="left">
<n-button
:disabled="djData === 'empty'"
type="primary"
class="play"
tag="div"
circle
strong
secondary
@click="playAllSongs"
>
<template #icon>
<n-icon size="32">
<SvgIcon icon="play-arrow-rounded" />
</n-icon>
</template>
</n-button>
<n-button size="large" tag="div" round strong secondary @click="likeOrDislike(djId)">
<template #icon>
<n-icon>
<SvgIcon
:icon="isLikeOrDislike(djId) ? 'favorite-outline-rounded' : 'favorite-rounded'"
/>
</n-icon>
</template>
{{ isLikeOrDislike(djId) ? "订阅电台" : "取消订阅" }}
</n-button>
<n-dropdown :options="moreOptions" trigger="hover" placement="bottom-start">
<n-button size="large" tag="div" circle strong secondary>
<template #icon>
<n-icon>
<SvgIcon icon="format-list-bulleted" />
</n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
<n-space class="right">
<!-- 模糊搜索 -->
<Transition name="fade" mode="out-in">
<n-input
v-if="djData !== 'empty' && djData?.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>
</Transition>
</n-space>
</n-space>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<div v-if="djData !== 'empty'" class="list">
<Transition name="fade" mode="out-in">
<div v-if="!searchValue" class="song-list">
<SongList :data="djData" type="dj" />
<!-- 分页 -->
<Pagination
v-if="djData?.length"
:totalCount="totalCount"
:pageNumber="pageNumber"
@pageNumberChange="pageNumberChange"
/>
</div>
<SongList v-else-if="searchData?.length" :data="searchData" type="dj" />
<n-empty
v-else
:description="`搜不到关于 ${searchValue} 的任何节目`"
style="margin-top: 60px"
size="large"
>
<template #icon>
<n-icon>
<SvgIcon icon="search-off" />
</n-icon>
</template>
</n-empty>
</Transition>
</div>
<n-empty v-else class="empty" description="这个电台还没有节目哦" />
</Transition>
</div>
<div v-else class="title">
<n-text class="key">参数不完整</n-text>
<n-button class="back" strong secondary @click="router.go(-1)"> 返回上一页 </n-button>
</div>
</template>
<script setup>
import { NIcon } from "naive-ui";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { musicData, 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 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);
const pageNumber = ref(Number(router.currentRoute.value.query?.page) || 1);
const djDetail = ref(null);
const djData = ref(null);
// 模糊搜索数据
const searchValue = ref(null);
const searchData = ref([]);
const totalCount = ref(0);
// 图标渲染
const renderIcon = (icon) => {
return () => h(NIcon, null, { default: () => h(SvgIcon, { icon }, null) });
};
// 更多操作数据
const moreOptions = [
{
label: "打开源页面链接",
key: "open",
props: {
onclick: () => {
const id = djId.value;
if (id) window.open(`https://music.163.com/#/djradio?id=${id}`);
},
},
icon: renderIcon("link"),
},
];
// 获取电台信息
const getDjDetailData = async (id) => {
try {
if (!id) return false;
// 清空数据
djDetail.value = null;
djData.value = null;
// 获取数据
const detail = await getDjDetail(id);
// 基础信息
djDetail.value = formatData(detail.data, "dj")[0];
} catch (error) {
console.error("获取电台信息出错:", error);
$message.error("获取电台信息出现错误");
}
};
// 获取电台全部节目
const getDjProgramData = async (id, limit = loadSize.value, offset = 0) => {
try {
djData.value = [];
const result = await getDjProgram(id, limit, offset);
console.log(result);
// 数据总数
totalCount.value = result.count;
if (totalCount.value === 0) return (djData.value = "empty");
// 处理数据
djData.value = formatData(result.programs, "dj");
} catch (error) {
console.error("获取电台节目错误:", error);
$message.error("获取电台节目出现错误");
}
};
// 播放电台全部节目
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();
// 是否为空
if (!searchValue || searchValue === "") {
return true;
}
// 返回结果
const result = fuzzySearch(searchValue, djData.value);
searchData.value = result;
}, 300);
// 判断收藏还是取消
const isLikeOrDislike = (id) => {
const djs = userLikeData.value.djs;
if (djs.length) {
return !djs.some((item) => item.id === Number(id));
}
return true;
};
// 订阅 / 取消订阅电台
const likeOrDislike = debounce(async (id) => {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const type = isLikeOrDislike(id) ? 1 : 0;
const result = await likeDj(id, type);
if (result.code === 200) {
$message.success((type === 1 ? "订阅" : "取消订阅") + "成功");
// 更新用户电台
await data.setUserLikeDjs();
} else {
$message.error((type === 1 ? "订阅" : "取消订阅") + "失败,请重试");
}
} catch (error) {
console.error("订阅出错:", error);
$message.error("订阅操作出现错误");
}
}, 300);
// 页数变化
const pageNumberChange = (page) => {
router.push({
path: "/dj",
query: { id: djId.value, page },
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
if (val.name === "dj") {
// 更改参数
pageNumber.value = Number(val.query?.page) || 1;
djId.value = val.query?.id;
// 调用接口
await getDjDetailData(djId.value);
await getDjProgramData(
djId.value,
loadSize.value,
(pageNumber.value - 1) * settings.loadSize,
);
}
},
);
onMounted(async () => {
await getDjDetailData(djId.value);
await getDjProgramData(djId.value, loadSize.value, (pageNumber.value - 1) * settings.loadSize);
});
</script>
<style lang="scss" scoped>
.dj {
.detail {
display: flex;
flex-direction: row;
align-items: stretch;
margin-bottom: 20px;
.cover {
position: relative;
display: flex;
width: 200px;
height: 200px;
min-width: 200px;
margin-right: 20px;
border-radius: 8px;
.cover-img {
width: 100%;
height: 100%;
border-radius: 8px;
z-index: 1;
transition:
filter 0.3s,
transform 0.3s;
:deep(img) {
width: 100%;
opacity: 0;
transition: opacity 0.35s ease-in-out;
}
&:active {
transform: scale(0.98);
}
}
.cover-shadow {
position: absolute;
top: 4px;
height: 100%;
width: 100%;
filter: blur(16px) opacity(0.6);
transform: scale(0.92, 0.96);
z-index: 0;
background-size: cover;
aspect-ratio: 1/1;
}
}
.data {
width: 100%;
.name {
font-size: 30px;
font-weight: bold;
margin-bottom: 12px;
}
.creator {
display: flex;
flex-direction: row;
align-items: center;
.n-avatar {
width: 28px;
height: 28px;
margin-right: 8px;
}
.nickname {
transition: color 0.3s;
cursor: pointer;
&:hover {
color: var(--main-color);
}
}
.create-time {
margin-left: 12px;
font-size: 13px;
}
.tags {
margin-left: 12px;
font-size: 13px;
padding: 0 16px;
line-height: 0;
cursor: pointer;
transition:
transform 0.3s,
background-color 0.3s,
color 0.3s;
&:hover {
background-color: var(--main-second-color);
color: var(--main-color);
}
&:active {
transform: scale(0.95);
}
}
}
.num {
margin-top: 12px;
.num-item {
display: flex;
flex-direction: row;
align-items: center;
.n-icon {
margin-right: 4px;
// color: var(--main-color);
}
}
}
.description {
margin-top: 12px;
.n-text {
display: initial;
}
}
:deep(.n-skeleton) {
&:first-child {
width: 60%;
margin-top: 0;
height: 40px;
}
height: 30px;
margin-top: 12px;
border-radius: 8px;
}
}
}
.menu {
align-items: center;
margin: 26px 0;
.left {
align-items: center;
.play {
--n-width: 46px;
--n-height: 46px;
}
}
.right {
.search {
height: 40px;
width: 130px;
display: flex;
align-items: center;
border-radius: 40px;
transition:
width 0.3s,
background-color 0.3s;
&.n-input--focus {
width: 200px;
}
}
}
}
}
.title {
display: flex;
flex-direction: column;
.key {
margin: 10px 0;
font-size: 36px;
font-weight: bold;
margin-right: 8px;
}
.back {
width: 98px;
}
}
</style>

View File

@@ -364,7 +364,7 @@ const playAllSongs = async () => {
playSongData.value = playListData.value[0];
playIndex.value = 0;
// 初始化播放器
initPlayer(true);
await initPlayer(true);
} else {
console.log("处于歌单内");
playSongData.value = playListData.value[existingIndex];

View File

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

View File

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

View File

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

View File

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

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